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
1316 changed files with 335350 additions and 10834 deletions
+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
+3 -135
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"repository": "MacRimi/ProxMenux",
"updatedAt": "2026-09-14T09:55:40.963Z",
"updatedAt": "2026-09-02T08:48:44.825Z",
"backfilledAt": "2026-07-22T18:09:20.834Z",
"totals": {
"downloads": 0,
"clones": 31307
"clones": 22221
},
"assets": {},
"traffic": {
@@ -133,54 +133,6 @@
"2026-09-01": {
"count": 552,
"uniques": 396
},
"2026-09-02": {
"count": 1065,
"uniques": 726
},
"2026-09-03": {
"count": 1489,
"uniques": 770
},
"2026-09-04": {
"count": 938,
"uniques": 642
},
"2026-09-05": {
"count": 722,
"uniques": 513
},
"2026-09-06": {
"count": 884,
"uniques": 445
},
"2026-09-07": {
"count": 655,
"uniques": 485
},
"2026-09-08": {
"count": 597,
"uniques": 440
},
"2026-09-09": {
"count": 615,
"uniques": 431
},
"2026-09-10": {
"count": 542,
"uniques": 383
},
"2026-09-11": {
"count": 646,
"uniques": 342
},
"2026-09-12": {
"count": 451,
"uniques": 336
},
"2026-09-13": {
"count": 482,
"uniques": 362
}
}
},
@@ -3417,91 +3369,7 @@
"stars": 2926,
"forks": 152,
"downloads": 0,
"clones": 23286
},
{
"date": "2026-09-03",
"stars": 2927,
"forks": 152,
"downloads": 0,
"clones": 24775
},
{
"date": "2026-09-04",
"stars": 2930,
"forks": 152,
"downloads": 0,
"clones": 25713
},
{
"date": "2026-09-05",
"stars": 2944,
"forks": 152,
"downloads": 0,
"clones": 26435
},
{
"date": "2026-09-06",
"stars": 2954,
"forks": 152,
"downloads": 0,
"clones": 27319
},
{
"date": "2026-09-07",
"stars": 2959,
"forks": 152,
"downloads": 0,
"clones": 27974
},
{
"date": "2026-09-08",
"stars": 2959,
"forks": 153,
"downloads": 0,
"clones": 28571
},
{
"date": "2026-09-09",
"stars": 2960,
"forks": 153,
"downloads": 0,
"clones": 29186
},
{
"date": "2026-09-10",
"stars": 2961,
"forks": 154,
"downloads": 0,
"clones": 29728
},
{
"date": "2026-09-11",
"stars": 2965,
"forks": 154,
"downloads": 0,
"clones": 30374
},
{
"date": "2026-09-12",
"stars": 2967,
"forks": 154,
"downloads": 0,
"clones": 30825
},
{
"date": "2026-09-13",
"stars": 2971,
"forks": 154,
"downloads": 0,
"clones": 31307
},
{
"date": "2026-09-14",
"stars": 2972,
"forks": 154,
"downloads": 0,
"clones": 31307
"clones": 22221
}
]
}
+166 -48
View File
@@ -4,10 +4,19 @@ Auto-translate missing keys in AppImage/messages/<locale>/common.json
against the English source (AppImage/messages/en/common.json).
Guardrails:
- Keys already translated in a target locale are PRESERVED. A key is
considered "already translated" when the target value is non-empty
AND differs from the English source. This protects human-curated
locales (Vaso73's sk) from being overwritten.
- 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
@@ -16,6 +25,13 @@ Guardrails:
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.
@@ -58,36 +74,38 @@ DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
Non-string leaves are coerced to str (should not happen in messages
catalogs, but keeps the function total)."""
out: dict[str, str] = {}
for key, value in node.items():
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(flatten(value, path))
elif value is None:
out[path] = ""
else:
out[path] = str(value)
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[str, str]) -> dict:
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
out: dict = {}
for path, value in flat.items():
parts = path.split(".")
cursor = out
for part in parts[:-1]:
existing = cursor.get(part)
if not isinstance(existing, dict):
existing = {}
cursor[part] = existing
cursor = existing
cursor[parts[-1]] = value
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]]:
@@ -98,7 +116,9 @@ def protect_placeholders(text: str) -> tuple[str, list[str]]:
def _swap(match: re.Match) -> str:
placeholders.append(match.group(0))
return f"__PMX_PH_{len(placeholders) - 1}__"
# 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
@@ -109,10 +129,29 @@ def restore_placeholders(text: str, placeholders: list[str]) -> str:
fallback assignment (target = existing or EN) upstream catches
the worst case."""
for i, original in enumerate(placeholders):
text = text.replace(f"__PMX_PH_{i}__", original)
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,
@@ -121,7 +160,7 @@ def translate_one(
timeout: int,
appimage_path: Path,
) -> str:
"""Dispatch to the correct provider. Reuses the same three
"""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":
@@ -130,6 +169,8 @@ def translate_one(
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
@@ -182,9 +223,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--provider",
choices=("appimage", "googletrans", "google-web"),
choices=("argos", "appimage", "googletrans", "google-web"),
default="googletrans",
help="Translation provider. Default matches build_translation_cache.",
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",
@@ -198,9 +243,16 @@ def build_arg_parser() -> argparse.ArgumentParser:
"--refresh",
action="store_true",
help=(
"Re-translate EVERY key, ignoring existing translations. "
"Dangerous: this DOES overwrite human-curated strings. "
"Use only when you know what you are doing."
"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(
@@ -240,11 +292,53 @@ def main() -> int:
print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True)
total_failures: list[tuple[str, str, str]] = []
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
@@ -261,14 +355,25 @@ def main() -> int:
# 3. Auto-fills from prior runs whose output happened to
# match EN — leaving them alone is the intended
# steady-state, not a bug.
# When someone genuinely wants to redo everything, --refresh
# is still available (and is destructive by design).
missing: list[str] = []
# 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 en_value:
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 args.refresh or not existing:
if not existing or ".".join(str(part) for part in key) in refresh_keys:
missing.append(key)
if args.limit > 0:
@@ -276,10 +381,12 @@ def main() -> int:
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
if not missing:
print(f" {lang}: nothing to do", flush=True)
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, str, str]] = []
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
for index, key in enumerate(missing, start=1):
en_value = en_flat[key]
@@ -336,7 +443,18 @@ def main() -> int:
# 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.
return 0
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
+436 -24
View File
@@ -57,6 +57,16 @@ PROTECTED_TECHNICAL_TERMS = (
"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",
@@ -69,6 +79,40 @@ PROTECTED_TECHNICAL_TERMS = (
"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(
@@ -81,26 +125,159 @@ 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."""
"""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"__PMX_TERM_{len(protected) - 1}__"
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"__PMX_TERM_{index}__", original)
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]:
@@ -151,6 +328,93 @@ def extract_translate_texts(
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
@@ -269,6 +533,20 @@ def clean_translation(value: str) -> str:
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,
@@ -277,8 +555,11 @@ def translate_text(
timeout: int,
appimage_path: Path,
) -> str:
protected_text, protected_terms = protect_technical_terms(text)
if provider == "googletrans":
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)
@@ -289,7 +570,9 @@ def translate_text(
else:
raise ValueError(f"Unknown provider: {provider}")
translated = restore_technical_terms(clean_translation(translated), protected_terms)
return translated or text
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]:
@@ -304,6 +587,35 @@ def load_language_cache(path: Path) -> dict[str, str]:
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")
@@ -319,6 +631,30 @@ def build_arg_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",
@@ -351,7 +687,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--provider",
choices=("appimage", "googletrans", "google-web"),
choices=("argos", "appimage", "googletrans", "google-web"),
default="appimage",
help="Translation provider to use. Default: appimage",
)
@@ -364,10 +700,21 @@ def build_arg_parser() -> argparse.ArgumentParser:
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="Translate all entries again instead of reusing existing cache values.",
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",
@@ -406,17 +753,45 @@ def main() -> int:
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
}
next_by_lang: dict[str, dict[str, str]] = {lang: {} 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
@@ -427,7 +802,13 @@ def main() -> int:
for index, text in enumerate(texts, start=1):
done += 1
if not args.refresh and existing.get(text):
# 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:
@@ -435,20 +816,51 @@ def main() -> int:
continue
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
try:
next_by_lang[lang][text] = translate_text(
text,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
except Exception as exc:
next_by_lang[lang][text] = existing.get(text, text)
failures.append((text, lang, str(exc)))
print(f" failed: {exc}", file=sys.stderr, 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)
+69 -23
View File
@@ -18,6 +18,7 @@ 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,
)
@@ -86,6 +87,7 @@ PROTECTED_PATTERNS = (
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>",
@@ -100,6 +102,11 @@ TERM_RE = re.compile(
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",
@@ -206,13 +213,13 @@ def needs_translation(
source: str,
target: Any,
path: tuple[str | int, ...],
refresh: bool,
forced_tokens: set[str] | None = None,
) -> bool:
if should_copy(source, path):
return False
if refresh:
return True
# 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
@@ -279,6 +286,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
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]] = []
@@ -296,7 +314,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
# 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.
token = f"[[[PMXDOC{index:04d}]]]"
#
# 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]
@@ -317,13 +340,20 @@ def restore_text(text: str, mapping: dict[str, str]) -> str:
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 == "googletrans":
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)
@@ -446,7 +476,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic
def pending_leaves(
source: Any,
target: Any,
refresh: bool,
forced_tokens: set[str] | None = None,
) -> list[Leaf]:
return [
@@ -456,7 +485,6 @@ def pending_leaves(
leaf.source,
get_at_path(target, leaf.path),
leaf.path,
refresh,
forced_tokens,
)
]
@@ -533,13 +561,14 @@ def translate_file(
memory: dict[str, str],
translate: Callable[[str, str], str],
args: argparse.Namespace,
) -> tuple[Any | None, list[str], int]:
) -> 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 not args.refresh and leaf.source in memory:
if leaf.source in memory:
resolved[leaf.path] = memory[leaf.source]
continue
jobs.setdefault(leaf.source, []).append(leaf.path)
@@ -566,12 +595,17 @@ def translate_file(
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)
if failures:
return None, failures, len(jobs)
return merge_tree(source, target, resolved), [], len(jobs)
# 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:
@@ -586,8 +620,12 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--provider",
choices=("google-web", "googletrans", "appimage"),
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(
@@ -623,7 +661,9 @@ def build_parser() -> argparse.ArgumentParser:
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
@@ -646,7 +686,8 @@ def main() -> int:
print("No target languages selected.", file=sys.stderr)
return 2
if args.refresh:
print("WARNING: --refresh overwrites existing translations in the selected scope.")
print("--refresh no longer overwrites existing translations; "
"name the leaves with --tokens instead.")
translate = provider_function(args)
state = load_source_state(source_state_path)
@@ -716,7 +757,7 @@ def main() -> int:
pending_tokens.update(changed_tokens)
pending_tokens.update(
leaf_token(leaf.path)
for leaf in pending_leaves(source, target, args.refresh)
for leaf in pending_leaves(source, target)
)
set_state_pending_tokens(state, language, relative, pending_tokens)
@@ -742,6 +783,11 @@ def main() -> int:
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:
@@ -773,7 +819,7 @@ def main() -> int:
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, args.refresh, queued_tokens)
leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
@@ -816,7 +862,7 @@ def main() -> int:
f"({len(leaves)} strings{suffix})",
flush=True,
)
built, failures, calls = translate_file(
built, failures, calls, failed_tokens = translate_file(
source,
target,
leaves,
@@ -827,18 +873,18 @@ def main() -> int:
)
if failures:
total_failures += len(failures)
set_state_pending_tokens(state, language, relative_key, queued_tokens)
write_json(source_state_path, state)
print(
f" skipped atomically after {len(failures)} failures "
f"({calls} calls)",
f" {len(failures)} strings left untranslated ({calls} calls)",
file=sys.stderr,
)
for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr)
continue
write_json(target_path, built)
set_state_pending_tokens(state, language, relative_key, set())
# 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)
@@ -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()
@@ -58,7 +58,7 @@ class WebDocsI18nTests(unittest.TestCase):
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, refresh=False), [])
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
@@ -67,7 +67,6 @@ class WebDocsI18nTests(unittest.TestCase):
leaves = MODULE.pending_leaves(
source,
target,
refresh=False,
forced_tokens={token},
)
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
@@ -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)
+25 -1
View File
@@ -32,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
@@ -42,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
+25 -1
View File
@@ -32,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
@@ -42,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
+25 -1
View File
@@ -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
+25 -1
View File
@@ -32,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
@@ -42,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
@@ -15,9 +15,15 @@ on:
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:
@@ -79,6 +85,11 @@ jobs:
--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
@@ -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"
+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
+8 -6
View File
@@ -41,13 +41,15 @@ Thumbs.db
/web/.next
/web/out
# Build artifacts generated by web's prebuild + build scripts.
# `prebuild` runs `sync:scripts` which rsyncs ../scripts/ into
# public/scripts/. `build` runs pagefind --site out which writes the
# search index into public/pagefind/. Both are regenerated fresh by
# the GitHub Pages CI on every deploy; committing them would just
# bloat the repo and produce constant noise in `git status`.
# 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
+1 -1
View File
@@ -1 +1 @@
fc092901ab67df01316a389e92502c26248fe61a68b705d9eacfd717a40c2391 ProxMenux-1.2.6.AppImage
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
+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>
)
}
+37 -12
View File
@@ -9,6 +9,7 @@ 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
@@ -248,8 +249,8 @@ export function AppsDashboard() {
return map
}, [links, t])
const sortedCategoryEntries = useMemo(
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
[categoryCounts],
() => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
[categoryCounts, t],
)
// ─── Controls state ────────────────────────────────────────────
@@ -327,7 +328,8 @@ export function AppsDashboard() {
l.appName.toLowerCase().includes(q) ||
(l.ctName || "").toLowerCase().includes(q) ||
(l.vmid != null && String(l.vmid).includes(q)) ||
(l.category || "").toLowerCase().includes(q)
(l.category || "").toLowerCase().includes(q) ||
getCategoryLabel(t, l.category || "").toLowerCase().includes(q)
)
}
const sorted = [...filtered]
@@ -344,7 +346,7 @@ export function AppsDashboard() {
// category — grouped alphabetically, then by app name inside
const catA = a.category || uncatKey
const catB = b.category || uncatKey
const c = catA.localeCompare(catB)
const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB))
return c !== 0 ? c : a.appName.localeCompare(b.appName)
})
return sorted
@@ -432,7 +434,7 @@ export function AppsDashboard() {
>
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
{sortedCategoryEntries.map(([cat, n]) => (
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
<option key={cat} value={cat}>{`${getCategoryLabel(t, cat)} · ${n}`}</option>
))}
</select>
@@ -532,6 +534,7 @@ function CardsGrid({
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">
@@ -572,7 +575,7 @@ function CardsGrid({
{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>{cat}</span>
<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) => (
@@ -592,9 +595,9 @@ function CardsGrid({
// `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) {
function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
@@ -634,13 +637,22 @@ function AppCard({
}
}
// 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" : "CT"
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
const hasBinding = link.vmid != null
return (
@@ -679,7 +691,20 @@ function AppCard({
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
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>
@@ -715,9 +740,9 @@ function AppCard({
<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={link.category}
title={getCategoryLabel(t, link.category)}
>
{link.category}
{getCategoryLabel(t, link.category)}
</span>
)}
</div>
+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>
)
}
+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>
)
}
+5 -3
View File
@@ -1,9 +1,11 @@
"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"
@@ -237,7 +239,7 @@ export function CustomLinkEditor({
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
@@ -258,7 +260,7 @@ export function CustomLinkEditor({
<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" : "CT"} {g.vmid} · {g.name}
{g.type === "qemu" ? "VM" : "LXC"} {g.vmid} · {g.name}
</SelectItem>
))}
</SelectContent>
@@ -288,7 +290,7 @@ export function CustomLinkEditor({
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
{t("apps.customLinkCancel")}
</Button>
<Button
+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>
)
}
+1 -1
View File
@@ -629,7 +629,7 @@ export default function Hardware() {
const hasRealtimeData = (): boolean => {
if (!realtimeGPUData) return false
// Esto permite mostrar datos incluso cuando la GPU está inactiva (valores en 0 o null)
// Show the data even while the GPU is idle, when values are 0 or null
return realtimeGPUData.has_monitoring_tool === true
}
+64 -1
View File
@@ -299,6 +299,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
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"),
@@ -321,7 +336,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"No VM startup failures": t("healthStatus.details.noVmFailures"),
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
}
if (exact[value]) return exact[value]
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] })
@@ -347,6 +362,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
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$/)
@@ -359,6 +376,52 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
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
}
+49 -31
View File
@@ -49,7 +49,7 @@ import { ScriptTerminalModal } from "./script-terminal-modal"
import { RestoreProgressCard } from "./restore-progress-card"
import { fetchApi, getApiUrl } from "../lib/api-config"
import { fetchTerminalTicket } from "../lib/terminal-ws"
import { formatStorage, formatBytes } from "../lib/utils"
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
import { getStorageUsageColor } from "../lib/storage-usage-color"
import { useI18n, useT } from "../lib/i18n/provider"
@@ -618,8 +618,11 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
<div className="flex-1 space-y-1">
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
<div className="text-muted-foreground">
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatch.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
<code className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.name}</code>
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>
: part
)}
</div>
</div>
</div>
@@ -1300,7 +1303,7 @@ export function HostBackup() {
</div>
) : unifiedArchives.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">
{t("backup.archives.emptyBefore")} <span className="font-medium">{t("backup.manual.run")}</span> {t("backup.archives.emptyAfter")}
{t("backup.archives.emptyMessage")}
</div>
) : (
<div className="space-y-2">
@@ -1510,7 +1513,7 @@ export function HostBackup() {
)}
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
variant="outline" className={cancelButtonClass}
onClick={() => setJobToDelete(null)}
disabled={busyJobId === jobToDelete?.id}
>
@@ -2131,8 +2134,11 @@ function InspectModal({
<div className="flex-1 space-y-1">
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
<div className="text-muted-foreground">
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatchInspect.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
<code className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.name}</code>
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>
: part
)}
</div>
</div>
</div>
@@ -2302,7 +2308,7 @@ function InspectModal({
</DialogHeader>
{kf && <KeyfileErrorBlock err={kf} />}
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
<Button variant="outline" className={cancelButtonClass} onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
</div>
</DialogContent>
</Dialog>
@@ -2408,7 +2414,7 @@ function InspectModal({
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
{t("actions.cancel")}
</Button>
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
@@ -2439,7 +2445,7 @@ function InspectModal({
{archiveLog?.content ?? ""}
</pre>
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowArchiveFullLog(false)}>{t("actions.close")}</Button>
</div>
</DialogContent>
</Dialog>
@@ -3879,7 +3885,14 @@ function CreateJobDialog({
{t("backup.encryption.encryptBackups")}
</div>
<p className="text-[11px] text-muted-foreground mt-1">
{t("backup.encryption.pbsHelpBefore")} <code className="font-mono">--keyfile</code> {t("backup.encryption.pbsHelpMiddle")} <code className="font-mono">proxmox-backup-client backup</code>. {t("backup.encryption.pbsHelpAfter")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) {t("backup.encryption.pbsHelpEnd")}
{t("backup.encryption.pbsHelp").split(/(\{keyfile\}|\{option\}|\{mode\})/).map((part, index) => {
const literals: Record<string, string> = {
"{keyfile}": "/usr/local/share/proxmenux/pbs-key.conf",
"{option}": "--keyfile",
"{mode}": "0600",
}
return literals[part] ? <code key={index} className="font-mono break-all">{literals[part]}</code> : part
})}
</p>
</div>
</label>
@@ -4089,7 +4102,7 @@ function CreateJobDialog({
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
{t("backup.destinations.localAttachHelpBefore")} <code className="font-mono">/dump</code> {t("backup.destinations.localAttachHelpMiddle")} <code className="font-mono">/var/lib/vz/dump</code>.
{t("backup.destinations.localAttachHelpMessage")}
</p>
</>
) : (
@@ -4303,7 +4316,7 @@ function CreateJobDialog({
{/* Footer navigation */}
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button
variant="ghost"
variant="outline" className={cancelButtonClass}
onClick={onClose}
disabled={submitting}
>
@@ -5200,7 +5213,7 @@ function ManualBackupDialog({
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button variant="ghost" onClick={onClose} disabled={submitting}>
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>
{t("actions.cancel")}
</Button>
<div className="flex items-center gap-2">
@@ -5709,13 +5722,13 @@ function DestinationsSection({
)}
{backups > 0 && (
<div className="px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5">
{t("backup.destinations.backupsKeptBefore", { count: backups })} <span className="font-medium">{t("backup.destinations.kept")}</span>.
{t("backup.destinations.backupsKeptMessage")}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
<Button variant="outline" className={cancelButtonClass} onClick={() => setConfirmingDest(null)}>
{t("actions.cancel")}
</Button>
<Button variant="destructive" onClick={confirmDeleteDest}>
@@ -6039,7 +6052,7 @@ function ConfigureDestinationWizard({
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>{t("actions.cancel")}</Button>
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>{t("actions.cancel")}</Button>
</div>
</DialogContent>
</Dialog>
@@ -6177,7 +6190,13 @@ function AddDestinationDialog({
// back to the URL match; default 22 when neither is set.
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
setBorgSshPort(String(port || 22))
setBorgSshRemotePath(`/${ssh[4]}`)
// `./path` (relative to the SSH user's home) and `~/path` are Borg
// path forms of their own — only an absolute path gets the slash
// the URL dropped.
const remotePath = ssh[4]
setBorgSshRemotePath(
remotePath.startsWith("./") || remotePath.startsWith("~/") ? remotePath : `/${remotePath}`,
)
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
setBorgRepo("")
} else {
@@ -6429,7 +6448,7 @@ function AddDestinationDialog({
<Label htmlFor="borgSshUser">{t("backup.fields.sshUser")}</Label>
<Input id="borgSshUser" value={borgSshUser} onChange={(e) => setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />
<p className="text-xs text-muted-foreground mt-1">
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
{t("backup.destinations.sshUserHelpMessage")}
</p>
</div>
<div className="grid grid-cols-[1fr_100px] gap-3">
@@ -6458,14 +6477,13 @@ function AddDestinationDialog({
<Label htmlFor="borgKeyPath">{t("backup.fields.sshKeyPath")}</Label>
<Input id="borgKeyPath" value={borgSshKeyPath} onChange={(e) => setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />
<p className="text-xs text-muted-foreground mt-1">
{t("backup.destinations.sshKeyHelpBefore")}
{" "}{t("backup.destinations.sshKeyHelpMiddle")} <span className="font-medium text-foreground">{t("backup.actions.generateKey")}</span> {t("backup.destinations.sshKeyHelpAfter")}
{t("backup.destinations.sshKeyHelpMessage", { action: t("backup.actions.prepareSshKey") })}
</p>
</div>
<div className="rounded-md border border-border bg-card p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</span>
<span className="text-xs font-medium">{t("backup.destinations.sshKeySetupTitle")}</span>
<Button
type="button"
size="sm"
@@ -6475,13 +6493,13 @@ function AddDestinationDialog({
onClick={generateBorgKey}
>
{generatingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
{generatedKey ? t("backup.actions.regenerate") : t("backup.actions.generateKey")}
{t("backup.actions.prepareSshKey")}
</Button>
</div>
{generatedKey ? (
<>
<p className="text-[11px] text-muted-foreground">
{t("backup.destinations.appendAuthorizedKeyBefore")} <code className="font-mono">~{borgSshUser}/.ssh/authorized_keys</code>:
{t("backup.destinations.sshAuthorizedKeyHelp", { user: borgSshUser })}
</p>
<textarea
readOnly
@@ -6492,7 +6510,7 @@ function AddDestinationDialog({
</>
) : (
<p className="text-[11px] text-muted-foreground">
{t("backup.destinations.createsSshKeyBefore")} <code className="font-mono">borg serve</code> {t("backup.destinations.createsSshKeyAfter")}
{t("backup.destinations.sshKeySetupHelp")}
</p>
)}
</div>
@@ -6595,7 +6613,7 @@ function AddDestinationDialog({
)}
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border">
<Button variant="ghost" onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
<Button
onClick={handleSave}
disabled={!canSubmit || submitting}
@@ -6844,7 +6862,7 @@ function UsbPicker({
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
variant="outline" className={cancelButtonClass}
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
disabled={busyKey === formatTarget?.path_or_device}
>
@@ -7267,7 +7285,7 @@ function UsbDrivesSection() {
</div>
)}
<div className="flex justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
<Button variant="outline" className={cancelButtonClass} onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
{t("actions.cancel")}
</Button>
<Button
@@ -7763,7 +7781,7 @@ function JobDetailModal({
{detail?.id}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
<Button variant="outline" className={cancelButtonClass} onClick={() => setShowDisableConfirm(false)}>
{t("actions.cancel")}
</Button>
<Button
@@ -8146,7 +8164,7 @@ function PbsKeyfileRecoveryDialog({
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={busy}>
<Button variant="outline" className={cancelButtonClass} onClick={onClose} disabled={busy}>
{t("actions.cancel")}
</Button>
<Button
@@ -9021,7 +9039,7 @@ function RestoreOptionsModal({
{t("backup.actions.back")}
</Button>
)}
<Button variant="ghost" onClick={onClose}>
<Button variant="outline" className={cancelButtonClass} onClick={onClose}>
{t("actions.cancel")}
</Button>
{step === "custom" && (
+8 -3
View File
@@ -363,10 +363,12 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
.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; }
@@ -532,7 +534,10 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div>
</div>
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</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 -->
+2 -1
View File
@@ -10,6 +10,7 @@ import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
import Image from "next/image"
interface LoginProps {
@@ -297,7 +298,7 @@ export function Login({ onLogin }: LoginProps) {
</form>
</div>
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
<p className="text-center text-sm text-muted-foreground">{`ProxMenux Monitor v${APP_VERSION}`}</p>
</div>
</div>
)
+500 -59
View File
@@ -33,8 +33,9 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
import { useT } from "@/lib/i18n/provider"
// installed_via is optional now — an empty value means "register only,
@@ -42,7 +43,7 @@ import { useT } from "@/lib/i18n/provider"
// apps and casual "just want a link" registrations use this default.
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
"python_dist" | "docker_label" | "docker_exec" |
"command" | "manual"
"command" | "manual" | "oci_image"
type GithubSource = "releases" | "tags"
interface PortEntry {
@@ -92,10 +93,14 @@ interface AppConfig {
health_path?: string
logo_url?: string
helper_slug?: string
// Set when the application runs inside Docker: registered for its identity
// and installed version, updated by the image it comes from.
update_via?: string
// Preserved here even though this editor does not execute updates.
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
update_command?: string
update_method?: "none" | "helper" | "custom"
hide_no_updater_notice?: boolean
// Per-app opt-out for the `app_update_available` notification.
// Absent / true = notify; false = silenced. Set from the bell
@@ -106,15 +111,30 @@ interface AppConfig {
exclude_from_badge?: boolean
}
// A port as the installation record states it: the application answers on
// the first one, and any other is a service of its own (go2rtc next to Frigate).
interface PortDetail {
port: number
scheme: string | null
path: string | null
description: string
logo_url: string
}
interface DetectedApp {
slug: string
name: string
logo_url?: string | null
default_ports?: number[]
port_details?: PortDetail[] | null
// Categoría preset from helpers_cache.category_names[0] — used to
// auto-fill the Web Link editor when the user clicks "Register".
category?: string | null
tracking_suggestion?: TrackingSuggestion | null
// The scheme the application is served on, when it is known rather than
// guessed. A ProxMenux OCI install records it; Chromium answers on 3001
// over https, which no port heuristic can tell from 3000 over http.
scheme?: "http" | "https" | null
}
interface AppState {
@@ -124,12 +144,40 @@ interface AppState {
update_available: boolean | null
error: string | null
checked_at: string | null
// An application installed from an OCI image: the image it runs and the
// one its registry publishes today. The image decides whether there is an
// update; the version above only says which application it carries.
installed_digest?: string | null
latest_digest?: string | null
image_created?: string | null
latest_image_created?: string | null
image_reference?: string | null
image_repository?: string | null
}
// The repository of an OCI image, shown the way the repository of a tracked
// application is: owner/name for GitHub, the image name for Docker Hub.
export const ociRepoLabel = (url: string) =>
url.replace(/^https?:\/\/(www\.)?/, "")
.replace(/^github\.com\//, "")
.replace(/^hub\.docker\.com\/(r|_)\//, "")
.replace(/\/$/, "")
// "2026-09-24 · a3f21c08": an image as a reader can compare two of them.
export const ociImageLabel = (created?: string | null, digest?: string | null) =>
[created ? created.slice(0, 10) : null, digest ? digest.split(":").pop()!.slice(0, 8) : null]
.filter(Boolean)
.join(" · ")
interface AppEntry extends AppConfig {
id: string
state?: AppState
created_at?: string
// Resolved server-side from the image a delegated app updates with. It is
// not the app's own upstream — the app deliberately has none — so it is
// kept in its own field rather than folded into state.latest_version.
docker_available_version?: string | null
docker_update_available?: boolean | null
}
interface SidecarResponse {
@@ -209,20 +257,54 @@ interface DockerWebLinkSuggestion {
logo_url?: string | null
}
// Identity of a container ProxMenux installed from an OCI image. It comes
// from the installation record rather than a probe, so it names the
// application even when the guest also ships something else — CT 152 runs
// Chromium and carries a docker client the probe reported instead.
interface OciInstance {
template_id: string | null
image_reference: string | null
repository: string | null
scheme: string | null
port: number | null
path: string | null
website: string | null
// The ports of an application with no web address: a service that is
// reached on them and has no page to open.
service_ports?: { port: number; protocol: string }[] | null
}
interface Suggestions {
ready?: boolean
name_suggestion: string | null
helper_slug: string | null
oci_instance?: OciInstance | null
port_suggestions: number[]
web_path_hint: string | null
tracking_suggestion?: TrackingSuggestion | null
default_ports?: number[]
port_details?: PortDetail[] | null
logo_url?: string | null
category?: string | null
extras?: DetectedApp[]
docker_workloads?: DockerWorkload[]
docker_web_links?: DockerWebLinkSuggestion[]
}
// A registrable application inside Docker. Its image remains the updater;
// registration adds identity and version tracking, not a second update path.
interface DockerWorkload {
slug: string
name: string
logo_url?: string | null
container_name?: string
installed_version?: string | null
installed_via?: string | null
tracking_suggestion?: TrackingSuggestion | null
default_ports?: number[]
category?: string | null
}
// Compact catalog entry — one row for every registerable app the
// picker can offer. Fetched once from /api/apps/catalog on panel
// mount, filtered client-side while the user types.
@@ -264,6 +346,10 @@ interface Props {
ctIp?: string | null
onChange?: () => void
managed?: ManagedAppInfo | null
// What the OCI record says about this container, known without probing
// it: whether ProxMenux installed it from an image, and whether it is a
// secondary container of a multi-container application.
oci?: { instance: boolean; memberOf?: { vmid: number; label: string } | null } | null
// Optional seed payload from the parent's cross-open ref cache. When
// supplied, the panel renders with real content on the very first
// frame and only revalidates silently in the background — no
@@ -339,7 +425,8 @@ function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?:
const custom = (customUrl || "").trim()
if (custom) return custom
if (!ip || ip === "DHCP" || !port) return null
return `${scheme || defaultSchemeFor(port)}://${ip}:${port}`
const host = ip.includes(":") && !ip.startsWith("[") ? `[${ip}]` : ip
return `${scheme || defaultSchemeFor(port)}://${host}:${port}`
}
// Suggest a dpkg/apk package name from a friendly app name — lowercase,
@@ -364,7 +451,7 @@ function parseArgvInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean)
}
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
@@ -375,6 +462,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const [loading, setLoading] = useState(!seed)
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchingApplications, setSearchingApplications] = useState(false)
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
@@ -457,6 +545,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
useEffect(() => {
if (managed) return
return subscribeLxcApps(vmid, bundle => {
setSidecar(bundle.sidecar)
setSuggestions(bundle.suggestions)
})
}, [vmid, managed])
useEffect(() => { load() }, [load])
// Live Docker Hub preview. Debounced so typing an image/regex does not
@@ -515,6 +611,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
if (lower.includes("github rate limited")) {
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
}
if (lower.startsWith("registry unreachable")) {
return t("vmLxc.appEditor.upstreamErrorRegistry")
}
return msg
}
@@ -556,14 +655,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const detectedList: DetectedApp[] = useMemo(() => {
if (!suggestions) return []
const out: DetectedApp[] = []
if (suggestions.helper_slug && suggestions.name_suggestion) {
// A ProxMenux OCI install has no helper slug — nothing was installed by a
// community script — so its template id identifies it instead.
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
if (primarySlug && suggestions.name_suggestion) {
out.push({
slug: suggestions.helper_slug,
slug: primarySlug,
name: suggestions.name_suggestion,
logo_url: suggestions.logo_url,
default_ports: suggestions.default_ports,
port_details: suggestions.port_details,
category: suggestions.category,
tracking_suggestion: suggestions.tracking_suggestion,
scheme: suggestions.oci_instance?.scheme === "https" ? "https"
: suggestions.oci_instance?.scheme === "http" ? "http" : null,
})
}
const seen = new Set(out.map((d) => d.slug))
@@ -594,12 +699,58 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)
// Alias for pre-existing consumers (post-registration chip strip).
const unregisteredDetected = visibleDetected
// Applications running inside Docker. They are filtered the same way, and
// deliberately NOT nested inside the Docker chip: that chip disappears the
// moment Docker is registered, which is exactly when these become
// registrable.
const visibleWorkloads = (suggestions?.docker_workloads || []).filter(
(w) => !registeredSlugs.has(w.slug) && !dismissedSlugs.has(w.slug),
)
// Detections the user hid and could restore from the Register-a-
// different-app panel. Not affected by registration state.
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
// A container installed from an OCI image holds exactly the application its
// record names, so there is nothing to search for and nothing else to add.
// What can go stale is what the record and the registry say, and this
// reads both again.
const isOciInstall = !!suggestions?.oci_instance || !!oci?.instance
const ociMemberOf = oci?.memberOf ?? null
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
useEffect(() => {
if (!isOciAdguard) return
let cancelled = false
fetchApi<{ available: boolean }>(`/api/vms/${vmid}/apps/adguard-setup`)
.then((result) => { if (!cancelled) setAdguardSetupAvailable(result.available === true) })
.catch(() => { if (!cancelled) setAdguardSetupAvailable(false) })
return () => { cancelled = true }
}, [vmid, isOciAdguard])
const refreshOciData = async () => {
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
try {
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
method: "POST",
})
setSuggestions(result)
let next = sidecar
if (apps.length > 0) {
next = await fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
setSidecar(next)
}
if (next) setLxcAppsCached(vmid, next, result)
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
} finally {
setSearchingApplications(false)
}
}
const searchInstalledApplications = async () => {
const before = new Set(visibleDetected.map((item) => item.slug))
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
@@ -613,6 +764,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const detected = new Set<string>()
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
for (const item of result.docker_workloads || []) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
@@ -680,7 +832,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
// Preserved explicitly: saving replaces the whole record, so dropping
// it here would silently un-delegate the app the first time someone
// edits one of its ports.
update_via: (existing as { update_via?: string }).update_via || "",
update_command: existing.update_command || "",
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
exclude_from_badge: existing.exclude_from_badge === true,
@@ -709,10 +866,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// Docker endpoints come from the real published host-port mappings
// listed under Web links. Do not pre-save a catalog default such as
// 9000; the user explicitly chooses which workload links to add.
if (p.slug !== "docker" && p.default_ports?.length) {
seed.ports = p.default_ports.map((port) => ({
const suggestedPorts = isOciAdguard && p.slug === "image-adguard-home"
? [80, ...(adguardSetupAvailable ? [3000] : [])]
: p.default_ports || []
if (p.port_details?.length && !(isOciAdguard && p.slug === "image-adguard-home")) {
seed.ports = p.port_details.map((d) => ({
port: d.port,
...(d.description ? { description: d.description } : {}),
scheme: d.scheme === "https" ? "https" as const
: d.scheme === "http" ? "http" as const : defaultSchemeFor(d.port),
web_path: d.path || s?.web_path_hint || "",
...(d.logo_url ? { logo_url: d.logo_url } : {}),
...(p.category ? { category: p.category } : {}),
}))
} else if (p.slug !== "docker" && suggestedPorts.length) {
seed.ports = suggestedPorts.map((port) => ({
port,
scheme: defaultSchemeFor(port),
...(isOciAdguard && port === 3000 ? { description: "Config" } : {}),
// A recorded scheme beats the port heuristic: getting this wrong
// hands the user a link that cannot connect.
scheme: p.scheme || defaultSchemeFor(port),
web_path: s?.web_path_hint || "",
// Auto-fill Categoría from helpers_cache.category_names[0]
// when the catalog entry carries one. User can still change
@@ -745,6 +918,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
docker_image: (t as any).docker_image || "",
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
}
// A containerised workload delegates its update to its image, and
// the backend refuses a delegation that also carries an upstream —
// so the upstream fields seeded above are cleared, not just ignored.
if ((t as { update_via?: string }).update_via === "docker") {
seed = {
...seed,
update_via: "docker",
upstream_type: "",
repo: "",
upstream_url: "",
upstream_json_path: "",
docker_image: "",
}
}
setShowAdvanced(true)
} else {
setShowAdvanced(false)
@@ -761,7 +948,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
setEditing({ appId: existing?.id || null, draft: seed })
setDetectorTest(null)
setError(null)
}, [suggestions, vmid, sidecar])
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
const closeEditor = () => {
setEditing(null)
@@ -1145,27 +1332,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
web_path: link.web_path || "/",
logo_url: link.logo_url || "",
}
const last = draft.ports[draft.ports.length - 1]
const indexAfterAdd = (last && last.port === "" && !last.description)
? draft.ports.length - 1
: draft.ports.length
if (indexAfterAdd === draft.ports.length) {
setField({ ports: [...draft.ports, entry] })
} else {
const ports = [...draft.ports]
ports[indexAfterAdd] = entry
setField({ ports })
}
setDetectorTest(null)
setEditing((current) => {
if (!current) return current
const currentPorts = current.draft.ports
// The buttons disappear after a port is added, but guard the state
// update too so a double click can never create duplicate links.
if (currentPorts.some((port) => port.port === link.host_port)) return current
const ports = [...currentPorts]
const last = ports[ports.length - 1]
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
else ports.push(entry)
return { ...current, draft: { ...current.draft, ports } }
})
// Ask the backend whether this service_name has a known catalog
// category and, if so, patch the just-inserted port so the user
// finds it pre-selected instead of having to open the dropdown.
// Non-blocking — the port is already visible either way.
// This must be a functional update: the response can arrive after the
// user has added or edited more links, and must never restore the old
// draft captured by this render.
const q = (link.service_name || "").trim()
if (q) {
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
.then((r) => {
if (!r?.category) return
setPort(indexAfterAdd, { category: r.category })
setEditing((current) => {
if (!current) return current
let changed = false
const ports = current.draft.ports.map((port) => {
if (port.port !== link.host_port || port.category) return port
changed = true
return { ...port, category: r.category || undefined }
})
return changed
? { ...current, draft: { ...current.draft, ports } }
: current
})
})
.catch(() => { /* non-fatal — user can pick manually */ })
}
@@ -1175,15 +1377,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const usedPorts = new Set(draft.ports.map((p) => p.port))
const isDockerDraft = draft.helper_slug === "docker" ||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
// A recognised Docker workload belongs in one place at a time. Keep its
// links out of Docker while it is offered (or already registered) as an
// independent app. Dismissing that detection makes the links available
// under Docker again.
const independentDockerWorkloadSlugs = new Set(
(suggestions?.docker_workloads || [])
.filter((workload) => !dismissedSlugs.has(workload.slug))
.map((workload) => workload.slug),
)
const suggestableDockerLinks = isDockerDraft
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
? (suggestions?.docker_web_links || []).filter(
(link) =>
!usedPorts.has(link.host_port) &&
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
)
: []
// A Docker registration uses structured container → published-port
// suggestions below. Suppress the generic ss/netstat chips in that case
// so the same endpoint is not presented twice without its workload name.
const suggestable = isDockerDraft
? []
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
// Keep the generic ss/netstat probe available for Docker too. It covers
// host-networked services and listeners that Docker does not expose in
// NetworkSettings.Ports. Published ports already represented by a named
// Docker workload stay deduplicated from the generic chips.
const dockerPublishedPorts = new Set(
(suggestions?.docker_web_links || []).map((link) => link.host_port),
)
const suggestable = (suggestions?.port_suggestions || []).filter(
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
)
return (
<div className="space-y-4">
@@ -1512,7 +1731,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
@@ -1589,14 +1808,34 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<SelectItem value="binary">{t("vmLxc.appEditor.binaryVersionHint")}</SelectItem>
<SelectItem value="file">{t("vmLxc.appEditor.methodFile")}</SelectItem>
<SelectItem value="python_dist">{t("vmLxc.appEditor.methodPython")}</SelectItem>
<SelectItem value="docker_label" disabled>{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
{/* Chosen by detection for a Docker workload; kept only so an
application already using it shows its method. */}
{method === "docker_label" && (
<SelectItem value="docker_label">{t("vmLxc.appEditor.methodDockerLabel")}</SelectItem>
)}
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
{/* Only meaningful on a container ProxMenux installed
from an image: the record it reads exists nowhere else. */}
{(method === "oci_image" || suggestions?.oci_instance) && (
<SelectItem value="oci_image">{t("vmLxc.appEditor.methodOciImage")}</SelectItem>
)}
</SelectContent>
</Select>
</div>
{method === "oci_image" && (
<div className="sm:col-span-2 text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.appEditor.ociImageHelp")}
{suggestions?.oci_instance?.image_reference && (
<div className="mt-1">
<code className="text-foreground/80">{suggestions.oci_instance.image_reference}</code>
</div>
)}
</div>
)}
{isPackaged && (
<div>
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
@@ -1799,7 +2038,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{method && (() => {
{/* The registry of the image is the upstream of an OCI
install, and the record already names it. */}
{method && method !== "oci_image" && (() => {
// Upstream source selector — 3 methods (github,
// http_json, docker_hub). Legacy sidecars with a
// `repo` set but no `upstream_type` default to
@@ -1825,6 +2066,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
if (!t) patch.tag_regex = ""
setField(patch)
}
// A delegated app has no upstream of its own on purpose:
// the image it runs on resolves the available version.
// Showing an empty "none" dropdown reads as a setting the
// user forgot, so say what is happening instead.
if (editing.draft.update_via === "docker") {
return (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.upstreamDelegatedTitle")}
</div>
<div className="text-[10px] text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.appEditor.upstreamDelegatedHelp")}
</div>
</div>
)
}
return (
<>
<div>
@@ -2227,6 +2484,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)
// A containerised application. Same chip language as a native detection,
// with the update path spelled out: it is registered for what it is, not
// for an updater it does not have.
const renderWorkloadChip = (w: DockerWorkload) => (
<div key={`workload-${w.slug}`} className="p-3 rounded-md border border-border/60 bg-background/40">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{w.logo_url && (
<ThemeAwareLogo
src={w.logo_url}
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{w.name}</div>
<div className="text-xs text-emerald-400/90">
{w.installed_version
? t("vmLxc.appEditor.versionDetected", { version: w.installed_version })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">
{t("vmLxc.appEditor.runsInsideDocker")}
{w.container_name ? ` · ${w.container_name}` : ""}
</div>
</div>
</div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
<Button
size="sm"
onClick={() => openEditor(undefined, {
withTracking: !!w.tracking_suggestion,
preset: {
slug: w.slug,
name: w.name,
logo_url: w.logo_url || "",
default_ports: w.default_ports || [],
category: w.category || null,
tracking_suggestion: w.tracking_suggestion || null,
} as unknown as DetectedApp,
})}
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.registerButton")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => dismissDetection(w.slug, w.name)}
aria-label={`${t("vmLxc.appEditor.hideButton")}: ${w.name}`}
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
>
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
</Button>
</div>
</div>
</div>
)
return (
<div className="space-y-4">
{/* Browse panel — surfaces hidden detections with Restore
@@ -2276,7 +2594,36 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
many detections there are (0, 1, or many). Below the chips,
a single "Register a different app" button lets the user
add something the auto-detector doesn't know about. */}
{apps.length === 0 && (
{/* A database or a cache of a multi-container application has no
application of its own: it is reached from the main container. */}
{apps.length === 0 && ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-blue-500/10 w-fit">
<Info className="h-5 w-5 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-foreground text-center">
{t("vmLxc.appEditor.stackMemberTitle")}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.stackMemberBody", { primary: ociMemberOf.label })}
</p>
<div className="pt-1 flex justify-center">
<Button
onClick={() => window.dispatchEvent(new CustomEvent("openLxcAppModal", {
detail: { vmid: ociMemberOf.vmid, tab: "app" },
}))}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<ChevronRight className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.openPrimary")}
</Button>
</div>
</CardContent>
</Card>
)}
{apps.length === 0 && !ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
@@ -2288,24 +2635,28 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.noAppsBody")}
</p>
{visibleDetected.length > 0 && (
{(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
<div className="space-y-2 pt-2">
{visibleDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
<div className="pt-1 flex flex-wrap justify-center gap-2">
<Button
onClick={searchInstalledApplications}
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
disabled={searchingApplications}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
: isOciInstall ? <RefreshCw className="h-4 w-4 mr-1.5" /> : <Search className="h-4 w-4 mr-1.5" />}
{isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</Button>
{!isOciInstall && (
<Button
onClick={openBrowseOrEditor}
className="bg-blue-500 hover:bg-blue-600 text-white"
@@ -2318,6 +2669,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</span>
)}
</Button>
)}
</div>
{detectionNotice && (
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
@@ -2353,6 +2705,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
{app.installed_via === "oci_image" && st?.image_reference && <>OCI · <code className="text-foreground/80 break-all">{st.image_reference}</code></>}
</div>
)}
{tracking && st?.checked_at && (
@@ -2364,6 +2717,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
stack below Checked, full-width so long repo
names wrap cleanly instead of competing with
the top-right on narrow screens. */}
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
<a
href={st.image_repository}
target="_blank"
rel="noopener noreferrer"
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
>
<span className="truncate">{ociRepoLabel(st.image_repository)}</span>
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</a>
)}
{app.repo && tracking && (
<a
href={`https://github.com/${app.repo}`}
@@ -2380,6 +2744,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{/* Desktop-only repo link: same row as the title on md+,
hidden on mobile where the stacked variant above
handles it. */}
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
<a
href={st.image_repository}
target="_blank"
rel="noopener noreferrer"
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
>
{ociRepoLabel(st.image_repository)}
<ExternalLink className="h-3 w-3" />
</a>
)}
{app.repo && tracking && (
<a
href={`https://github.com/${app.repo}`}
@@ -2394,8 +2769,49 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
{(() => {
const hasUpstream = !!(app.repo || app.upstream_type)
const hasUpdate = st?.update_available === true
// An application installed from an OCI image changes when its
// image does. Both are shown — the application version it
// carries and the image it runs — and the image decides.
if (app.installed_via === "oci_image") {
if (!tracking) return null
const newImage = st?.update_available === true
const appMoved = !!(st?.latest_version && st.latest_version !== st.installed_version)
return (
<div className="mb-3 grid gap-3 grid-cols-1 sm:grid-cols-2">
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociAppVersion")}</div>
<div className="text-lg font-semibold font-mono text-foreground break-all">
{st?.installed_version || "—"}
</div>
{appMoved && (
<div className="text-xs font-mono text-purple-400 mt-1 break-all">→ {st!.latest_version}</div>
)}
</div>
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociImage")}</div>
<div className="text-sm font-semibold font-mono text-foreground">
{ociImageLabel(st?.image_created, st?.installed_digest) || "—"}
</div>
{newImage ? (
<div className="text-sm font-semibold font-mono text-purple-400 mt-1 flex items-center gap-2">
{t("vmLxc.appEditor.ociNewImage")}: {ociImageLabel(st?.latest_image_created, st?.latest_digest)}
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
</div>
) : st?.latest_digest ? (
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.ociImageCurrent")}</div>
) : null}
</div>
</div>
)
}
// A delegated app has no upstream of its own; the version to
// compare against comes from the image it updates with.
const delegated = app.update_via === "docker"
const hasUpstream = !!(app.repo || app.upstream_type) || (delegated && !!app.docker_available_version)
const hasUpdate = delegated
? app.docker_update_available === true
: st?.update_available === true
const latestVersion = delegated ? app.docker_available_version : st?.latest_version
if (!tracking || !(st?.installed_version || hasUpstream)) return null
return (
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
@@ -2413,8 +2829,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{t("vmLxc.appEditor.latestUpstream")}
</div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && st?.latest_version && (
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && latestVersion && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)}
</div>
@@ -2437,9 +2853,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
Logo is optional (per-port `logo_url`); when absent
the row indents naturally to align with the text.
If we can't resolve an IP for the CT we hide the row. */}
{app.ports && app.ports.length > 0 && (
{(isOciAdguard ? true : !!app.ports?.length) && (
<div className="mb-3 space-y-4">
{app.ports.map((p) => {
{(isOciAdguard
? [
{ port: 80, description: app.name, scheme: "http" as const },
...(adguardSetupAvailable ? [{ port: 3000, description: "Config", scheme: "http" as const }] : []),
]
: app.ports).map((p) => {
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
if (!url) return null
const label = p.description || app.name
@@ -2473,9 +2894,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<span
style={categoryChipStyle(p.category, isLightTheme)}
className="flex-shrink-0 px-1.5 py-0.5 border rounded text-[10px] font-medium truncate max-w-[40%]"
title={p.category}
title={getCategoryLabel(t, p.category)}
>
{p.category}
{getCategoryLabel(t, p.category)}
</span>
)}
</div>
@@ -2486,6 +2907,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{/* A service has no page to open: its address is shown as
text, on the ports of its container. */}
{!app.ports?.length && app.installed_via === "oci_image" && !!ctIp && ctIp !== "DHCP"
&& !!suggestions?.oci_instance?.service_ports?.length && (
<div className="mb-3 flex flex-col gap-1">
{suggestions.oci_instance.service_ports.map((p) => (
<span key={`${p.port}/${p.protocol}`} className="font-mono text-sm text-foreground">
{ctIp.split("/")[0]}:{p.port}{p.protocol === "udp" ? "/udp" : ""}
</span>
))}
</div>
)}
{/* Footer with per-card actions — only rendered in the
global edit mode (toggled from the "Edit" button next
to Add another application). View mode keeps cards
@@ -2561,12 +2995,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
hasn't been registered yet is shown as a chip with a
one-click Register button. Filtered against the sidecar's
`helper_slug` field so a registered app never re-appears. */}
{apps.length > 0 && unregisteredDetected.length > 0 && (
{apps.length > 0 && (unregisteredDetected.length > 0 || visibleWorkloads.length > 0) && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.alsoDetectedContainer")}
</div>
{unregisteredDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
@@ -2588,22 +3023,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<Button
variant="outline"
size="sm"
onClick={searchInstalledApplications}
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
disabled={searchingApplications || editMode}
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
aria-label={searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
aria-label={isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
>
{searchingApplications
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
: <Search className="h-4 w-4 sm:mr-1.5" />}
: isOciInstall ? <RefreshCw className="h-4 w-4 sm:mr-1.5" /> : <Search className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
{isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</span>
</Button>
{!isOciInstall && (
<Button
variant="outline"
size="sm"
@@ -2622,6 +3062,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
</span>
</Button>
)}
<button
type="button"
onClick={() => setEditMode((v) => !v)}
+1 -2
View File
@@ -178,8 +178,7 @@ export function LxcTerminalModal({
// `cancelled` short-circuits the async init if the modal closes
// before the dynamic xterm import resolves. Without this, we'd
// construct a Terminal instance, attach it to a now-stale ref, and
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
// con `import("xterm")` sin cancelación.
// open a WebSocket that nobody listens to.
let cancelled = false
// Small delay to ensure Dialog content is rendered
@@ -0,0 +1,16 @@
"use client"
import { useI18n } from "../lib/i18n/provider"
export function MetricsCacheNotice({ lastChecked }: { lastChecked: number | null }) {
const { language, t } = useI18n()
if (lastChecked === null) return null
return (
<p role="status" className="text-xs text-amber-600 dark:text-amber-400 mb-2">
{t("overview.metricsCachedWarning", {
time: new Date(lastChecked * 1000).toLocaleString(language),
})}
</p>
)
}
+2 -3
View File
@@ -665,9 +665,8 @@ function renderVertical(data: NetworkFlowData, labels: FlowLabels, unit: Network
const NIC_PATH_START_OFFSET = 46 // clears SUB_OFFSET_Y + text height
const NIC_VERTICAL_LEG = 56
const HOST_GAP_FROM_CONVERGE = 56
const GUEST_ROW_H = 100 // más separación vertical entre
// guests para que sub no toque
// el círculo del siguiente
const GUEST_ROW_H = 100 // enough vertical room that one guest's
// sub clears the next guest's circle
const BRIDGE_PITCH_PAD = 36
// Vertical positions of the label and the sub (rate) BELOW each
// node's circle. Both grew when the font went up to 12.5 px; this
+127 -108
View File
@@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
interface NetworkMetricsData {
time: string
@@ -55,12 +56,13 @@ export function NetworkTrafficChart({
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [visibleLines, setVisibleLines] = useState({
netIn: true,
netOut: true,
})
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
networkUnitProp || getNetworkUnit()
)
@@ -87,133 +89,147 @@ export function NetworkTrafficChart({
}, [networkUnitProp])
useEffect(() => {
let active = true
let hasData = false
let lastChecked: number | null = null
let refreshTimer: ReturnType<typeof setTimeout> | undefined
setIsInitialLoad(true)
fetchMetrics()
}, [timeframe, interfaceName, networkUnit])
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => {
fetchMetrics()
}, refreshInterval)
return () => clearInterval(interval)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
const fetchMetrics = async () => {
if (isInitialLoad) {
setLoading(true)
}
setLoading(true)
setError(null)
setStaleAt(null)
setData([])
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const fetchMetrics = async () => {
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const result = await fetchApi<any>(apiPath)
const result = await fetchApi<any>(apiPath)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
hasData = result.data.length > 0
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
}
}
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
return {
time: timeLabel,
timestamp: item.time,
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
} catch (err: any) {
if (!active) return
console.error("Error fetching network metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
} else {
setError(err?.body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: err.message || t("network.chart.loadError"))
}
} finally {
if (active) {
setLoading(false)
setIsInitialLoad(false)
if (refreshInterval > 0) refreshTimer = setTimeout(fetchMetrics, refreshInterval)
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
if (isInitialLoad) {
setIsInitialLoad(false)
}
} catch (err: any) {
console.error("Error fetching network metrics:", err)
setError(err.message || t("network.chart.loadError"))
} finally {
setLoading(false)
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(refreshTimer)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit])
const tickInterval = Math.ceil(data.length / 8)
@@ -272,6 +288,8 @@ export function NetworkTrafficChart({
}
return (
<>
<MetricsCacheNotice lastChecked={staleAt} />
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -327,5 +345,6 @@ export function NetworkTrafficChart({
/>
</AreaChart>
</ResponsiveContainer>
</>
)
}
+112 -83
View File
@@ -8,6 +8,7 @@ import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
const TIMEFRAME_OPTIONS = [
{ value: "hour", labelKey: "overview.timeframes.hour" },
@@ -132,6 +133,7 @@ export function NodeMetricsCharts() {
}>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<MetricsError | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const isMobile = useIsMobile()
const [visibleLines, setVisibleLines] = useState({
@@ -144,98 +146,124 @@ export function NodeMetricsCharts() {
const hasMemoryFree = data.some(d => d.memoryFree > 0)
useEffect(() => {
fetchMetrics()
}, [timeframe, language])
const fetchMetrics = async () => {
let active = true
let hasData = false
let lastChecked: number | null = null
let retryTimer: ReturnType<typeof setTimeout> | undefined
setLoading(true)
setError(null)
setStaleAt(null)
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
const fetchMetrics = async () => {
let retry = false
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
if (result.data[0]?.loadavg) {
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
hasData = result.data.length > 0
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
retry = result.cache_status === "stale"
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
console.error("Error fetching node metrics:", err)
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details,
suggestion: body?.suggestion,
})
} finally {
setLoading(false)
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
if (!active) return
retry = true
console.error("Error fetching node metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
return
}
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.code === "metrics_timeout" ? undefined : body?.details,
suggestion: body?.suggestion,
})
} finally {
if (active) {
setLoading(false)
if (retry) retryTimer = setTimeout(fetchMetrics, 60000)
}
}
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(retryTimer)
}
}, [timeframe, language])
const tickInterval = Math.ceil(data.length / 8)
@@ -358,6 +386,7 @@ export function NodeMetricsCharts() {
return (
<div className="space-y-6">
<MetricsCacheNotice lastChecked={staleAt} />
{/* Timeframe Selector */}
<div className="flex justify-end">
<Select value={timeframe} onValueChange={setTimeframe}>
+113 -90
View File
@@ -15,7 +15,7 @@ import { useT } from "../lib/i18n/provider"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
Trash2, ChevronRight, TestTube2, Mail, Webhook,
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
Moon, Newspaper
@@ -77,6 +77,7 @@ interface NotificationConfig {
ai_api_keys: Record<string, string> // Per-provider API keys
ai_models: Record<string, string> // Per-provider selected models
ai_model: string // Current active model (for the selected provider)
notification_language: string
ai_language: string
ai_ollama_url: string
ai_openai_base_url: string
@@ -150,7 +151,7 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
return {}
}
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "other"].map(key => ({ key }))
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
@@ -199,6 +200,24 @@ const AI_PROVIDERS = [
},
]
const NOTIFICATION_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
{ value: "es", label: "Español" },
{ value: "fr", label: "Français" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Português" },
{ value: "sk", label: "Slovenčina" },
{ value: "sv", label: "Svenska" },
]
function normalizeNotificationLanguage(notificationLanguage?: string, legacyAiLanguage?: string): string {
const runtimeLanguages = new Set(NOTIFICATION_LANGUAGES.map(language => language.value))
if (notificationLanguage && runtimeLanguages.has(notificationLanguage)) return notificationLanguage
if (legacyAiLanguage && runtimeLanguages.has(legacyAiLanguage)) return legacyAiLanguage
return "en"
}
const AI_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "sk", label: "Slovenčina" },
@@ -254,7 +273,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
event_categories: {
vm_ct: true, backup: true, resources: true, storage: true,
network: true, security: true, cluster: true, services: true,
health: true, updates: true, other: true,
health: true, updates: true, hardware: true, system: true, other: true,
},
event_toggles: {},
event_types_by_group: {},
@@ -284,6 +303,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
openrouter: "",
},
ai_model: "",
notification_language: "en",
ai_language: "en",
ai_ollama_url: "http://localhost:11434",
ai_openai_base_url: "",
@@ -323,7 +343,6 @@ export function NotificationSettings() {
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
// Cleartext secrets cached only while the eye toggle is "on" for
// that field. Settings GET returns "************" for everything in
@@ -401,6 +420,10 @@ export function NotificationSettings() {
ai_prompt_mode: data.config.ai_prompt_mode || "default",
ai_custom_prompt: data.config.ai_custom_prompt || "",
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
notification_language: normalizeNotificationLanguage(
data.config.notification_language,
data.config.ai_language,
),
}
// If ai_model exists but ai_models doesn't have it, save it
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
@@ -485,13 +508,6 @@ export function NotificationSettings() {
if (showHistory) loadHistory()
}, [showHistory, loadHistory])
// Auto-expand AI section when AI is enabled
useEffect(() => {
if (config.ai_enabled) {
setShowAdvanced(true)
}
}, [config.ai_enabled])
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
setConfig(prev => {
const next = updater(prev)
@@ -834,6 +850,7 @@ export function NotificationSettings() {
ai_enabled: String(cfg.ai_enabled),
ai_provider: cfg.ai_provider,
ai_model: cfg.ai_model,
notification_language: normalizeNotificationLanguage(cfg.notification_language, cfg.ai_language),
ai_language: cfg.ai_language,
ai_ollama_url: cfg.ai_ollama_url,
ai_openai_base_url: cfg.ai_openai_base_url,
@@ -894,13 +911,13 @@ export function NotificationSettings() {
return flat
}
const handleSave = async () => {
const saveConfig = async (nextConfig: NotificationConfig): Promise<boolean> => {
setSaving(true)
setSaveError(null)
try {
// If notifications are being disabled, clean up PVE webhook first
const wasEnabled = originalConfig.enabled
const isNowDisabled = !config.enabled
const isNowDisabled = !nextConfig.enabled
if (wasEnabled && isNowDisabled) {
try {
@@ -910,26 +927,33 @@ export function NotificationSettings() {
}
}
const payload = flattenConfig(config)
const payload = flattenConfig(nextConfig)
await fetchApi("/api/notifications/settings", {
method: "POST",
body: JSON.stringify(payload),
})
setOriginalConfig(config)
setConfig(nextConfig)
setOriginalConfig(nextConfig)
setHasChanges(false)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
loadStatus()
return true
} catch (err) {
console.error("Failed to save notification settings:", err)
const msg = err instanceof Error ? err.message : t("settings.notifications.errors.saveFailed")
setSaveError(msg)
return false
} finally {
setSaving(false)
}
}
const handleSave = async () => {
await saveConfig(config)
}
const handleCancel = () => {
setConfig(originalConfig)
setHasChanges(false)
@@ -2176,74 +2200,70 @@ export function NotificationSettings() {
</p>
</div>
{/* ── Advanced: AI Enhancement ── */}
<div>
<div className="flex items-center justify-between py-1">
<button
className="flex items-center gap-2 text-sm text-foreground hover:bg-muted/60 rounded-md px-2 py-1.5 -mx-2 transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<Sparkles className="h-4 w-4 text-purple-400" />
<span className="font-medium">{t("settings.notifications.ai.title")}</span>
{config.ai_enabled ? (
<Badge variant="outline" className="text-[10px] border-purple-500/40 text-purple-400 ml-1">
{t("status.active")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-border text-muted-foreground ml-1">
{t("settings.notifications.ui.optional")}
</Badge>
)}
</button>
{showAdvanced && (
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
className="h-6 px-2 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-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
)}
{/* ── Runtime notification language (independent of AI) ── */}
<div className="space-y-2 pb-3 border-b border-border/50">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-green-400" />
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ui.notificationLanguage")}</Label>
</div>
<Select
value={normalizeNotificationLanguage(config.notification_language, config.ai_language)}
onValueChange={value => updateConfig(previous => ({ ...previous, notification_language: value }))}
disabled={!editMode}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={t("settings.notifications.ui.selectNotificationLanguage")} />
</SelectTrigger>
<SelectContent>
{NOTIFICATION_LANGUAGES.map(language => (
<SelectItem key={language.value} value={language.value}>{language.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("settings.notifications.ui.notificationLanguageHint")}
</p>
</div>
{showAdvanced && (
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">{t("settings.notifications.ai.enhancedMessages")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
</div>
</div>
{/* ── AI Enhancement ── */}
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between py-1">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">{t("settings.notifications.ai.title")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editMode ? (
<>
<button
className="h-7 px-2 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-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
@@ -2255,10 +2275,12 @@ export function NotificationSettings() {
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.ai_enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
</button>
</div>
</div>
{config.ai_enabled && (
{config.ai_enabled && (
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border/50">
<>
{/* Provider + Info button */}
<div className="space-y-2">
@@ -2611,10 +2633,12 @@ export function NotificationSettings() {
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
setEditingCustomPrompt(false)
handleSave()
onClick={async () => {
const nextConfig = { ...config, ai_custom_prompt: customPromptDraft }
if (await saveConfig(nextConfig)) {
setEditingCustomPrompt(false)
setCustomPromptDraft("")
}
}}
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
>
@@ -2664,8 +2688,8 @@ export function NotificationSettings() {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const text = await file.text()
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
handleSave()
const nextConfig = { ...config, ai_custom_prompt: text }
await saveConfig(nextConfig)
}
}
input.click()
@@ -2733,7 +2757,6 @@ export function NotificationSettings() {
)}
</div>
</>
)}
</div>
)}
</div>
@@ -0,0 +1,291 @@
"use client"
// Console output of a native OCI container: what the image's main process
// writes to stdout/stderr, kept on the host by liblxc. The first request
// brings the last N lines; while following, each poll sends back the byte
// offset and inode it holds and receives only what was appended. Nothing is
// cached — the backend reads the file on every request.
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardContent } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { AlertCircle, Download, FileText, Loader2, Pause, Play, RefreshCw } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "@/lib/i18n/provider"
interface ConsoleLogResponse {
ok: boolean
enabled: boolean
lines: string[]
offset: number
inode: number | null
reset?: boolean
head_truncated?: boolean
start_marks?: boolean
error?: string
}
const LINE_OPTIONS = [100, 500, 1000] as const
const POLL_MS = 2000
// Written into the log by the container's pre-start hook at every start.
const START_MARK_RE = /^=== ProxMenux: container started (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ===$/
const lastStart = (lines: string[]) => {
for (let i = lines.length - 1; i >= 0; i--) if (START_MARK_RE.test(lines[i])) return i
return -1
}
const lineTone = (line: string) => {
if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400"
if (/\bwarn(ing)?\b/i.test(line)) return "text-amber-400"
return "text-foreground/90"
}
export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: boolean }) {
const t = useT()
const [lineCount, setLineCount] = useState<number>(500)
const [lines, setLines] = useState<string[]>([])
const [enabled, setEnabled] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [follow, setFollow] = useState(true)
const [filter, setFilter] = useState("")
const [sinceStart, setSinceStart] = useState(true)
const position = useRef<{ offset: number; inode: number | null } | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const inFlight = useRef(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const r = await fetchApi<ConsoleLogResponse>(
`/api/lxc/${vmid}/console-log?lines=${lineCount}${sinceStart ? "&since_start=1" : ""}`,
)
setEnabled(r.enabled)
setLines(r.lines || [])
position.current = { offset: r.offset, inode: r.inode }
if (r.error) setError(r.error)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [vmid, lineCount, sinceStart])
const poll = useCallback(async () => {
const pos = position.current
if (!pos || inFlight.current) return
inFlight.current = true
try {
const inode = pos.inode === null ? "" : `&inode=${pos.inode}`
const r = await fetchApi<ConsoleLogResponse>(
`/api/lxc/${vmid}/console-log?lines=${lineCount}&offset=${pos.offset}${inode}`,
)
position.current = { offset: r.offset, inode: r.inode }
if (r.reset) {
setLines(r.lines || [])
} else if (r.lines?.length) {
setLines((prev) => {
let next = prev.concat(r.lines)
// A restart while following opens a new session.
const mark = sinceStart ? lastStart(next) : -1
if (mark > 0) next = next.slice(mark)
return next.length > lineCount ? next.slice(next.length - lineCount) : next
})
}
} catch {
// A failed poll leaves the view as it is; the next one retries.
} finally {
inFlight.current = false
}
}, [vmid, lineCount, sinceStart])
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (!follow || !running || !enabled || loading) return
const timer = setInterval(() => {
if (document.visibilityState === "visible") poll()
}, POLL_MS)
poll()
return () => clearInterval(timer)
}, [follow, running, enabled, loading, poll])
const shown = useMemo(() => {
const needle = filter.trim().toLowerCase()
return needle ? lines.filter((l) => START_MARK_RE.test(l) || l.toLowerCase().includes(needle)) : lines
}, [lines, filter])
useEffect(() => {
if (follow && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [shown, follow])
// Scrolling up to read stops following; the Follow button resumes it.
const onScroll = () => {
const el = scroller.current
if (!el || !follow) return
if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false)
}
const download = () => {
const blob = new Blob([lines.join("\n") + "\n"], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `ct-${vmid}-console.log`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="h-full flex flex-col min-h-0">
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-sky-500/10">
<FileText className="h-4 w-4 text-sky-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.consoleLog.title")}</h3>
{enabled && shown.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">{shown.length}</Badge>
)}
</div>
{enabled && (
<div className="flex items-center gap-2 flex-wrap">
<Select value={sinceStart ? "start" : "all"} onValueChange={(v) => setSinceStart(v === "start")}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start" className="text-xs">{t("vmLxc.consoleLog.sinceStart")}</SelectItem>
<SelectItem value="all" className="text-xs">{t("vmLxc.consoleLog.allHistory")}</SelectItem>
</SelectContent>
</Select>
<Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{t("vmLxc.consoleLog.lastLines", { count: n })}
</SelectItem>
))}
</SelectContent>
</Select>
{running && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => setFollow((f) => !f)}
aria-pressed={follow}
>
{follow ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
<span>{follow ? t("vmLxc.consoleLog.pause") : t("vmLxc.consoleLog.follow")}</span>
</Button>
)}
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={load} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
<span>{t("vmLxc.consoleLog.refresh")}</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={download}
disabled={lines.length === 0}
>
<Download className="h-3 w-3" />
<span>{t("vmLxc.consoleLog.download")}</span>
</Button>
</div>
)}
</div>
<p className="text-xs text-muted-foreground mb-3 shrink-0">
{t("vmLxc.consoleLog.description")}
{enabled && !running && <> {t("vmLxc.consoleLog.stopped")}</>}
</p>
{enabled && (
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("vmLxc.consoleLog.filter")}
className="h-8 text-xs mb-3 shrink-0"
/>
)}
{loading && lines.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.consoleLog.loading")}</span>
</div>
) : error ? (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-500 mb-1">{t("vmLxc.consoleLog.readFailed")}</p>
<p className="text-xs text-muted-foreground break-all">{error}</p>
</div>
</div>
</div>
) : !enabled ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="font-medium text-amber-500">{t("vmLxc.consoleLog.notAvailableTitle")}</p>
<p className="text-xs text-muted-foreground leading-relaxed">{t("vmLxc.consoleLog.notAvailableHint")}</p>
</div>
</div>
</div>
) : lines.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.empty")}
<div className="text-xs mt-1">{t("vmLxc.consoleLog.emptyHint")}</div>
</div>
) : shown.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.noMatches")}
</div>
) : (
<div
ref={scroller}
onScroll={onScroll}
className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0"
>
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{shown.map((line, idx) => {
const mark = START_MARK_RE.exec(line)
if (mark) {
const when = new Date(`${mark[1]}T${mark[2]}`)
return (
<div key={idx} className="my-1.5 flex items-center gap-2 text-sky-400/90 select-none">
<span className="h-px flex-1 bg-sky-500/30" />
<span>{t("vmLxc.consoleLog.startedAt", {
date: Number.isNaN(when.getTime()) ? `${mark[1]} ${mark[2]}` : when.toLocaleString(),
})}</span>
<span className="h-px flex-1 bg-sky-500/30" />
</div>
)
}
return <div key={idx} className={lineTone(line)}>{line || " "}</div>
})}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+12 -4
View File
@@ -12,11 +12,12 @@ import { VirtualMachines } from "./virtual-machines"
import { AppsDashboard } from "./apps-dashboard"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { AuditReport } from "./audit-report"
import { Settings } from "./settings"
import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { HostBackup } from "./host-backup"
import { BackupTab } from "./backup-tab"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -41,6 +42,7 @@ import {
Settings2,
Terminal,
ShieldCheck,
ClipboardCheck,
Info,
DatabaseBackup,
ChevronDown,
@@ -237,8 +239,7 @@ export function ProxmoxDashboard() {
fetchHealthInfoCount()
fetchUpdateStatus()
// En overview: cada 30 segundos para actualización frecuente del estado de salud
// En otras tabs: cada 60 segundos para reducir carga
// Overview polls health every 30 s; the other tabs every 60 s.
let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
@@ -399,6 +400,7 @@ export function ProxmoxDashboard() {
case "terminal": return t("navigation.terminal")
case "logs": return t("navigation.systemLogs")
case "security": return t("navigation.security")
case "audit": return t("navigation.audit")
case "settings": return t("navigation.settings")
case "about": return t("navigation.about")
case "profile": return t("navigation.profile")
@@ -618,6 +620,7 @@ export function ProxmoxDashboard() {
const ADMIN_ITEMS = [
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
@@ -774,6 +777,7 @@ export function ProxmoxDashboard() {
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
@@ -840,8 +844,12 @@ export function ProxmoxDashboard() {
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<HostBackup key={`backup-${componentKey}`} />
<BackupTab key={`backup-${componentKey}`} />
</TabsContent>
<TabsContent value="terminal" className="mt-0">
+34 -19
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages, Package, ScrollText, ArrowUpCircle } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
@@ -305,34 +305,49 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
icon: <Package className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociManager",
text: "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
},
{
icon: <Wrench className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.secureGatewayArch",
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
icon: <ScrollText className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociInMonitor",
text: "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
},
{
icon: <Bell className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.atomicNotifications",
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
icon: <Cpu className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociAcceleration",
text: "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
},
{
icon: <DatabaseBackup className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.borgSshPort",
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
},
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.githubToken",
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
key: "releaseNotes.currentFeatures.ociRecovery",
text: "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
},
{
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.replicationContext",
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
key: "releaseNotes.currentFeatures.ociWatchdog",
text: "Watchdog for OCI applications — an application that stops on its own is started again and a notification says so. It is not restarted when the user stops it, or while a backup or a migration is in progress.",
},
{
icon: <Server className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.shareCoveX",
text: "ShareCoveX joins the catalog — a file server with a web panel that shares host directories or container volumes over SMB, NFS and Time Machine.",
},
{
icon: <Calendar className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.guestBackupJobs",
text: "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
},
{
icon: <Languages className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.notificationLanguage",
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
},
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditReviewDate",
text: "Audit & Report — an accepted finding can carry a review date that brings it back without withdrawing the decision, and the comparison says whether a finding got worse, got better or held (with @Vaso73).",
},
]
@@ -287,8 +287,7 @@ const initMessage = {
// Snapshot the open-state at call time. After the dynamic xterm
// imports resolve, bail out if the modal has since been closed —
// otherwise we attach a Terminal to a stale ref and open a WS that
// nobody reads. Audit Tier 6 — useEffect con `import("xterm")` sin
// cancelación.
// nobody reads.
const wasOpenAtCall = isOpenRef.current
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
@@ -162,6 +162,19 @@ export function SecureGatewaySetup() {
loadInitialData()
}, [])
// The gateway was updated from the Updates tab of its container: read the
// update state and the status again so this card agrees with it.
useEffect(() => {
const refresh = (event: Event) => {
const detail = (event as CustomEvent).detail || {}
if (detail.appId !== "secure-gateway" || detail.source === "card") return
void loadUpdateInfo(true)
void loadStatus()
}
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
}, [])
const loadInitialData = async () => {
setLoading(true)
setLoadError(null)
@@ -288,6 +301,8 @@ export function SecureGatewaySetup() {
// Status may briefly show "stopped" if tailscale was restarted —
// refresh that too so the action buttons render the right state.
await loadStatus()
// The Updates tab of the gateway container reads it again.
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId: "secure-gateway", source: "card" } }))
} else {
setUpdateError(res?.message || sg("errors.updateFailed"))
}
+128 -18
View File
@@ -21,6 +21,7 @@ import { useI18n } from "../lib/i18n/provider"
interface ApiTokenEntry {
id: string
name: string
scope?: "read_only" | "full_admin"
token_prefix: string
created_at: string
expires_at: string
@@ -81,6 +82,12 @@ export function Security() {
if (normalized.includes("invalid 2fa code")) {
return st("errors.invalid2faCode")
}
if (normalized.includes("2fa code required")) {
return st("errors.enter2faOrBackup")
}
if (normalized.includes("current password is incorrect")) {
return st("errors.invalidPassword")
}
if (normalized.includes("invalid password")) {
return st("errors.invalidPassword")
}
@@ -103,6 +110,7 @@ export function Security() {
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [changePasswordTotpCode, setChangePasswordTotpCode] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
@@ -123,6 +131,10 @@ export function Security() {
const [loadingTokens, setLoadingTokens] = useState(false)
const [revokingTokenId, setRevokingTokenId] = useState<string | null>(null)
const [tokenName, setTokenName] = useState("")
// API tokens default to read-only (the safe choice for dashboards that
// only read metrics). full_admin is opt-in and carries a warning: it
// can do everything the logged-in user can (host power, updates, terminal).
const [tokenScope, setTokenScope] = useState<"read_only" | "full_admin">("read_only")
// Proxmox Firewall state
const [firewallLoading, setFirewallLoading] = useState(true)
@@ -370,18 +382,19 @@ export function Security() {
setSuccess("")
setShowLynisUninstallConfirm(false)
try {
const data = await fetchApi("/api/security/lynis/uninstall", {
const data = await fetchApi<{ success: boolean; partial?: boolean; outcome?: string }>("/api/security/lynis/uninstall", {
method: "POST",
})
if (data.success) {
if (data.success === true) {
setSuccess(st("messages.lynisUninstalled"))
loadSecurityTools()
setLynisReport(null)
} else {
setError(data.message || st("errors.lynisUninstallFailed"))
setError(data.outcome === "no_files" ? st("errors.lynisUninstallNoFiles") : data.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
}
} catch (err) {
setError(err instanceof Error ? err.message : st("errors.lynisUninstallFailed"))
const body = err instanceof Error && "body" in err ? err.body as { partial?: boolean } | undefined : undefined
setError(body?.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
} finally {
setUninstallingLynis(false)
}
@@ -976,6 +989,11 @@ export function Security() {
return
}
if (totpEnabled && !changePasswordTotpCode.trim()) {
setError(st("errors.enter2faOrBackup"))
return
}
const pwError = validatePasswordStrength(newPassword, t)
if (pwError) {
setError(pwError)
@@ -992,8 +1010,9 @@ export function Security() {
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
},
body: JSON.stringify({
current_password: currentPassword,
old_password: currentPassword,
new_password: newPassword,
...(totpEnabled ? { totp_code: changePasswordTotpCode.trim() } : {}),
}),
})
@@ -1012,6 +1031,7 @@ export function Security() {
setCurrentPassword("")
setNewPassword("")
setConfirmNewPassword("")
setChangePasswordTotpCode("")
} catch (err) {
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
} finally {
@@ -1138,6 +1158,7 @@ export function Security() {
password: tokenPassword,
totp_token: totpEnabled ? tokenTotpCode : undefined,
token_name: tokenName || st("apiTokens.defaultName"),
scope: tokenScope,
}),
})
@@ -1156,6 +1177,7 @@ export function Security() {
setTokenPassword("")
setTokenTotpCode("")
setTokenName("")
setTokenScope("read_only")
loadApiTokens()
} catch (err) {
setError(err instanceof Error ? err.message : st("errors.generateTokenRetry"))
@@ -1332,10 +1354,12 @@ export function Security() {
.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; }
.hide-mobile { }
@media (min-width: 640px) {
.top-bar { padding: 12px 24px; }
@@ -1390,6 +1414,9 @@ export function Security() {
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
@media screen and (max-width: 639px) {
.grid-3, .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
@@ -1458,7 +1485,10 @@ function pmxPrint(){
<strong>${st("lynis.report.brandTitle")}</strong>
<span id="pmx-print-hint" class="hide-mobile" style="font-size:11px;opacity:0.7;">${st("lynis.report.reviewHint")}</span>
</div>
<button onclick="pmxPrint()">${st("lynis.printSavePdf")}</button>
<div class="btn-group">
<button onclick="pmxPrint()" 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="pmxPrint()" 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 -->
@@ -2012,6 +2042,22 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
{totpEnabled && (
<div className="space-y-2">
<Label htmlFor="change-password-totp">{st("twoFactor.codeOrBackup")}</Label>
<Input
id="change-password-totp"
type="text"
inputMode="text"
autoComplete="one-time-code"
placeholder={st("twoFactor.codeOrBackupPlaceholder")}
value={changePasswordTotpCode}
onChange={(e) => setChangePasswordTotpCode(e.target.value)}
disabled={loading}
/>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleChangePassword}
@@ -2021,7 +2067,13 @@ ${(report.sections && report.sections.length > 0) ? `
{loading ? st("auth.changing") : st("auth.changePassword")}
</Button>
<Button
onClick={() => setShowChangePassword(false)}
onClick={() => {
setShowChangePassword(false)
setCurrentPassword("")
setNewPassword("")
setConfirmNewPassword("")
setChangePasswordTotpCode("")
}}
variant="outline"
className="flex-1"
disabled={loading}
@@ -2442,6 +2494,44 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
<div className="space-y-2">
<Label>{st("apiTokens.scope.label")}</Label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setTokenScope("read_only")}
disabled={generatingToken}
className={`rounded-md border px-3 py-2 text-left transition-colors ${
tokenScope === "read_only"
? "border-blue-500 bg-blue-500/10"
: "border-border hover:bg-muted/60"
}`}
>
<span className="block text-sm font-medium">{st("apiTokens.scope.readOnly")}</span>
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.readOnlyHint")}</span>
</button>
<button
type="button"
onClick={() => setTokenScope("full_admin")}
disabled={generatingToken}
className={`rounded-md border px-3 py-2 text-left transition-colors ${
tokenScope === "full_admin"
? "border-amber-500 bg-amber-500/10"
: "border-border hover:bg-muted/60"
}`}
>
<span className="block text-sm font-medium">{st("apiTokens.scope.fullAdmin")}</span>
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.fullAdminHint")}</span>
</button>
</div>
{tokenScope === "full_admin" && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2.5 text-xs text-amber-600 dark:text-amber-400">
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" />
<span>{st("apiTokens.scope.fullAdminWarning")}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="token-password">{st("auth.password")}</Label>
<div className="relative">
@@ -2614,6 +2704,15 @@ ${(report.sections && report.sections.length > 0) ? `
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{token.name}</p>
{token.scope === "read_only" ? (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/15 text-blue-500 border border-blue-500/30 whitespace-nowrap">
{st("apiTokens.scope.readOnly")}
</span>
) : (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/15 text-amber-500 border border-amber-500/30 whitespace-nowrap">
{st("apiTokens.scope.fullAdmin")}
</span>
)}
{isInvalid && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/15 text-red-500 border border-red-500/30 whitespace-nowrap">
{st("apiTokens.invalidRegenerate")}
@@ -3174,7 +3273,7 @@ ${(report.sections && report.sections.length > 0) ? `
<span className="text-xs text-foreground font-mono font-medium">{rule.dport || "*"}</span>
<span className={`text-[10px] px-1 py-0 rounded flex-shrink-0 ${
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
}`}>{rule.source_file}</span>
}`}>{rule.source_file === "cluster" ? st("firewall.cluster") : st("firewall.host")}</span>
</div>
{comment && (
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{comment}</p>
@@ -3192,7 +3291,7 @@ ${(report.sections && report.sections.length > 0) ? `
<span className={`hidden sm:block text-[10px] px-1.5 py-0.5 rounded text-center ${
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
}`}>
{rule.source_file}
{rule.source_file === "cluster" ? st("firewall.cluster") : st("firewall.host")}
</span>
{/* Expand/Delete */}
<div className="flex items-center justify-end">
@@ -4279,14 +4378,25 @@ ${(report.sections && report.sections.length > 0) ? `
onClick={(e) => {
e.stopPropagation()
if (confirm(st("confirm.deleteAuditReport"))) {
fetchApi("/api/security/lynis/report", { method: "DELETE" })
.then(() => {
setLynisReport(null)
setLynisShowReport(false)
setSuccess(st("messages.reportDeleted"))
loadSecurityTools()
setError("")
setSuccess("")
fetchApi<{ success: boolean; outcome?: string; partial?: boolean }>("/api/security/lynis/report", { method: "DELETE" })
.then((data) => {
if (data.success === true) {
setLynisReport(null)
setLynisShowReport(false)
setSuccess(st("messages.reportDeleted"))
loadSecurityTools()
} else if (data.outcome === "no_files") {
setError(st("errors.deleteReportNoFiles"))
} else {
setError(data.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
}
})
.catch((err) => {
const body = err instanceof Error && "body" in err ? err.body as { partial?: boolean } | undefined : undefined
setError(body?.partial ? st("errors.lynisRemovalPartial") : st("errors.lynisRemovalUnconfirmed"))
})
.catch(() => setError(st("errors.deleteReportFailed")))
}
}}
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
+5 -2
View File
@@ -2,12 +2,13 @@
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings"
import { HealthThresholds } from "./health-thresholds"
import { LxcUpdateDetection } from "./lxc-update-detection"
import { DiskExclusions } from "./disk-exclusions"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
@@ -1862,6 +1863,8 @@ export function Settings() {
</CardContent>
</Card>
<DiskExclusions />
{/* Health Monitor Thresholds — placed above Notifications because the
values configured here drive what triggers the notifications below. */}
<HealthThresholds />
@@ -2034,7 +2037,7 @@ export function Settings() {
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Wrench className="h-5 w-5 text-orange-500" />
<Sparkles className="h-5 w-5 text-orange-500" />
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
</div>
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
+75 -50
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power } from "lucide-react"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -70,6 +70,8 @@ interface DiskInfo {
// badge AND to suppress the (stale) temperature value, so the
// operator understands the graph is frozen on purpose — issue #232.
standby?: boolean
idle?: boolean
excluded?: boolean
health: string
power_on_hours?: number
smart_status?: string
@@ -114,7 +116,6 @@ interface DiskObservation {
occurrence_count: number
raw_message: string
severity: string
dismissed: boolean
device_name: string
serial: string
model: string
@@ -434,7 +435,21 @@ export function StorageOverview() {
// spun-down drive. Centralised here because the same pattern shows up
// in 4 different disk-list views (system / data / pool / other) and we
// want them all to behave identically — issue #232 fix.
const renderDiskTempOrStandby = (disk: DiskInfo) => {
// Why a disk shows no live temperature, when it doesn't: excluded by the
// user, parked, or idle and deliberately not read. Shared by every view
// that paints a disk's temperature, so they cannot disagree.
const renderNoReadingBadge = (disk: DiskInfo) => {
if (disk.excluded) {
return (
<Badge
className="bg-muted text-muted-foreground border-border gap-1"
title={t("storage.diskExcludedTitle")}
>
<EyeOff className="h-3 w-3" />
{t("storage.diskExcluded")}
</Badge>
)
}
if (disk.standby) {
return (
<Badge
@@ -446,6 +461,23 @@ export function StorageOverview() {
</Badge>
)
}
if (disk.idle) {
return (
<Badge
className="bg-muted text-muted-foreground border-border gap-1"
title={t("storage.idleTitle")}
>
<Moon className="h-3 w-3" />
{t("storage.idle")}
</Badge>
)
}
return null
}
const renderDiskTempOrStandby = (disk: DiskInfo) => {
const noReading = renderNoReadingBadge(disk)
if (noReading) return noReading
if (disk.temperature > 0) {
return (
<div className="flex items-center gap-1">
@@ -533,14 +565,8 @@ export function StorageOverview() {
{/* Header line 2: size + temperature/standby. */}
<div className="flex items-center justify-between gap-3 mt-1">
<span className="text-sm text-muted-foreground">{disk.size_formatted}</span>
{disk.standby ? (
<Badge
className="bg-blue-500/10 text-blue-300 border-blue-500/30 gap-1"
title={t("storage.standbyTitle")}
>
<Power className="h-3 w-3" />
{t("storage.standby")}
</Badge>
{renderNoReadingBadge(disk) ? (
renderNoReadingBadge(disk)
) : disk.temperature > 0 ? (
<span
className={`text-base font-semibold ${getTempColor(
@@ -938,37 +964,25 @@ export function StorageOverview() {
}
const getDiskHealthBreakdown = () => {
if (!storageData || !storageData.disks) {
return { normal: 0, warning: 0, critical: 0 }
if (!storageData || !Array.isArray(storageData.disks)) {
return { normal: 0, warning: 0, critical: 0, unavailable: 0 }
}
let normal = 0
let warning = 0
let critical = 0
let unavailable = 0
storageData.disks.forEach((disk) => {
if (disk.temperature === 0) {
// No temperature reading available — count as normal so a
// missing sensor doesn't inflate the warning count.
normal++
// Match disk rows: policy-suppressed cached readings are not live temperatures.
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
unavailable++
return
}
// Reuse the exact threshold lookup that the per-disk badge
// (`getTempColor`) uses, so the green / amber / red colour in
// the disk card and the "X normal, Y warning, Z critical" tally
// at the top of the page always agree.
//
// Previously this breakdown carried its own hardcoded ladder
// (HDD ≤45 normal, ≤55 warning, …) which was far stricter than
// the configurable defaults from `useDiskTempThresholds`
// (HDD warn 60, hot 65). A disk at 48 °C therefore showed a
// green badge but was counted as "warning" in the summary —
// exactly the case the user reported. Driving both from the
// same source (`dtThresholds`) means the user-tunable values
// under Settings → Health Monitor Thresholds → Disk temperature
// now apply to the breakdown as well, and the displayed colour
// is always the source of truth.
// Only usable readings enter the existing configurable threshold lookup.
// Keep the resolver and defaults shared with the per-disk temperature badge.
const diskType = getDiskType(disk.name, disk.rotation_rate)
const cls: keyof DiskTempMap =
diskType === "NVMe" ? "NVMe" : diskType === "SSD" ? "SSD" : "HDD"
@@ -978,11 +992,11 @@ export function StorageOverview() {
else normal++
})
return { normal, warning, critical }
return { normal, warning, critical, unavailable }
}
const getDiskTypesBreakdown = () => {
if (!storageData || !storageData.disks) {
if (!storageData || !Array.isArray(storageData.disks)) {
return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
}
@@ -1107,7 +1121,7 @@ export function StorageOverview() {
)
}
if (!storageData || storageData.error) {
if (!storageData || storageData.error || !Array.isArray(storageData.disks)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">
@@ -1285,12 +1299,16 @@ export function StorageOverview() {
{(() => {
const total = Math.max(1, storageData.disk_count || 0)
const seg = 100 / total
const allHealthy = diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
const healthBadge = allHealthy
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">✓ {t("storage.allHealthy")}</Badge>
: diskHealthBreakdown.critical > 0
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{t("storage.criticalCount", { count: diskHealthBreakdown.critical })}</Badge>
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{t("storage.warningCount", { count: diskHealthBreakdown.warning })}</Badge>
const allTemperaturesNormal = storageData.disks.length > 0 && diskHealthBreakdown.unavailable === 0 && diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
const healthBadge = storageData.disks.length === 0
? <Badge variant="outline" className="bg-muted text-muted-foreground border-border">{t("storage.temperatureNoDisks")}</Badge>
: allTemperaturesNormal
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{t("storage.temperatureNormal")}</Badge>
: diskHealthBreakdown.critical > 0
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{t("storage.temperatureCriticalCount", { count: diskHealthBreakdown.critical })}</Badge>
: diskHealthBreakdown.warning > 0
? <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })}</Badge>
: <Badge variant="outline" className="bg-muted text-muted-foreground border-border">{t(diskHealthBreakdown.normal > 0 ? "storage.temperatureIncomplete" : "storage.temperatureUnavailable")}</Badge>
const seg_purple = '#a855f7'
const seg_cyan = '#06b6d4'
const seg_blue = '#3b82f6'
@@ -1312,7 +1330,15 @@ export function StorageOverview() {
<span className="text-3xl font-bold leading-none">{storageData.disk_count}</span>
<span className="text-base font-medium ml-1 text-muted-foreground">{diskCountLabel(storageData.disk_count)}</span>
</div>
</div>
<div className="mb-3 flex flex-col items-start gap-1 [&>div]:max-w-full [&>div]:whitespace-normal [&>div]:break-words">
{healthBadge}
{diskHealthBreakdown.critical > 0 && diskHealthBreakdown.warning > 0 && (
<p className="text-xs text-yellow-500">{t("storage.temperatureHighCount", { count: diskHealthBreakdown.warning })}</p>
)}
{diskHealthBreakdown.unavailable > 0 && (
<p className="text-xs text-muted-foreground">{t("storage.temperatureUnavailableCount", { count: diskHealthBreakdown.unavailable })}</p>
)}
</div>
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
{segments.map((s, i) => (
@@ -2065,7 +2091,7 @@ export function StorageOverview() {
<h4 className="font-semibold mb-3 flex items-center gap-2">
{t("storage.wearLifetime")}
{smartJsonData?.has_data && !wi && (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.realTest")}</Badge>
<Badge className="bg-green-500/10 text-green-400 border-green-500/20 text-[10px] px-1.5">{t("storage.savedSmartData")}</Badge>
)}
</h4>
<div className="flex gap-5 items-start">
@@ -2777,7 +2803,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
// Build recommendations
const recommendations: string[] = []
if (isHealthy) {
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">&#10003;</div><div><strong>${t("storage.smartReport.recommendations.healthyTitle")}</strong><p>${t("storage.smartReport.recommendations.healthyText")}</p></div></div>`)
recommendations.push(`<div class="rec-item rec-ok"><div class="rec-icon">&#10003;</div><div><strong>${t("storage.smartReport.recommendations.passedTitle")}</strong><p>${t("storage.smartReport.recommendations.passedText")}</p></div></div>`)
} else {
recommendations.push(`<div class="rec-item rec-critical"><div class="rec-icon">&#10007;</div><div><strong>${t("storage.smartReport.recommendations.criticalTitle")}</strong><p>${t("storage.smartReport.recommendations.criticalText")}</p></div></div>`)
}
@@ -2860,7 +2886,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
const severityLabel = severityText(obs.severity)
const firstDate = obs.first_occurrence ? new Date(obs.first_occurrence).toLocaleString() : na
const lastDate = obs.last_occurrence ? new Date(obs.last_occurrence).toLocaleString() : na
const dismissedBadge = obs.dismissed ? `<span style="background:#16a34a20;color:#16a34a;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:4px;">${t("storage.smartReport.dismissed")}</span>` : ''
const errorTypeLabel = observationTypeText(type)
obsItemsHtml += `
@@ -2870,7 +2895,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
<span style="background:${severityBadgeColor}20;color:${severityBadgeColor};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${severityLabel}</span>
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">ID: #${obs.id}</span>
<span style="background:#64748b20;color:#475569;padding:2px 8px;border-radius:4px;font-size:11px;">${t("storage.smartReport.occurrences")}: <strong>${obs.occurrence_count}</strong></span>
${dismissedBadge}
</div>
<div style="margin-bottom:10px;">
@@ -3068,8 +3092,6 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
}
@media screen and (max-width: 640px) {
body { padding: 16px; padding-top: 64px; }
.grid-4 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.rpt-header-right { text-align: left; }
.exec-box { flex-wrap: wrap; }
@@ -3173,6 +3195,9 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
@media screen and (max-width: 640px) {
.grid-3, .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
@@ -3284,7 +3309,7 @@ function pmxPrint(){
<h3>${t("storage.smartReport.healthAssessment")}</h3>
<p>
${isHealthy
? t("storage.smartReport.healthyAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noBadSectors") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
? t("storage.smartReport.passedAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noReallocatedSectorsReported") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) })
: t("storage.smartReport.failedAssessment")
}
</p>
@@ -3294,11 +3319,11 @@ function pmxPrint(){
<!-- Simple Explanation for Non-Technical Users -->
<div style="background:${isHealthy ? '#dcfce7' : (hasCritical ? '#fee2e2' : '#fef3c7')};border:1px solid ${isHealthy ? '#86efac' : (hasCritical ? '#fca5a5' : '#fcd34d')};border-radius:8px;padding:16px;margin-top:12px;">
<div style="font-weight:700;font-size:14px;color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};margin-bottom:8px;">
${isHealthy ? t("storage.smartReport.healthyMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
${isHealthy ? t("storage.smartReport.passedMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}
</div>
<p style="color:${isHealthy ? '#166534' : (hasCritical ? '#991b1b' : '#92400e')};font-size:12px;margin:0 0 8px 0;">
${isHealthy
? t("storage.smartReport.healthyMeaning")
? t("storage.smartReport.passedMeaning")
: (hasCritical
? t("storage.smartReport.criticalMeaning")
: t("storage.smartReport.warningMeaning")
+6 -6
View File
@@ -29,7 +29,7 @@ import {
} from "lucide-react"
import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
import { useI18n } from "@/lib/i18n/provider"
interface Backup {
volid: string
@@ -89,7 +89,7 @@ interface CombinedLogEntry {
}
export function SystemLogs() {
const t = useT()
const { language, t } = useI18n()
const [logs, setLogs] = useState<SystemLog[]>([])
const [backups, setBackups] = useState<Backup[]>([])
const [events, setEvents] = useState<Event[]>([])
@@ -607,7 +607,7 @@ export function SystemLogs() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
{(logsCounts?.total ?? 0).toLocaleString(language)}
</div>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
</CardContent>
@@ -619,7 +619,7 @@ export function SystemLogs() {
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString(language)}</div>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
</CardContent>
</Card>
@@ -630,7 +630,7 @@ export function SystemLogs() {
<AlertTriangle className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString(language)}</div>
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
</CardContent>
</Card>
@@ -641,7 +641,7 @@ export function SystemLogs() {
<Database className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-500">{backupStats.total.toLocaleString("fr-FR")}</div>
<div className="text-2xl font-bold text-blue-500">{backupStats.total.toLocaleString(language)}</div>
<p className="text-xs text-muted-foreground mt-2">{formatBytes(backupStats.totalSize)}</p>
</CardContent>
</Card>
+7 -6
View File
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
@@ -173,6 +173,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
export function SystemOverview() {
const t = useT()
const { language } = useI18n()
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -511,7 +512,7 @@ export function SystemOverview() {
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
</div>
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("overview.runningCount", { count: vmStats.running })}
{t(getCountFormKey(language, "overview.runningCount", vmStats.running), { count: vmStats.running })}
</Badge>
</div>
<div className="mt-3 flex gap-1 flex-wrap">
@@ -864,7 +865,7 @@ export function SystemOverview() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("overview.kernel")}</span>
@@ -905,18 +906,18 @@ export function SystemOverview() {
<div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || t("app.notAvailable")}</span>
</div>
<div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || t("app.notAvailable")}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
<span className="text-lg font-semibold text-foreground">
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || t("app.notAvailable")}
</span>
</div>
</CardContent>
+1 -1
View File
@@ -639,7 +639,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
// Prevenir comportamientos por defecto del navegador
// Stop the browser default for this key
if (e) {
e.preventDefault()
e.stopPropagation()

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