Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two further fixes this depends on:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Highlights:

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

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

Highlights:

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

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

Highlights:

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

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

Highlights:

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

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

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

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

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

## Docs

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

## AppImage

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Contents:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reproduced and validated on PVE 9.x lab:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New AppImage SHA-256:
  16ad59ea63a64e5be460cd73f87315e8b39b756bf1c61f3cb2019e9fa3e76361

Closes #220.

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

SHA-256:
  3b44eb1172b4b1b7e6a36d1c9f1cd5a237ec04d52543bb791358525b0653a402

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

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

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

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

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

New SHA-256:
  d043e2f27f21315931ab53d87f02390b1a66b0c1730e8b7699aafb565809efbb

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:06:49 +02:00
ProxMenuxBot 84d7b60cd2 Update helpers_cache.json 2026-05-18 18:34:18 +00:00
MacRimi 81844fa456 Update AppImage 2026-05-18 18:09:15 +02:00
MacRimi 4bedeb9fcd Update appimage 2026-05-18 17:47:25 +02:00
ProxMenuxBot 0d435a5d4a Update helpers_cache.json 2026-05-18 13:08:58 +00:00
ProxMenuxBot 4994543d56 Update helpers_cache.json 2026-05-18 07:19:20 +00:00
ProxMenuxBot 026ceab2ef Update helpers_cache.json 2026-05-18 00:31:31 +00:00
ProxMenuxBot c92bf504fb Update helpers_cache.json 2026-05-17 12:17:49 +00:00
ProxMenuxBot a7eac42c9d Update helpers_cache.json 2026-05-17 00:30:19 +00:00
ProxMenuxBot f20550d5f6 Update helpers_cache.json 2026-05-14 18:35:05 +00:00
MacRimiandGitHub c13601cd2d Merge pull request #203 from jcastro/feature/fix-open-bug-issues
Fix webhook loopback detection and update handoff

Thanks for the thorough work here, jcastro — really appreciate the bug triage on top of the fix itself, it makes reviewing much easier.
The changes look good to me:
	•	_is_loopback_addr() is a clean solution. Using the stdlib ipaddress module and explicitly handling the IPv4-mapped IPv6 case (::ffff:127.0.0.1) is exactly what was needed after the dual-stack binding change. The docstring explaining why is a nice touch.
	•	The exec bash swap in menu is the right fix for #180. Replacing the shell before the installer overwrites /usr/local/bin/menu avoids the half-old/half-new parsing that was causing the syntax error on line 105. Good catch removing the now-unreachable return 0 too.
Validation looks solid (8/8 loopback cases, syntax checks on all scripts).
Merging into develop. Thanks again! 🙌
2026-05-14 17:25:59 +02:00
MacRimiandGitHub c0dd7eacb6 Merge pull request #204 from jcastro/feature/select-iso-from-all-storages
Select VM ISOs from all ISO storages
2026-05-14 17:05:17 +02:00
MacRimiandGitHub f2d5eac330 Merge pull request #202 from jcastro/feature/settings-release-channel
Add release channel switcher to Settings
2026-05-14 15:58:50 +02:00
jcastro 092b548d20 Select VM ISOs from all ISO storages 2026-05-14 14:45:45 +02:00
jcastro 70ab072c79 Fix webhook loopback detection and update handoff 2026-05-14 14:33:27 +02:00
jcastro f8a8c43d0d Add release channel switcher to settings 2026-05-14 14:23:43 +02:00
MacRimiandGitHub fcd431b421 Merge pull request #200 from jcastro/feature/enable-zfs-autotrim-auto-post-install
Enable ZFS autotrim in auto post-install
2026-05-14 07:28:27 +02:00
MacRimiandGitHub 2a9ba5b526 Merge pull request #201 from jcastro/feature/update-figurine-2.0.0
Update Figurine to 2.0.0
2026-05-14 07:26:12 +02:00
jcastro aba9402830 Update Figurine to 2.0.0 2026-05-14 06:52:08 +02:00
jcastro 8877f9871f Enable ZFS autotrim in auto post-install 2026-05-14 06:46:08 +02:00
ProxMenuxBot 742b08c86d Update helpers_cache.json 2026-05-13 12:40:43 +00:00
ProxMenuxBot a85ddcd6c1 Update helpers_cache.json 2026-05-13 00:31:38 +00:00
ProxMenuxBot 758ac4ab62 Update helpers_cache.json 2026-05-12 00:29:10 +00:00
ProxMenuxBot 56e5d7ff54 Update helpers_cache.json 2026-05-11 18:35:26 +00:00
ProxMenuxBot 596a8ba21d Update helpers_cache.json 2026-05-11 12:55:43 +00:00
ProxMenuxBot 19b67b3c8a Update helpers_cache.json 2026-05-11 07:07:56 +00:00
ProxMenuxBot eaa97ce301 Update helpers_cache.json 2026-05-11 00:29:24 +00:00
MacRimiandGitHub f569826b78 Merge pull request #197 from pespinel/codex/fix-beta-monitor-service
fix(monitor): update beta service runtime path
2026-05-10 23:06:29 +02:00
pespinel 0daab74a58 fix(monitor): update beta service runtime path 2026-05-10 22:24:09 +02:00
ProxMenuxBot a05d16a789 Update helpers_cache.json 2026-05-10 12:16:31 +00:00
MacRimiandGitHub 16c97e94cc Comment out extraction info message
Commented out the message logging for extracting AppImage runtime.
2026-05-10 08:48:12 +02:00
MacRimiandGitHub bd9af49412 Add ProxMenux-Monitor AppImage SHA256 file 2026-05-10 06:10:09 +02:00
MacRimi ab5c7093eb Update AppImage 2026-05-10 05:19:36 +02:00
MacRimi b4e8c5101a Update AppImage 2026-05-10 05:11:51 +02:00
MacRimi 911886b90c Update AppImage 2026-05-10 05:00:00 +02:00
MacRimi c14b72456f Update AppImage 2026-05-10 04:46:33 +02:00
MacRimi 6d7e06a0d2 Update AppImage 2026-05-09 23:37:46 +02:00
MacRimi 0288c14a29 Update AppImage 2026-05-09 23:22:45 +02:00
MacRimi 748334eed6 Update beta_version.txt 2026-05-09 19:10:56 +02:00
MacRimi 07301ea599 Update beta_version.txt 2026-05-09 19:10:05 +02:00
MacRimi 2f919de9e3 update beta ProxMenux 1.2.1.1-beta 2026-05-09 18:59:59 +02:00
MacRimi 5ed1fc44fd Update 1.2.1.1-beta 2026-05-09 18:31:47 +02:00
ProxMenuxBot f11aa77e3a Update helpers_cache.json 2026-05-07 18:32:01 +00:00
MacRimiandGitHub 1bbcf60e58 Update attribution clause in LICENSE 2026-05-07 17:16:56 +02:00
MacRimiandGitHub 32bbf5bb27 Update attribution clause in LICENSE file 2026-05-07 17:16:24 +02:00
ProxMenuxBot c90ee58bc0 Update helpers_cache.json 2026-05-07 12:34:40 +00:00
ProxMenuxBot 2983e20695 Update helpers_cache.json 2026-05-06 17:56:15 +00:00
MacRimiandGitHub 4e7570cb26 Refactor install_methods variable and split_notes function 2026-05-06 19:55:33 +02:00
ProxMenuxBot 7987b61345 Update helpers_cache.json 2026-05-05 12:22:52 +00:00
ProxMenuxBot 6fbd47ed8a Update helpers_cache.json 2026-05-05 06:43:37 +00:00
ProxMenuxBot bada4ef524 Update helpers_cache.json 2026-05-05 00:27:53 +00:00
ProxMenuxBot 8918d37bb6 Update helpers_cache.json 2026-05-04 18:29:56 +00:00
ProxMenuxBot b0d94a3594 Update helpers_cache.json 2026-05-04 06:59:01 +00:00
ProxMenuxBot 1291204d2b Update helpers_cache.json 2026-05-04 00:27:30 +00:00
ProxMenuxBot 804f201589 Update helpers_cache.json 2026-05-03 18:16:18 +00:00
ProxMenuxBot 65779b1eb6 Update helpers_cache.json 2026-05-03 00:27:17 +00:00
ProxMenuxBot 7eac978950 Update helpers_cache.json 2026-05-02 18:16:00 +00:00
ProxMenuxBot b1d0cfa2e7 Update helpers_cache.json 2026-05-02 12:16:12 +00:00
ProxMenuxBot 282b1c2b0a Update helpers_cache.json 2026-05-02 00:27:19 +00:00
ProxMenuxBot b4b0d4cd7e Update helpers_cache.json 2026-04-30 18:27:38 +00:00
ProxMenuxBot 6a66ee75ac Update helpers_cache.json 2026-04-30 12:29:27 +00:00
ProxMenuxBot 45dcdcccbb Update helpers_cache.json 2026-04-30 06:50:16 +00:00
ProxMenuxBot 5d47f1dfca Update helpers_cache.json 2026-04-29 12:29:45 +00:00
ProxMenuxBot 1e07e5f17b Update helpers_cache.json 2026-04-28 12:33:16 +00:00
ProxMenuxBot 8adb1b9730 Update helpers_cache.json 2026-04-28 06:52:18 +00:00
ProxMenuxBot 9d3720b6a1 Update helpers_cache.json 2026-04-26 18:12:46 +00:00
ProxMenuxBot 6496601e4e Update helpers_cache.json 2026-04-26 12:12:36 +00:00
ProxMenuxBot b96df3b501 Update helpers_cache.json 2026-04-26 00:23:40 +00:00
ProxMenuxBot 489460f509 Update helpers_cache.json 2026-04-24 18:13:16 +00:00
ProxMenuxBot 192870d658 Update helpers_cache.json 2026-04-24 12:18:50 +00:00
ProxMenuxBot bed4b5ee49 Update helpers_cache.json 2026-04-22 12:19:08 +00:00
ProxMenuxBot f0eb402100 Update helpers_cache.json 2026-04-22 06:35:40 +00:00
MacRimiandGitHub e73486904b Revise changelog for ProxMenux v1.2.1 release
Updated the changelog for ProxMenux v1.2.1 to clarify community-reported fixes and improvements.
2026-04-21 22:27:33 +02:00
github-actions[bot] b8b49da99e Update AppImage beta build (2026-04-21 19:30:40) 2026-04-21 19:30:40 +00:00
github-actions[bot] 1119a45e93 Update AppImage release build (2026-04-21 19:28:24) 2026-04-21 19:28:24 +00:00
MacRimiandGitHub 85860cdddd Merge pull request #187 from MacRimi/develop
Create CHANGELOG.md
2026-04-21 21:23:08 +02:00
MacRimi da2e89bc94 Create CHANGELOG.md 2026-04-21 21:22:34 +02:00
MacRimiandGitHub 01ed2da10e Merge pull request #186 from MacRimi/develop
Develop
2026-04-21 21:22:13 +02:00
MacRimi 5fea839e34 Delete CHANGELOG.md 2026-04-21 21:22:01 +02:00
MacRimiandGitHub d725910e7a Delete CHANGELOG.md 2026-04-21 21:20:37 +02:00
MacRimiandGitHub 99f73ad745 Fix image link in CHANGELOG for SR-IOV state
Updated image link for SR-IOV active state in the Monitor UI.
2026-04-21 21:19:16 +02:00
MacRimiandGitHub b599a990f6 Rename riov-indicator.png to sriov-indicator.png 2026-04-21 21:17:33 +02:00
MacRimi c742393efc Update CHANGELOG.md 2026-04-21 21:15:31 +02:00
MacRimiandGitHub c403300cd2 Merge pull request #185 from MacRimi/develop
new version v1.2.1
2026-04-21 21:14:12 +02:00
MacRimiandGitHub c3a5d6201e Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-21 21:12:02 +02:00
MacRimiandGitHub 9e7350c3bb Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-21 21:09:21 +02:00
MacRimi 5bee471884 Update version.txt 2026-04-21 21:07:15 +02:00
MacRimi 77eb8c7b78 update pci_passthrough_helpers.sh 2026-04-21 21:06:22 +02:00
ProxMenuxBot acf2302755 Update helpers_cache.json 2026-04-21 18:20:49 +00:00
ProxMenuxBot 8fedd3defe Update helpers_cache.json 2026-04-21 12:19:33 +00:00
ProxMenuxBot adda8181a6 Update helpers_cache.json 2026-04-21 06:35:35 +00:00
ProxMenuxBot 789494cc89 Update helpers_cache.json 2026-04-21 00:23:27 +00:00
ProxMenuxBot 561086e940 Update helpers_cache.json 2026-04-19 18:10:57 +00:00
MacRimi 20c1140676 Create build-appimage-manual.yml 2026-04-19 19:33:58 +02:00
github-actions[bot] 9bf99c0fdd Update AppImage beta build (2026-04-19 11:49:17) 2026-04-19 11:49:17 +00:00
MacRimi 899eb61dcf update verified_ai_models.json 2026-04-19 13:47:12 +02:00
github-actions[bot] a5e6e112a5 Update AppImage beta build (2026-04-19 10:29:06) 2026-04-19 10:29:06 +00:00
MacRimi 834795d6d9 update gpu-switch-mode-indicator.tsx 2026-04-19 12:26:52 +02:00
ProxMenuxBot 8442cbca77 Update helpers_cache.json 2026-04-19 00:21:12 +00:00
github-actions[bot] bcca760403 Update AppImage beta build (2026-04-19 00:10:21) 2026-04-19 00:10:21 +00:00
github-actions[bot] 102a58a068 Update AppImage release build (2026-04-19 00:07:37) 2026-04-19 00:07:37 +00:00
MacRimiandGitHub 4e849d5309 Merge pull request #178 from MacRimi/develop
update notification_events.py
2026-04-19 02:03:53 +02:00
MacRimiandGitHub 44e92c8bf0 Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-19 02:03:35 +02:00
MacRimiandGitHub 873f5ae51e Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-19 02:03:24 +02:00
MacRimi 3e0b907138 update notification_events.py 2026-04-19 02:01:04 +02:00
MacRimiandGitHub 7ee6b6a96b Merge pull request #177 from MacRimi/develop
Update nvidia_installer.sh
2026-04-18 19:21:21 +02:00
MacRimi 14adb673f6 Update nvidia_installer.sh 2026-04-18 19:20:36 +02:00
MacRimiandGitHub 91381f0850 Merge pull request #176 from MacRimi/develop
Develop
2026-04-18 19:01:18 +02:00
MacRimi d3e91b5d06 Update menu 2026-04-18 19:00:08 +02:00
MacRimi 74fcd7d569 Update beta_version.txt 2026-04-18 18:53:31 +02:00
MacRimi 0843cd8363 Create beta_version.txt 2026-04-18 18:52:07 +02:00
MacRimiandGitHub 8c3d022506 Merge pull request #175 from MacRimi/develop
Develop
2026-04-18 18:51:06 +02:00
MacRimi a5a55f3c7d Delete beta_version.txt 2026-04-18 18:50:29 +02:00
MacRimi 2fb9e74a13 Update menu 2026-04-18 18:48:33 +02:00
MacRimi f950882ffd Update beta_version.txt 2026-04-18 18:20:57 +02:00
MacRimiandGitHub 18b3b572f0 Delete beta_version.txt 2026-04-18 18:16:31 +02:00
MacRimiandGitHub 023e3ff59b Merge pull request #174 from MacRimi/develop
Create beta_version.txt
2026-04-18 18:02:59 +02:00
MacRimi 7318c81fe0 Create beta_version.txt 2026-04-18 18:01:40 +02:00
MacRimiandGitHub f516a1cf4c Merge pull request #173 from MacRimi/develop
Develop
2026-04-18 17:58:51 +02:00
MacRimi 43959fc758 Delete beta_version.txt 2026-04-18 17:58:02 +02:00
MacRimi ff7b1e10a4 Create version.txt 2026-04-18 17:55:35 +02:00
MacRimiandGitHub b049712cd6 Update CHANGELOG for ProxMenux v1.2.0 release
Document the release of ProxMenux v1.2.0 with AI enhancements and performance improvements.
2026-04-18 14:21:20 +02:00
MacRimiandGitHub 2cdfc60fe1 Update CHANGELOG for ProxMenux v1.2.0 release
This release introduces AI-enhanced notifications, a redesigned multi-channel notification system, and performance improvements. It consolidates recent work on Storage, Hardware, and GPU/TPU scripts.
2026-04-18 14:20:31 +02:00
MacRimiandGitHub 2739cb0894 Revise CHANGELOG for version v1.1.9
Updated changelog for version v1.1.9 with new entries.
2026-04-18 14:15:15 +02:00
MacRimiandGitHub 3e5ef4fa08 Fix menu option display height in hw_grafics_menu.sh 2026-04-18 09:15:37 +02:00
MacRimiandGitHub a7a010d660 Fix spinner stop call and add newline at end of file 2026-04-18 09:14:30 +02:00
MacRimi 67000f5ff1 Update nvidia_installer.sh 2026-04-18 09:13:52 +02:00
MacRimiandGitHub efa111e2dd Refactor message building in menu_Helper_Scripts.sh 2026-04-18 09:06:01 +02:00
MacRimiandGitHub 813798ec2b Add message for completed NVIDIA uninstallation
Add confirmation message after NVIDIA uninstallation steps.
2026-04-18 09:05:18 +02:00
MacRimi c8b1cd0fab Update nvidia_installer.sh 2026-04-18 08:58:50 +02:00
MacRimiandGitHub 9220dfb7a3 Update version.txt 2026-04-18 01:24:52 +02:00
MacRimi f6e9497f1e Delete version.txt 2026-04-18 01:23:47 +02:00
MacRimiandGitHub 45e7713638 Update menu 2026-04-18 01:21:11 +02:00
MacRimi 802dc491f8 Update menu 2026-04-18 01:20:39 +02:00
MacRimi 3ca5a36240 Update config_menu.sh 2026-04-18 01:13:13 +02:00
MacRimi 3046299414 Update hw_grafics_menu.sh 2026-04-18 01:03:16 +02:00
MacRimiandGitHub 46edd4e3e4 Remove safe self-update and beta check functions 2026-04-18 00:59:54 +02:00
MacRimiandGitHub 800a18ac60 Eliminate syntax check for installed launcher
Removed defensive checks for launcher script syntax during installation.
2026-04-18 00:59:23 +02:00
MacRimi 37c60cb82a Upate menu 2026-04-18 00:58:25 +02:00
MacRimi 9446112081 Update beta_version.txt 2026-04-18 00:38:32 +02:00
MacRimiandGitHub 07384e4d7c Implement safe self-update mechanism
Add safe self-update functionality to prevent launcher corruption.
2026-04-18 00:36:56 +02:00
MacRimiandGitHub 1f7bf74970 Add syntax check for installed launcher script
Added defensive checks to ensure the installed launcher script passes syntax validation before proceeding with installation.
2026-04-18 00:36:30 +02:00
MacRimi 4b72490486 update menu 2026-04-18 00:33:40 +02:00
MacRimiandGitHub 9e4e0bc24a Update version.txt 2026-04-18 00:11:49 +02:00
MacRimiandGitHub 18687666a6 Update README with security note on VirusTotal
Added a security note regarding VirusTotal false positives.
2026-04-18 00:08:58 +02:00
MacRimiandGitHub ccb924f8f6 Bump version from 1.1.9 to 1.2.0 2026-04-18 00:03:40 +02:00
MacRimiandGitHub 58ac4ee743 Update CHANGELOG to remove UI flow consistency details
Removed UI flow consistency section from CHANGELOG.
2026-04-18 00:02:25 +02:00
MacRimiandGitHub d515448113 Release v1.2.0 with AI enhancements and optimizations
This release introduces AI-enhanced notifications, a redesigned multi-channel notification system, expanded hardware detection, and significant performance optimizations.
2026-04-18 00:00:52 +02:00
github-actions[bot] ec3de30b14 Update AppImage release build (2026-04-17 22:00:49) 2026-04-17 22:00:49 +00:00
MacRimiandGitHub 09ff203662 Update README with security note on VirusTotal
Added a security note regarding VirusTotal false positives for the installation script.
2026-04-17 23:58:31 +02:00
MacRimiandGitHub 4837b66089 Update README.md 2026-04-17 23:57:15 +02:00
MacRimiandGitHub c312cf3d38 Delete beta_version.txt 2026-04-17 23:56:38 +02:00
MacRimiandGitHub 46f5af1966 Delete install_proxmenux_beta.sh 2026-04-17 23:56:25 +02:00
MacRimiandGitHub 85f98dff9a Delete AppImage/ProxMenux-Monitor.AppImage.sha256 2026-04-17 23:54:41 +02:00
MacRimiandGitHub bd56964860 Delete AppImage/ProxMenux-1.2.0.AppImage 2026-04-17 23:54:30 +02:00
MacRimiandGitHub 481a653b75 Delete AppImage/ProxMenux-1.0.2.AppImage 2026-04-17 23:54:20 +02:00
MacRimiandGitHub f94b59954f Merge pull request #169 from MacRimi/develop
ProxMenux v1.2.0
2026-04-17 23:51:51 +02:00
MacRimiandGitHub 4f7977b5ca Merge branch 'main' into develop 2026-04-17 23:49:29 +02:00
MacRimiandGitHub 512cc11894 Delete AppImage/ProxMenux-1.0.1.AppImage 2026-04-17 23:39:24 +02:00
github-actions[bot] 51be0bd3bd Update AppImage release build (2026-04-17 21:38:53) 2026-04-17 21:38:53 +00:00
MacRimi 0f6095f8c3 Create ProxMenux_ai.png 2026-04-17 23:28:16 +02:00
MacRimi 831bf67ee4 update install 2026-04-17 22:26:25 +02:00
MacRimiandGitHub e7cca5e532 Delete AppImage/ProxMenux-1.0.2-beta.AppImage 2026-04-17 22:02:18 +02:00
MacRimi 75b08de934 update hw_grafics_menu.sh 2026-04-17 21:55:53 +02:00
github-actions[bot] 987b665115 Update AppImage beta build (2026-04-17 18:58:06) 2026-04-17 18:58:06 +00:00
MacRimi 68ca68c6f1 update hardware.tsx 2026-04-17 20:56:07 +02:00
ProxMenuxBot 5c4ca290fb Update helpers_cache.json 2026-04-17 18:16:35 +00:00
github-actions[bot] 1d61442a49 Update AppImage beta build (2026-04-17 18:03:26) 2026-04-17 18:03:26 +00:00
MacRimi 0e2ede5e66 update v1.2.0 2026-04-17 20:01:30 +02:00
github-actions[bot] 0db74814be Update AppImage beta build (2026-04-17 17:56:07) 2026-04-17 17:56:07 +00:00
MacRimi 75c6f74fc4 update nstall_coral_pve9.sh 2026-04-17 19:53:17 +02:00
MacRimi e6faec24fa update nvidia_installer.sh 2026-04-17 19:02:46 +02:00
MacRimi 35b7d01d7e update nvidia_installer.sh 2026-04-17 18:35:42 +02:00
MacRimi 415bc439bb Update nvidia_update.sh 2026-04-17 18:24:07 +02:00
MacRimi fe47522275 Update nvidia_update.sh 2026-04-17 18:07:43 +02:00
MacRimi 24b97831a4 Update nvidia_update.sh 2026-04-17 18:06:21 +02:00
github-actions[bot] ac95a5afba Update AppImage beta build (2026-04-17 15:39:07) 2026-04-17 15:39:07 +00:00
MacRimi 03850d2958 update virtual-machines.tsx 2026-04-17 17:36:57 +02:00
github-actions[bot] c7b49cfc4a Update AppImage beta build (2026-04-17 15:03:32) 2026-04-17 15:03:32 +00:00
MacRimi 5398211ab5 update virtual-machines.tsx 2026-04-17 17:01:24 +02:00
github-actions[bot] dc8ebb651a Update AppImage beta build (2026-04-17 14:41:07) 2026-04-17 14:41:07 +00:00
MacRimi 039e35f3c5 update health_monitor.py 2026-04-17 16:39:08 +02:00
github-actions[bot] ffadb2c508 Update AppImage beta build (2026-04-17 13:42:17) 2026-04-17 13:42:17 +00:00
MacRimi 019e98e6b6 update flask_proxmenux_routes.py 2026-04-17 15:33:50 +02:00
github-actions[bot] c998e39038 Update AppImage beta build (2026-04-17 08:42:52) 2026-04-17 08:42:52 +00:00
MacRimi baa2ff4fa9 update health_persistence.py 2026-04-17 10:38:39 +02:00
github-actions[bot] 4b6a91e74c Update AppImage beta build (2026-04-16 18:54:07) 2026-04-16 18:54:07 +00:00
MacRimi 37f56c8a16 Update settings.tsx 2026-04-16 20:51:52 +02:00
github-actions[bot] 09bb47f408 Update AppImage beta build (2026-04-16 18:42:29) 2026-04-16 18:42:29 +00:00
MacRimi 5db6762690 upddate flask_proxmenux_routes.py 2026-04-16 20:40:34 +02:00
github-actions[bot] b1cc880253 Update AppImage beta build (2026-04-16 18:15:34) 2026-04-16 18:15:34 +00:00
MacRimi 7d4ea806a2 Update flask_proxmenux_routes.py 2026-04-16 20:13:13 +02:00
github-actions[bot] 2b306c9033 Update AppImage beta build (2026-04-16 18:05:44) 2026-04-16 18:05:44 +00:00
MacRimi a776d6b746 Update health_persistence.py 2026-04-16 20:01:56 +02:00
github-actions[bot] 07f87de742 Update AppImage beta build (2026-04-16 17:36:53) 2026-04-16 17:36:53 +00:00
MacRimi cf871da880 update health_persistence.py 2026-04-16 19:33:47 +02:00
github-actions[bot] 774d42d5be Update AppImage beta build (2026-04-16 17:20:45) 2026-04-16 17:20:46 +00:00
MacRimi 6660122e69 Update health_persistence.py 2026-04-16 19:18:42 +02:00
github-actions[bot] 1ef4bc4fed Update AppImage beta build (2026-04-16 17:13:00) 2026-04-16 17:13:00 +00:00
MacRimi ee1204c566 update health_monitor.py 2026-04-16 19:10:47 +02:00
github-actions[bot] 7f2b0c5de1 Update AppImage beta build (2026-04-16 16:12:35) 2026-04-16 16:12:35 +00:00
MacRimi 9737ffd996 Update storage-overview.tsx 2026-04-16 18:10:27 +02:00
github-actions[bot] be60b7e17c Update AppImage beta build (2026-04-16 16:02:46) 2026-04-16 16:02:46 +00:00
MacRimi 97368a6f44 update storage-overview.tsx 2026-04-16 17:58:27 +02:00
github-actions[bot] c3d7f01b40 Update AppImage beta build (2026-04-16 15:41:42) 2026-04-16 15:41:42 +00:00
MacRimi cbebd5147c update storage-overview.tsx 2026-04-16 17:36:23 +02:00
github-actions[bot] 4611be734f Update AppImage beta build (2026-04-16 15:00:04) 2026-04-16 15:00:04 +00:00
MacRimi 528b57664f Update storage-overview.tsx 2026-04-16 16:57:40 +02:00
github-actions[bot] cc86d68507 Update AppImage beta build (2026-04-16 14:44:10) 2026-04-16 14:44:10 +00:00
MacRimi 7c8da462db update storage-overview.tsx 2026-04-16 16:42:11 +02:00
github-actions[bot] b7963c3b70 Update AppImage beta build (2026-04-16 14:11:03) 2026-04-16 14:11:03 +00:00
MacRimi d51dd35376 Update storage-overview.tsx 2026-04-16 16:08:58 +02:00
github-actions[bot] 196086498e Update AppImage beta build (2026-04-16 13:54:35) 2026-04-16 13:54:35 +00:00
MacRimi f6ff76f9ce Update storage-overview.tsx 2026-04-16 15:52:26 +02:00
github-actions[bot] 20a2db6739 Update AppImage beta build (2026-04-16 13:38:55) 2026-04-16 13:38:55 +00:00
MacRimi aa3b8ebe82 Update storage-overview.tsx 2026-04-16 15:28:48 +02:00
github-actions[bot] d03afa1793 Update AppImage beta build (2026-04-16 13:11:30) 2026-04-16 13:11:30 +00:00
MacRimi 056cee2f94 Update storage-overview.tsx 2026-04-16 15:09:16 +02:00
github-actions[bot] f80e087429 Update AppImage beta build (2026-04-16 12:47:49) 2026-04-16 12:47:49 +00:00
MacRimi eea765300e Update flask_server.py 2026-04-16 14:45:47 +02:00
ProxMenuxBot c2396d7e81 Update helpers_cache.json 2026-04-16 12:19:51 +00:00
github-actions[bot] 0b94acf7f6 Update AppImage beta build (2026-04-16 10:10:25) 2026-04-16 10:10:25 +00:00
MacRimi 324cb23f75 Update storage-overview.tsx 2026-04-16 12:08:36 +02:00
github-actions[bot] b341ba8297 Update AppImage beta build (2026-04-16 09:46:02) 2026-04-16 09:46:02 +00:00
MacRimi f5b9da0908 update storage-overview.tsx 2026-04-16 11:43:42 +02:00
ProxMenuxBot c83672a4bc Update helpers_cache.json 2026-04-16 00:24:34 +00:00
ProxMenuxBot af8e3f6a71 Update helpers_cache.json 2026-04-15 12:18:08 +00:00
ProxMenuxBot 5025d38a76 Update helpers_cache.json 2026-04-14 12:18:35 +00:00
ProxMenuxBot f3c7fb97fb Update helpers_cache.json 2026-04-13 18:23:34 +00:00
github-actions[bot] cb2ab5f67b Update AppImage beta build (2026-04-13 17:32:33) 2026-04-13 17:32:33 +00:00
MacRimi 003c8850b7 Update storage-overview.tsx 2026-04-13 19:30:19 +02:00
github-actions[bot] d9461c170d Update AppImage beta build (2026-04-13 17:25:04) 2026-04-13 17:25:04 +00:00
MacRimi 57b5de4a4a Update storage-overview.tsx 2026-04-13 19:22:59 +02:00
github-actions[bot] c406c52086 Update AppImage beta build (2026-04-13 17:16:44) 2026-04-13 17:16:44 +00:00
MacRimi df4855ec47 Update storage-overview.tsx 2026-04-13 19:11:57 +02:00
github-actions[bot] 8c7f4a4c20 Update AppImage beta build (2026-04-13 16:53:47) 2026-04-13 16:53:47 +00:00
MacRimi 3ec733d9c6 update storage-overview.tsx 2026-04-13 18:49:18 +02:00
github-actions[bot] 71c64d1ae5 Update AppImage beta build (2026-04-13 13:31:25) 2026-04-13 13:31:25 +00:00
MacRimi 98becfd368 update storage-overview.tsx 2026-04-13 15:29:22 +02:00
github-actions[bot] 4eea90bd97 Update AppImage beta build (2026-04-13 12:51:54) 2026-04-13 12:51:54 +00:00
MacRimi 2344935357 update storage-overview.tsx 2026-04-13 14:49:48 +02:00
ProxMenuxBot 550279ec68 Update helpers_cache.json 2026-04-13 12:20:26 +00:00
github-actions[bot] 44aefb5d3b Update AppImage beta build (2026-04-13 09:13:46) 2026-04-13 09:13:46 +00:00
MacRimi 7d3cf4d364 Update flask_server.py 2026-04-13 11:11:42 +02:00
github-actions[bot] f6ef383598 Update AppImage beta build (2026-04-13 08:09:07) 2026-04-13 08:09:07 +00:00
MacRimi a6149e3cd8 update storage-overview.tsx 2026-04-13 10:07:09 +02:00
github-actions[bot] 07f1098418 Update AppImage beta build (2026-04-13 07:50:38) 2026-04-13 07:50:38 +00:00
MacRimi 3d00f33dbf Update flask_server.py 2026-04-13 09:48:48 +02:00
MacRimi 98c859fbf8 Update storage-overview.tsx 2026-04-13 09:35:23 +02:00
github-actions[bot] 9460bee72f Update AppImage beta build (2026-04-13 07:20:17) 2026-04-13 07:20:17 +00:00
MacRimi 5a957fe904 Update storage-overview.tsx 2026-04-13 09:18:09 +02:00
github-actions[bot] 59a4b6e4ca Update AppImage beta build (2026-04-13 06:42:00) 2026-04-13 06:42:00 +00:00
MacRimi 9000316224 Update storage-overview.tsx 2026-04-13 08:38:32 +02:00
github-actions[bot] da96c57819 Update AppImage beta build (2026-04-12 21:56:49) 2026-04-12 21:56:49 +00:00
MacRimi b3cef0b009 Update storage-overview.tsx 2026-04-12 23:54:52 +02:00
MacRimi 78ace237dd Update storage-overview.tsx 2026-04-12 23:48:49 +02:00
MacRimi e94e065eca update storage-overview.tsx 2026-04-12 23:45:23 +02:00
github-actions[bot] 4ffe0f3f46 Update AppImage beta build (2026-04-12 21:13:40) 2026-04-12 21:13:40 +00:00
MacRimi 7af4150e44 update storage-overview.tsx 2026-04-12 23:11:31 +02:00
github-actions[bot] 71950369e1 Update AppImage beta build (2026-04-12 21:01:05) 2026-04-12 21:01:05 +00:00
MacRimi adb4815c9b Update storage-overview.tsx 2026-04-12 22:58:49 +02:00
github-actions[bot] 1841feb643 Update AppImage beta build (2026-04-12 20:52:32) 2026-04-12 20:52:32 +00:00
MacRimi f14a0393b7 update storage-overview.tsx 2026-04-12 22:50:30 +02:00
github-actions[bot] 710f77764b Update AppImage beta build (2026-04-12 20:37:41) 2026-04-12 20:37:41 +00:00
MacRimi ae2e86d1d1 Update storage-overview.tsx 2026-04-12 22:35:12 +02:00
github-actions[bot] 167fcb2921 Update AppImage beta build (2026-04-12 20:30:17) 2026-04-12 20:30:17 +00:00
MacRimi 47145ab9d1 update storage-overview.tsx 2026-04-12 22:28:15 +02:00
github-actions[bot] 441ee8e948 Update AppImage beta build (2026-04-12 19:58:21) 2026-04-12 19:58:21 +00:00
github-actions[bot] 9c4528dfcb Update AppImage beta build (2026-04-12 19:39:05) 2026-04-12 19:39:05 +00:00
MacRimi d7c60631b4 Update storage-overview.tsx 2026-04-12 21:37:02 +02:00
github-actions[bot] 530f5c2dbc Update AppImage beta build (2026-04-12 19:30:36) 2026-04-12 19:30:36 +00:00
MacRimi 03dc2afe8d Update storage-overview.tsx 2026-04-12 21:28:36 +02:00
MacRimi 7d35d91415 Update storage-overview.tsx 2026-04-12 21:06:01 +02:00
MacRimi 4843fae0eb Update scripts 2026-04-12 20:32:34 +02:00
ProxMenuxBot 3dfbeac541 Update helpers_cache.json 2026-04-11 00:18:18 +00:00
MacRimi 4fa4bbb08b update switch_gpu_mode.sh 2026-04-10 09:51:03 +02:00
github-actions[bot] 4204e619db Update AppImage beta build (2026-04-10 07:10:14) 2026-04-10 07:10:14 +00:00
MacRimi a663b83daa Update script-terminal-modal.tsx 2026-04-10 09:07:57 +02:00
github-actions[bot] fb218a9331 Update AppImage beta build (2026-04-09 20:08:09) 2026-04-09 20:08:09 +00:00
MacRimi 75b677576e update switch_gpu_mode_direct.sh 2026-04-09 22:05:23 +02:00
MacRimi 04bda0bf10 update switch_gpu_mode_direct.sh 2026-04-09 21:42:44 +02:00
MacRimi 8ca33dec6f update switch_gpu_mode_direct.sh 2026-04-09 21:03:06 +02:00
MacRimi eed9303e41 Update script-terminal-modal.tsx 2026-04-09 20:50:56 +02:00
MacRimi 5277e7b47d Update script-terminal-modal.tsx 2026-04-09 20:41:12 +02:00
github-actions[bot] 727c86a804 Update AppImage beta build (2026-04-09 18:32:02) 2026-04-09 18:32:02 +00:00
MacRimi 21edae5944 Update script-terminal-modal.tsx 2026-04-09 20:29:50 +02:00
github-actions[bot] a0d48a1191 Update AppImage beta build (2026-04-09 18:22:51) 2026-04-09 18:22:51 +00:00
MacRimi 086ba9e577 Update switch_gpu_mode_direct.sh 2026-04-09 20:20:46 +02:00
github-actions[bot] bda5fdbecd Update AppImage beta build (2026-04-09 18:14:32) 2026-04-09 18:14:32 +00:00
MacRimi 13d2eeb9b2 update gpu-switch-mode-indicator.tsx 2026-04-09 20:12:21 +02:00
github-actions[bot] 1bbf814ea3 Update AppImage beta build (2026-04-09 18:01:03) 2026-04-09 18:01:03 +00:00
MacRimi 6d0e5add0b create switch_gpu_mode_direct.sh 2026-04-09 19:58:54 +02:00
github-actions[bot] 06849ca666 Update AppImage beta build (2026-04-09 17:42:58) 2026-04-09 17:42:58 +00:00
MacRimi 4346a5554f Update hardware.tsx 2026-04-09 19:40:49 +02:00
github-actions[bot] 2a174df697 Update AppImage beta build (2026-04-09 17:30:29) 2026-04-09 17:30:29 +00:00
MacRimi 28df51092c update gpu-switch-mode-indicator.tsx 2026-04-09 19:27:45 +02:00
github-actions[bot] ca70852060 Update AppImage beta build (2026-04-09 13:44:32) 2026-04-09 13:44:32 +00:00
MacRimi 86df5cda6e Update hardware.tsx 2026-04-09 15:42:05 +02:00
github-actions[bot] 7db7b7d98f Update AppImage beta build (2026-04-09 13:31:57) 2026-04-09 13:31:57 +00:00
MacRimi 3cede88a3d update gpu-switch-mode-indicator.tsx 2026-04-09 15:29:41 +02:00
github-actions[bot] af63b71ab8 Update AppImage beta build (2026-04-09 13:04:37) 2026-04-09 13:04:38 +00:00
MacRimi 2805c46a22 update gpu-switch-mode-indicator.tsx 2026-04-09 15:02:25 +02:00
github-actions[bot] 104353f013 Update AppImage beta build (2026-04-09 12:22:57) 2026-04-09 12:22:57 +00:00
MacRimi 435f346d98 update notification_events.py 2026-04-09 14:08:56 +02:00
MacRimi 2b8caa924f update notification_events.py 2026-04-09 12:34:03 +02:00
ProxMenuxBot dd22f303ac Update helpers_cache.json 2026-04-08 18:23:52 +00:00
ProxMenuxBot 02141ae16f Update helpers_cache.json 2026-04-08 12:16:35 +00:00
ProxMenuxBot b9b10a69d5 Update helpers_cache.json 2026-04-07 18:17:36 +00:00
github-actions[bot] d8631a8594 Update AppImage beta build (2026-04-07 13:37:06) 2026-04-07 13:37:06 +00:00
MacRimi 463769aba9 Update health_persistence.py 2026-04-07 15:34:48 +02:00
ProxMenuxBot a9fbaf15b2 Update helpers_cache.json 2026-04-07 12:16:27 +00:00
ProxMenuxBot b04c3b9f78 Update helpers_cache.json 2026-04-07 00:19:15 +00:00
MacRimi f2229c2393 Update add_gpu_vm.sh 2026-04-06 23:10:02 +02:00
MacRimi b4aadfee3e Update add_gpu_vm.sh 2026-04-06 22:58:17 +02:00
MacRimi 840e8a774e Update add_gpu_vm.sh 2026-04-06 22:51:30 +02:00
MacRimi c429d391f5 Update add_gpu_vm.sh 2026-04-06 22:34:37 +02:00
MacRimi 2fda3dd1d5 Update add_gpu_vm.sh 2026-04-06 22:26:54 +02:00
MacRimi 4018093c9d Update add_gpu_vm.sh 2026-04-06 22:21:38 +02:00
MacRimi 2f5b8ce4ed update add_gpu_vm.sh 2026-04-06 22:04:38 +02:00
MacRimi 2174c04e4f update add_gpu_vm.sh 2026-04-06 21:50:46 +02:00
MacRimi 109a4d7f54 update switch_gpu_mode.sh 2026-04-06 21:12:13 +02:00
ProxMenuxBot 080ee5cff0 Update helpers_cache.json 2026-04-06 18:16:41 +00:00
MacRimi 257668ab96 Update gpu_hook_guard_helpers.sh 2026-04-06 19:36:35 +02:00
MacRimi d747ac7659 Update gpu_hook_guard_helpers.sh 2026-04-06 19:25:47 +02:00
MacRimi d7c04ebbc7 update vm_storage_helpers.sh 2026-04-06 19:13:31 +02:00
MacRimi c5b01b4bb7 update switch_gpu_mode.sh 2026-04-06 18:40:57 +02:00
MacRimi e8cc90b83d update m_storage_helpers.sh 2026-04-06 17:26:01 +02:00
MacRimi d3974018d8 add_controller_nvme_vm.sh 2026-04-06 17:16:26 +02:00
MacRimi 10ce9dbcde add_controller_nvme_vm.sh 2026-04-06 13:39:07 +02:00
github-actions[bot] 5be4bd2ae9 Update AppImage beta build (2026-04-06 10:16:08) 2026-04-06 10:16:08 +00:00
MacRimi ea1d8ab037 Update system-overview.tsx 2026-04-06 12:06:08 +02:00
MacRimi adde2ce5b9 update health_persistence.py 2026-04-06 12:02:05 +02:00
ProxMenuxBot 9d37a7293b Update helpers_cache.json 2026-04-06 00:19:03 +00:00
ProxMenuxBot 975d4aab60 Update helpers_cache.json 2026-04-05 12:07:31 +00:00
github-actions[bot] 5ead9ee661 Update AppImage beta build (2026-04-05 10:19:43) 2026-04-05 10:19:43 +00:00
MacRimi 95e876b37f update health_monitor.py 2026-04-05 12:17:42 +02:00
github-actions[bot] 4c72d0b3ef Update AppImage beta build (2026-04-05 10:05:01) 2026-04-05 10:05:01 +00:00
MacRimi e7dc030304 Update health_monitor.py 2026-04-05 12:02:59 +02:00
github-actions[bot] c9d5c84d35 Update AppImage beta build (2026-04-05 10:00:14) 2026-04-05 10:00:14 +00:00
MacRimi 4b01ba1d2f update health_monitor.py 2026-04-05 11:58:14 +02:00
github-actions[bot] 723e56ada2 Update AppImage beta build (2026-04-05 09:53:20) 2026-04-05 09:53:20 +00:00
MacRimi e9851da12f update virtual-machines.tsx 2026-04-05 11:51:26 +02:00
MacRimi 5826b0419b update pci_passthrough_helpers.sh 2026-04-05 11:24:08 +02:00
ProxMenuxBot 77124c4549 Update helpers_cache.json 2026-04-05 00:19:09 +00:00
github-actions[bot] 00dbd1c87e Update AppImage beta build (2026-04-03 23:33:41) 2026-04-03 23:33:41 +00:00
MacRimi e0e732dd2c update health_persistence.py 2026-04-04 01:31:37 +02:00
MacRimi ce69c0ba1f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-04 00:55:09 +02:00
MacRimi 58c4e115ba update add_gpu_vm.sh 2026-04-04 00:54:57 +02:00
github-actions[bot] 44478057dd Update AppImage beta build (2026-04-03 22:53:29) 2026-04-03 22:53:29 +00:00
MacRimi d1efae37a4 update health_persistence.py 2026-04-04 00:51:20 +02:00
ProxMenuxBot 8d8e4bab26 Update helpers_cache.json 2026-04-03 12:10:34 +00:00
MacRimi db113f0433 Update menu_post_install.sh 2026-04-03 10:44:10 +02:00
MacRimi 5a66167709 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-03 10:34:56 +02:00
MacRimi 7326201b0a update system_utils.sh 2026-04-03 10:34:46 +02:00
github-actions[bot] f116a6b0f2 Update AppImage beta build (2026-04-03 08:07:42) 2026-04-03 08:07:42 +00:00
MacRimi 3087ab36da Update flask_server.py 2026-04-03 10:05:44 +02:00
ProxMenuxBot 28a7189905 Update helpers_cache.json 2026-04-03 00:19:43 +00:00
ProxMenuxBot 0f8e215706 Update helpers_cache.json 2026-04-02 18:15:08 +00:00
github-actions[bot] fd92bed465 Update AppImage beta build (2026-04-02 18:01:23) 2026-04-02 18:01:23 +00:00
MacRimi 281f6975ec Update notification_templates.py 2026-04-02 19:59:27 +02:00
github-actions[bot] e2c40eae48 Update AppImage beta build (2026-04-02 17:26:45) 2026-04-02 17:26:45 +00:00
MacRimi 26968b02a1 update post_install.sh 2026-04-02 19:23:55 +02:00
MacRimi 0d8070455d Update notification_templates.py 2026-04-02 18:29:35 +02:00
MacRimi 2537e964a5 Update notification_templates.py 2026-04-02 17:38:47 +02:00
MacRimi ca7134e610 Update notification_templates.py 2026-04-02 17:20:02 +02:00
MacRimi a8c591affd Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 17:05:10 +02:00
MacRimi e3842f200d Update add_gpu_lxc.sh 2026-04-02 17:05:00 +02:00
github-actions[bot] cc952d8c79 Update AppImage beta build (2026-04-02 15:01:15) 2026-04-02 15:01:15 +00:00
MacRimi e11daa0b36 update ai_context_enrichment.py 2026-04-02 16:59:09 +02:00
github-actions[bot] 873c77d659 Update AppImage beta build (2026-04-02 09:40:56) 2026-04-02 09:40:56 +00:00
MacRimi 1756a6eb28 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 11:38:51 +02:00
MacRimi 9636d3671c Update notification_events.py 2026-04-02 11:38:36 +02:00
github-actions[bot] 22e2ebb96c Update AppImage beta build (2026-04-02 09:12:04) 2026-04-02 09:12:04 +00:00
MacRimi c53d3807e7 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 11:10:03 +02:00
MacRimi 23a6392979 Update notification_events.py 2026-04-02 11:09:50 +02:00
github-actions[bot] fa599ad183 Update AppImage beta build (2026-04-02 07:52:09) 2026-04-02 07:52:09 +00:00
MacRimi e79fdcfe58 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 09:50:10 +02:00
MacRimi 3746f356b9 Update notification_events.py 2026-04-02 09:49:54 +02:00
github-actions[bot] a5f3362d6e Update AppImage beta build (2026-04-02 07:31:50) 2026-04-02 07:31:50 +00:00
MacRimi 2072264918 update notification_manager.py 2026-04-02 09:29:29 +02:00
github-actions[bot] a5cb01133e Update AppImage beta build (2026-04-02 07:01:18) 2026-04-02 07:01:18 +00:00
MacRimi 62b55cbf16 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-04-02 08:58:02 +02:00
MacRimi 7ea0c4d36c Update notification_events.py 2026-04-02 08:57:37 +02:00
github-actions[bot] 546200844e Update AppImage beta build (2026-04-02 06:40:10) 2026-04-02 06:40:10 +00:00
MacRimi 007e3d1c0e update notification_templates.py 2026-04-02 08:38:01 +02:00
ProxMenuxBot 74a508e3a8 Update helpers_cache.json 2026-04-02 00:17:06 +00:00
MacRimi 5f5dc171be update scripts gpu 2026-04-01 23:09:51 +02:00
github-actions[bot] bccba6e9b9 Update AppImage beta build (2026-04-01 13:27:08) 2026-04-01 13:27:08 +00:00
MacRimi c2073a5db5 Update health_monitor.py 2026-04-01 15:24:47 +02:00
ProxMenuxBot 52ad229d93 Update helpers_cache.json 2026-04-01 12:16:07 +00:00
github-actions[bot] f6a7352672 Update AppImage beta build (2026-04-01 11:24:43) 2026-04-01 11:24:43 +00:00
MacRimi 46e0322e6f update health_persistence.py 2026-04-01 13:01:48 +02:00
MacRimi 618538a854 update health_persistence.py 2026-04-01 12:30:19 +02:00
MacRimi d62396717a update health_persistence.py 2026-04-01 12:03:54 +02:00
MacRimi a734fa5566 Update health_monitor.py 2026-04-01 00:01:12 +02:00
MacRimi f98b302b94 Update health_persistence.py 2026-03-31 23:47:38 +02:00
MacRimi 215d36900a Update health_persistence.py 2026-03-31 23:20:33 +02:00
MacRimi 2df55d2839 Update health_monitor.py 2026-03-31 23:14:48 +02:00
MacRimi e00051caa7 update health_monitor.py 2026-03-31 23:00:00 +02:00
MacRimi 5138b2f1d5 update health_persistence.py 2026-03-31 20:55:17 +02:00
MacRimi 65dfb9103f update /system-logs.tsx 2026-03-31 20:10:58 +02:00
MacRimi 39bbc036cd update system-logs.tsx 2026-03-31 19:38:23 +02:00
MacRimi aaf6dd36f0 update system-logs.tsx 2026-03-31 19:30:10 +02:00
MacRimi e7519e68a3 update system-logs.tsx 2026-03-31 19:09:41 +02:00
MacRimi ad5803ef9c update proxmox-dashboard.tsx 2026-03-31 18:51:38 +02:00
MacRimi c04b514a2a update proxmox-dashboard.tsx 2026-03-31 18:42:35 +02:00
MacRimiandGitHub cb9f567154 Fix link in security note in README.md
Updated the link in the security note to point to the correct GitHub issue.
2026-03-30 23:40:06 +02:00
MacRimi 80afa789e7 Update notification service 2026-03-30 22:26:20 +02:00
MacRimi 43f2ce52a5 Update notification service 2026-03-30 22:10:40 +02:00
MacRimi cf2e24269e Update notification_templates.py 2026-03-30 21:34:17 +02:00
MacRimi 6899650bf8 Update health_persistence.py 2026-03-30 21:19:08 +02:00
MacRimi c16df51892 Update notification_templates.py 2026-03-30 21:10:54 +02:00
MacRimi c549737ad0 Update HealthMonitor 2026-03-30 20:52:25 +02:00
MacRimi a85b51843a Update switch.tsx 2026-03-30 20:27:40 +02:00
MacRimi 60d401f5ea Update settings.tsx 2026-03-30 20:19:00 +02:00
ProxMenuxBot ca02b9001f Update helpers_cache.json 2026-03-30 18:17:00 +00:00
MacRimi 2fc5e2865d Update notification service 2026-03-30 19:55:19 +02:00
MacRimi 261b2bfb3c Update notification_manager.py 2026-03-30 19:16:31 +02:00
MacRimi 30e32e89b2 Update notification_manager.py 2026-03-30 19:11:19 +02:00
MacRimiandGitHub 8b1a2b9bff Update security note in README.md
Clarified security note regarding VirusTotal false positives.
2026-03-30 19:04:53 +02:00
MacRimiandGitHub f71289b248 Update README with security note and support request
Added a security note regarding VirusTotal false positives and encouraged support for the project.
2026-03-30 19:04:02 +02:00
MacRimi 54eab9af49 Update notification service 2026-03-30 18:53:03 +02:00
ProxMenuxBot a05546e811 Update helpers_cache.json 2026-03-30 12:16:12 +00:00
ProxMenuxBot 276c648f29 Update helpers_cache.json 2026-03-29 18:07:42 +00:00
ProxMenuxBot 8c389f4790 Update helpers_cache.json 2026-03-29 12:07:15 +00:00
ProxMenuxBot a09144d21a Update helpers_cache.json 2026-03-29 00:18:49 +00:00
MacRimi cb9a43f496 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-28 23:18:42 +01:00
MacRimi 30606e4743 Update beta_version.txt 2026-03-28 23:18:03 +01:00
github-actions[bot] d05913fbdf Update AppImage beta build (2026-03-28 22:15:36) 2026-03-28 22:15:36 +00:00
MacRimi a34efb50e0 Update notification service 2026-03-28 23:13:35 +01:00
MacRimi a26c69fc8d Update beta_version.txt 2026-03-28 23:09:39 +01:00
MacRimi 107803705c Update beta_version.txt 2026-03-28 23:02:01 +01:00
MacRimi 858b1689bf Update security_menu.sh 2026-03-28 22:56:07 +01:00
MacRimi 641acbd1f4 Update security_menu.sh 2026-03-28 22:53:56 +01:00
MacRimi 1de76ae6c1 Create security_menu.sh 2026-03-28 22:50:44 +01:00
github-actions[bot] 94f535b8ec Update AppImage beta build (2026-03-28 21:48:28) 2026-03-28 21:48:28 +00:00
MacRimi f072e285fc Update startup_grace.py 2026-03-28 22:44:58 +01:00
MacRimi 2c363bbb8e Update health_persistence.py 2026-03-28 22:44:52 +01:00
MacRimi 1c2b87d584 Update notification_templates.py 2026-03-28 22:37:21 +01:00
MacRimi e55154bd5e Update notification service 2026-03-28 22:19:33 +01:00
MacRimi 71098abb65 Update terminal-panel.tsx 2026-03-28 22:13:20 +01:00
MacRimi 7a3a4d1413 Update terminal-panel.tsx 2026-03-28 22:04:47 +01:00
MacRimi 7858fb0283 Update terminal-panel.tsx 2026-03-28 21:50:04 +01:00
MacRimi 2f9959c009 Update virtual-machines.tsx 2026-03-28 21:32:59 +01:00
MacRimi e7d3b20295 Update menus 2026-03-28 19:32:15 +01:00
MacRimi 264fa4982f Update notification service 2026-03-28 19:29:16 +01:00
MacRimi 9636614761 Update notification service 2026-03-28 19:25:05 +01:00
MacRimi ac6561ca52 Update menus 2026-03-28 18:29:58 +01:00
MacRimi 923172d39b udate lynis install 2026-03-28 17:27:29 +01:00
MacRimi d46c42d26b Unistall Fail2ban 2026-03-28 17:01:08 +01:00
MacRimi d628233982 Update notification service 2026-03-28 15:50:30 +01:00
ProxMenuxBot e9e1d471ec Update helpers_cache.json 2026-03-28 12:07:42 +00:00
MacRimi f4740916f5 Update install_coral_lxc.sh 2026-03-28 12:49:52 +01:00
ProxMenuxBot 3a2c9b1b05 Update helpers_cache.json 2026-03-28 00:17:05 +00:00
MacRimi 4cc1147579 Update notification service 2026-03-27 20:42:03 +01:00
MacRimi 976f23a90e update notification service 2026-03-27 20:31:21 +01:00
MacRimi 8ed500adf7 Update notification service 2026-03-27 20:17:59 +01:00
MacRimi 8658044c0c Update notification service 2026-03-27 19:55:15 +01:00
MacRimi 0edc2cc3af Update notification service 2026-03-27 19:40:17 +01:00
ProxMenuxBot f4db4cde13 Update helpers_cache.json 2026-03-27 18:16:33 +00:00
MacRimi 6bb9313b95 Update notification service 2026-03-27 19:15:11 +01:00
ProxMenuxBot 6447dfef50 Update helpers_cache.json 2026-03-27 12:12:51 +00:00
ProxMenuxBot 55cb3a1267 Update helpers_cache.json 2026-03-27 00:18:11 +00:00
MacRimi 7c5e7208b9 Update notification service 2026-03-26 20:04:53 +01:00
ProxMenuxBot aad4b13fda Update helpers_cache.json 2026-03-26 18:19:00 +00:00
MacRimi 839a20df97 Update notification service 2026-03-26 19:05:11 +01:00
MacRimi d497763e38 Update repository and clean up 2026-03-26 19:03:01 +01:00
MacRimi 12c088c10b Update nvidia_installer.sh 2026-03-26 18:18:26 +01:00
MacRimi 8c6a6bece6 Update menu_Helper_Scripts.sh 2026-03-26 17:43:34 +01:00
ProxMenuxBot 819ca8a212 Update helpers_cache.json 2026-03-26 12:16:15 +00:00
MacRimi 8d1becbd8c Update shutdown-notify.sh 2026-03-26 01:11:12 +01:00
MacRimi ebb7491c58 Create install_proxmenux_beta.sh 2026-03-26 00:56:20 +01:00
MacRimi 4f1278c37a Delete install_proxmenux_beta.sh 2026-03-26 00:54:28 +01:00
MacRimiandGitHub 8ddee9013b Update install_proxmenux_beta.sh 2026-03-26 00:53:14 +01:00
MacRimi 7fe233ae2e Update install_proxmenux_beta.sh 2026-03-26 00:51:14 +01:00
MacRimi f7b37b1559 Update install_proxmenux_beta.sh 2026-03-26 00:49:06 +01:00
MacRimi 0b3624dbd5 Update notification service 2026-03-26 00:32:52 +01:00
MacRimi fb066eb2e9 Update install_proxmenux_beta.sh 2026-03-25 23:57:33 +01:00
MacRimi 22fadbb87b Update install_proxmenux_beta.sh 2026-03-25 23:56:27 +01:00
MacRimi 3b119d528c Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-25 23:50:59 +01:00
MacRimi bb3d3d759e Update install_proxmenux_beta.sh 2026-03-25 23:50:39 +01:00
github-actions[bot] 3c08ae9399 Update AppImage beta build (2026-03-25 22:48:45) 2026-03-25 22:48:45 +00:00
MacRimiandGitHub 7a6fa2afa5 Upgrade GitHub Actions to use latest versions 2026-03-25 23:47:00 +01:00
MacRimi ba4e3c3adb Update notification service 2026-03-25 23:45:34 +01:00
MacRimi 66892f69ce Update notification service 2026-03-25 23:30:00 +01:00
MacRimi e37469ac2b Update install_proxmenux_beta.sh 2026-03-25 23:17:29 +01:00
MacRimi cdc2d7bbcb update notification service 2026-03-25 23:13:11 +01:00
MacRimi 92c0e6ff09 Update install_proxmenux_beta.sh 2026-03-25 22:58:51 +01:00
MacRimi 9b3fd324c3 Update install_proxmenux_beta.sh 2026-03-25 22:56:55 +01:00
MacRimi 23bd692f8e Update install_proxmenux_beta.sh 2026-03-25 22:53:54 +01:00
MacRimi 29b4573ca9 Update install_proxmenux_beta.sh 2026-03-25 22:50:32 +01:00
MacRimi 8b6755d866 Update notification service 2026-03-25 22:43:42 +01:00
MacRimi 6da20aab05 update notification service 2026-03-25 22:14:38 +01:00
MacRimiandGitHub 5aa5942bcd Upgrade GitHub Actions to latest versions 2026-03-25 20:16:09 +01:00
MacRimi 68872d0e06 Update notification service 2026-03-25 20:12:08 +01:00
MacRimi d53c1dc402 Utdate notification service 2026-03-25 19:47:47 +01:00
MacRimi 6c2b03ae76 Update notification service 2026-03-25 19:21:37 +01:00
ProxMenuxBot 9f79d2b737 Update helpers_cache.json 2026-03-25 18:17:47 +00:00
MacRimi 2241b125d6 Update notification service 2026-03-25 18:28:54 +01:00
github-actions[bot] 152624302c Update AppImage beta build (2026-03-25 12:13:46) 2026-03-25 12:13:46 +00:00
ProxMenuxBot 6a703ee6a4 Update helpers_cache.json 2026-03-25 12:13:03 +00:00
MacRimi 0c0caa422d Update notification service 2026-03-25 13:10:36 +01:00
ProxMenuxBot 6fa7c1d4eb Update helpers_cache.json 2026-03-25 00:16:22 +00:00
MacRimiandGitHub 509fff3972 Refactor discussion template for AI prompts
Removed prompt name and output language fields from the template. Updated description field to include output language information.
2026-03-24 18:20:58 +01:00
MacRimi bcacd8b98e Update notification service 2026-03-24 17:48:52 +01:00
MacRimi d2c8178772 Update notification service 2026-03-24 17:34:05 +01:00
MacRimi 365a246461 Update health-status-modal.tsx 2026-03-24 17:18:38 +01:00
MacRimi 098ae13f94 Update install_proxmenux_beta.sh 2026-03-24 15:14:36 +01:00
ProxMenuxBot 6a92225630 Update helpers_cache.json 2026-03-24 12:14:00 +00:00
github-actions[bot] c98044be9a Update AppImage beta build (2026-03-24 12:02:15) 2026-03-24 12:02:15 +00:00
MacRimi e6eb81cc61 Update notification_events.py 2026-03-24 12:48:26 +01:00
github-actions[bot] 0e50caadec Update AppImage beta build (2026-03-24 11:06:32) 2026-03-24 11:06:32 +00:00
MacRimi aad44ad42f Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-24 12:04:31 +01:00
MacRimi 59fd055526 Update beta_version.txt 2026-03-24 12:04:18 +01:00
github-actions[bot] 5fce75a60d Update AppImage beta build (2026-03-24 11:01:15) 2026-03-24 11:01:15 +00:00
MacRimi 74390726b4 Update flask_server.py 2026-03-24 11:41:49 +01:00
MacRimiandGitHub 10f37b88c3 Upgrade GitHub Actions to v6 for build workflow 2026-03-24 10:58:16 +01:00
MacRimiandGitHub 9bfacd9da9 Upgrade GitHub Actions to version 6 2026-03-24 10:57:45 +01:00
MacRimiandGitHub a286770fd2 Upgrade GitHub Actions to version 6 2026-03-24 10:56:50 +01:00
github-actions[bot] 3101e84830 Update AppImage beta build (2026-03-24 09:41:18) 2026-03-24 09:41:18 +00:00
MacRimi 815b3bebda Update notification_templates.py 2026-03-24 10:38:06 +01:00
MacRimi c71eda1229 Update notification service 2026-03-24 10:27:52 +01:00
MacRimi 60518be5bd Update notification service 2026-03-23 23:05:27 +01:00
MacRimi 83254d9d70 Update notification_events.py 2026-03-23 21:04:01 +01:00
MacRimi d34cebc90d Update health_monitor.py 2026-03-23 20:25:27 +01:00
MacRimi c7ef51a73c Update notification service 2026-03-23 20:14:25 +01:00
MacRimi ab34fb08c1 Update health_monitor.py 2026-03-23 19:31:21 +01:00
ProxMenuxBot 6f99e1e8c1 Update helpers_cache.json 2026-03-23 18:14:42 +00:00
MacRimi 5fe87a04f0 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-23 18:08:35 +01:00
MacRimi 4ac71381da Update health_monitor.py 2026-03-23 18:08:22 +01:00
github-actions[bot] f95e1ad4b8 Update AppImage beta build (2026-03-23 16:56:41) 2026-03-23 16:56:41 +00:00
MacRimi 168726c131 Update notification service 2026-03-23 17:49:39 +01:00
ProxMenuxBot 4545aeb9c6 Update helpers_cache.json 2026-03-23 12:13:22 +00:00
ProxMenuxBot 84cf3e6a15 Update helpers_cache.json 2026-03-23 00:17:27 +00:00
MacRimi cd123b3479 Update notification_channels.py 2026-03-22 22:32:28 +01:00
MacRimi 4b99de8841 Update notification service 2026-03-22 22:24:17 +01:00
MacRimi 147ca0a41a Update gemini_provider.py 2026-03-22 21:35:25 +01:00
MacRimi 3f24d55945 update notification service 2026-03-22 21:14:19 +01:00
MacRimi 70871330d3 Update notification service 2026-03-22 20:58:57 +01:00
MacRimi 08bc354fa5 Update notification service 2026-03-22 20:47:51 +01:00
MacRimi 54c7322b23 Update flask_server.py 2026-03-22 20:20:59 +01:00
github-actions[bot] 4d2ceee26b Update AppImage beta build (2026-03-22 18:56:48) 2026-03-22 18:56:48 +00:00
MacRimi b6321e9698 Update notification_templates.py 2026-03-22 19:42:12 +01:00
MacRimi dd197c9826 Update auto_post_install.sh 2026-03-22 19:32:08 +01:00
MacRimi 01427f4926 Update notification service 2026-03-22 18:43:33 +01:00
MacRimi c47a7ba2a5 Update notification_events.py 2026-03-22 15:02:29 +01:00
MacRimi 04564bc9cf Update health_monitor.py 2026-03-22 14:57:46 +01:00
MacRimi 04661ce340 Update flask_server.py 2026-03-22 14:53:34 +01:00
MacRimi fcc54e2d6a Update flask_server.py 2026-03-22 14:48:30 +01:00
MacRimi 52fc3b03b7 Update flask_server.py 2026-03-22 14:39:09 +01:00
MacRimi 70c29ed7b6 Update flask_server.py 2026-03-22 14:29:46 +01:00
MacRimi d33741a90d Update notification service 2026-03-22 14:20:47 +01:00
ProxMenuxBot 484f117b8e Update helpers_cache.json 2026-03-22 12:05:28 +00:00
MacRimi 317739b508 Update beta_version.txt 2026-03-22 11:16:44 +01:00
github-actions[bot] d8062b4859 Update AppImage beta build (2026-03-22 10:15:19) 2026-03-22 10:15:19 +00:00
MacRimi 452eb70faf Update notification-settings.tsx 2026-03-22 10:22:17 +01:00
MacRimiandGitHub 83889d7e3c Add discussion template for AI notifications prompts 2026-03-22 00:28:09 +01:00
MacRimiandGitHub 2eb970a6a2 Create discussion template for custom prompts
Added a discussion template for sharing custom prompts, including fields for prompt name, AI provider, model, output language, description, prompt content, example output, additional notes, and confirmation checkboxes.
2026-03-22 00:18:20 +01:00
MacRimi 7838762a4e Update health_monitor.py 2026-03-21 23:19:41 +01:00
MacRimi f370a670ad Update flask_server.py 2026-03-21 23:03:30 +01:00
MacRimi 41fa6f4b10 Update notification service 2026-03-21 22:27:54 +01:00
MacRimi 18aa9a77dd Update notification service 2026-03-21 21:53:46 +01:00
MacRimi e53f6c0c52 Update notification-settings.tsx 2026-03-21 21:09:06 +01:00
MacRimi 642539cdfc Update notification-settings.tsx 2026-03-21 20:55:34 +01:00
MacRimi 6534fa7171 Update notification-settings.tsx 2026-03-21 20:41:02 +01:00
MacRimi ff08f4c0b5 Update notification-settings.tsx 2026-03-21 20:29:30 +01:00
MacRimi 134c62d543 Update notification-settings.tsx 2026-03-21 20:16:06 +01:00
MacRimi 1243842c68 Update notification-settings.tsx 2026-03-21 20:11:52 +01:00
MacRimi c1093be548 Update notification service 2026-03-21 19:57:28 +01:00
MacRimi b6f58758f2 Update notification-settings.tsx 2026-03-21 19:17:02 +01:00
MacRimi 1dbb59bc3f Update notification service 2026-03-21 18:53:35 +01:00
MacRimi 5e32857729 Update flask_server.py 2026-03-21 17:22:31 +01:00
MacRimiandGitHub e3a611f33d Remove star history chart from README
Removed the star history chart HTML section from README.
2026-03-21 11:08:33 +01:00
MacRimiandGitHub 8fb2a9094e Enhance README with star history chart
Added responsive star history chart with dark and light themes.
2026-03-21 11:07:56 +01:00
github-actions[bot] 46b9309336 Update AppImage beta build (2026-03-20 22:56:56) 2026-03-20 22:56:56 +00:00
MacRimi e60d2db8cb Update verified_ai_models.json 2026-03-20 23:48:07 +01:00
MacRimi e5e6c00100 Update notification service 2026-03-20 23:40:17 +01:00
MacRimi 40709b7480 Update flask_notification_routes.py 2026-03-20 23:29:25 +01:00
MacRimi 900c7154b6 Update notification service 2026-03-20 23:21:00 +01:00
MacRimi 2f4ea02544 Update notification service 2026-03-20 22:18:56 +01:00
MacRimi c24c10a13a Update notification service 2026-03-20 21:45:19 +01:00
MacRimi 22cd2e4bb3 Update notification service 2026-03-20 20:33:41 +01:00
github-actions[bot] 53ac43eb49 Update AppImage beta build (2026-03-20 19:13:03) 2026-03-20 19:13:03 +00:00
MacRimi fa29c46a95 Update notification service 2026-03-20 20:11:08 +01:00
MacRimi d871b4c78e Update notification_templates.py 2026-03-20 20:04:38 +01:00
MacRimi 03acdce2c8 Update beta_version.txt 2026-03-20 19:57:58 +01:00
github-actions[bot] 9aa3b61efc Update AppImage beta build (2026-03-20 18:56:21) 2026-03-20 18:56:21 +00:00
MacRimi 0a62e3deca Update notification_templates.py 2026-03-20 19:48:14 +01:00
MacRimi f454d5f045 Update notification_templates.py 2026-03-20 19:38:45 +01:00
MacRimi a5dca65e57 Update notification_templates.py 2026-03-20 19:20:51 +01:00
MacRimi 979a7e5d18 Update notification_templates.py 2026-03-20 19:16:36 +01:00
ProxMenuxBot d1e7154040 Update helpers_cache.json 2026-03-20 18:10:20 +00:00
MacRimi 4750ff8cd5 Update notification_templates.py 2026-03-20 19:03:04 +01:00
MacRimi 72d02010c7 Update notification service 2026-03-20 18:45:35 +01:00
MacRimi b49be42f2d Update notification_templates.py 2026-03-20 18:37:31 +01:00
MacRimi eddc183b85 Update notification_templates.py 2026-03-20 18:07:54 +01:00
MacRimi 812cf83de4 Update notification_templates.py 2026-03-20 17:46:02 +01:00
MacRimi 502cb8403f Update notification_templates.py 2026-03-20 17:09:32 +01:00
github-actions[bot] cf78bff21d Update AppImage beta build (2026-03-20 15:28:37) 2026-03-20 15:28:37 +00:00
MacRimi 1e352f4a7e Update notification service 2026-03-20 16:26:30 +01:00
MacRimi 47be85fdc0 Update notification_templates.py 2026-03-20 14:13:22 +01:00
MacRimi 5c9849e729 Update notification_templates.py 2026-03-20 13:55:47 +01:00
ProxMenuxBot e695b4e764 Update helpers_cache.json 2026-03-20 12:08:24 +00:00
github-actions[bot] d4d2e33619 Update AppImage beta build (2026-03-20 11:08:38) 2026-03-20 11:08:38 +00:00
MacRimi 1603f1ae66 Update notification service 2026-03-20 11:44:46 +01:00
MacRimi 88da476249 Update notification service 2026-03-20 11:26:26 +01:00
github-actions[bot] 1218fde6ea Update AppImage beta build (2026-03-19 20:30:24) 2026-03-19 20:30:24 +00:00
MacRimi 5046398e80 Update notification_templates.py 2026-03-19 21:28:27 +01:00
github-actions[bot] 20e5b6cc5a Update AppImage beta build (2026-03-19 19:47:33) 2026-03-19 19:47:33 +00:00
MacRimi c867c4ef51 Update ollama_provider.py 2026-03-19 20:45:35 +01:00
github-actions[bot] f2e804783b Update AppImage beta build (2026-03-19 19:22:23) 2026-03-19 19:22:23 +00:00
MacRimi 817e18ded2 Update ollama_provider.py 2026-03-19 20:20:11 +01:00
github-actions[bot] f99b498608 Update AppImage beta build (2026-03-19 19:11:56) 2026-03-19 19:11:56 +00:00
MacRimi d6cd4763f5 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 20:09:39 +01:00
MacRimi fdac846ede update ollama modal 2026-03-19 20:09:26 +01:00
github-actions[bot] d75c73df30 Update AppImage beta build (2026-03-19 19:00:26) 2026-03-19 19:00:27 +00:00
MacRimi 55c8dffe37 Update notification-settings.tsx 2026-03-19 19:58:17 +01:00
github-actions[bot] 8b35861602 Update AppImage beta build (2026-03-19 18:48:01) 2026-03-19 18:48:01 +00:00
MacRimi 6db7e64ca9 Update notification-settings.tsx 2026-03-19 19:42:42 +01:00
MacRimi 9484f78fb6 Update notification-settings.tsx 2026-03-19 19:36:39 +01:00
github-actions[bot] 1949aeb10f Update AppImage beta build (2026-03-19 18:20:31) 2026-03-19 18:20:31 +00:00
MacRimi 65fad4cc37 Create switch.tsx 2026-03-19 19:11:32 +01:00
MacRimi 876194cdc8 update storage settings 2026-03-19 19:07:26 +01:00
MacRimi cefeac72fc Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 17:56:57 +01:00
MacRimi 71708c3874 Update notification-settings.tsx 2026-03-19 17:56:51 +01:00
ProxMenuxBot b1eae7b768 Update helpers_cache.json 2026-03-19 12:09:20 +00:00
github-actions[bot] f02985e367 Update AppImage beta build (2026-03-19 09:11:42) 2026-03-19 09:11:42 +00:00
MacRimi 2848f672c1 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-19 10:08:11 +01:00
MacRimi f2210946c2 update ollama model 2026-03-19 10:08:08 +01:00
github-actions[bot] 4b79b9a417 Update AppImage beta build (2026-03-19 08:20:27) 2026-03-19 08:20:27 +00:00
MacRimi 10f8735f55 Update notification service 2026-03-19 09:18:14 +01:00
MacRimiandGitHub 19a3a14417 Delete AppImage/ProxMenux-1.0.2.AppImage 2026-03-18 23:07:40 +01:00
github-actions[bot] aeaea1289c Update AppImage beta build (2026-03-18 22:05:40) 2026-03-18 22:05:40 +00:00
MacRimi 014ffa9b74 Update release-notes-modal.tsx 2026-03-18 23:02:18 +01:00
github-actions[bot] 0a9efe0122 Update AppImage beta build (2026-03-18 21:43:41) 2026-03-18 21:43:41 +00:00
MacRimi 82082a4b89 Update v1.0.2-beta 2026-03-18 22:41:39 +01:00
MacRimiandGitHub 55bb5b5a1c Add beta program details to README
Added a beta program section with installation instructions and feedback guidelines.
2026-03-18 22:16:28 +01:00
MacRimi fd399edce7 Update README.md 2026-03-18 22:13:03 +01:00
MacRimi 5611b69ad2 Update README.md 2026-03-18 22:06:24 +01:00
MacRimi 7b181046d3 Update beta_version.txt 2026-03-18 21:56:26 +01:00
MacRimi b593d50b9a Update beta_version.txt 2026-03-18 21:55:49 +01:00
github-actions[bot] 8fe9426f5c Update AppImage beta build (2026-03-18 20:53:59) 2026-03-18 20:53:59 +00:00
MacRimi 0ce5f72df4 Update install_proxmenux_beta.sh 2026-03-18 21:47:24 +01:00
MacRimi d88c6570d0 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:24:33 +01:00
MacRimi 2980f7c9b8 Update menu 2026-03-18 21:24:18 +01:00
github-actions[bot] 7b2825e5ce Update AppImage beta build (2026-03-18 20:18:13) 2026-03-18 20:18:13 +00:00
MacRimi e47f79bcd4 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:15:49 +01:00
MacRimi 415020bf5d Beta Program Installer 2026-03-18 21:15:34 +01:00
github-actions[bot] 6ab1582db8 Update AppImage beta build (2026-03-18 20:05:10) 2026-03-18 20:05:10 +00:00
MacRimi 751a02aae8 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 21:02:46 +01:00
MacRimi bee637aa48 Beta Program Installer 2026-03-18 21:02:42 +01:00
github-actions[bot] ad6c7ffda8 Update AppImage beta build (2026-03-18 19:12:58) 2026-03-18 19:12:58 +00:00
MacRimi 7f59ca37f2 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-18 20:10:50 +01:00
MacRimi 9056964bb9 Update notification_templates.py 2026-03-18 20:10:48 +01:00
github-actions[bot] 154441bc1b Update AppImage beta build (2026-03-18 18:34:56) 2026-03-18 18:34:56 +00:00
MacRimi 873ec75586 update notification service 2026-03-18 19:32:38 +01:00
ProxMenuxBot e8232a9ea0 Update helpers_cache.json 2026-03-18 18:18:46 +00:00
MacRimi 751b361528 Update notification_templates.py 2026-03-18 19:18:27 +01:00
MacRimi 8990a3e243 Update notification_templates.py 2026-03-18 19:08:48 +01:00
MacRimi bc44490e96 Update notification_templates.py 2026-03-18 18:59:46 +01:00
MacRimi fe2d0b1d2a Update notification_templates.py 2026-03-18 18:43:05 +01:00
MacRimi bf3c5c1602 Update notification_templates.py 2026-03-18 18:37:55 +01:00
github-actions[bot] 6364931322 Update AppImage beta build (2026-03-18 16:52:23) 2026-03-18 16:52:23 +00:00
MacRimiandGitHub 536d7141d9 Add files via upload 2026-03-18 17:50:13 +01:00
MacRimi 69e0bfe89a update notification service 2026-03-18 17:48:02 +01:00
ProxMenuxBot aeabb99be6 Update helpers_cache.json 2026-03-18 12:14:02 +00:00
MacRimiandGitHub 38ee6d836d Add GitHub Actions workflow for AppImage beta build 2026-03-18 11:22:29 +01:00
MacRimiandGitHub 7bb4bd3da5 Rename workflow for building AppImage release 2026-03-18 11:21:26 +01:00
MacRimiandGitHub 7524615671 Save compiled AppImage files before git operations
Added steps to save compiled AppImage files before cleaning local changes.
2026-03-18 11:15:14 +01:00
MacRimiandGitHub f5fe883d49 Enhance AppImage workflow to include checksum upload
Updated the workflow to upload both AppImage and checksum artifacts, and modified the commit process to use the current branch instead of main.
2026-03-18 11:11:05 +01:00
MacRimiandGitHub bec6406216 Update GitHub Actions workflow for AppImage build 2026-03-18 10:48:41 +01:00
MacRimi c13c7ba626 Update notification service 2026-03-18 09:36:05 +01:00
ProxMenuxBot ef041f2702 Update helpers_cache.json 2026-03-18 00:17:00 +00:00
MacRimi 46b222180a Update notification-settings.tsx 2026-03-17 20:43:33 +01:00
MacRimi cd69b317c0 Update notification_manager.py 2026-03-17 20:41:09 +01:00
MacRimi 05fa751137 Update notification_templates.py 2026-03-17 20:34:55 +01:00
MacRimi 820317b9bd Update notification_manager.py 2026-03-17 20:25:19 +01:00
MacRimi bce01ad7a1 Update notification_templates.py 2026-03-17 20:05:23 +01:00
MacRimi bbe014798e Update notification_templates.py 2026-03-17 19:52:25 +01:00
MacRimi beea4dea04 Update notification_templates.py 2026-03-17 19:43:26 +01:00
MacRimi 71505362b4 Update notification service 2026-03-17 19:22:12 +01:00
MacRimi ff6904d436 Update notification-settings.tsx 2026-03-17 18:49:22 +01:00
MacRimi 1915bb3a9b Update notification service 2026-03-17 18:19:34 +01:00
MacRimiandGitHub df0f15419e Add files via upload 2026-03-17 18:07:18 +01:00
MacRimi 04474d2e07 Create telegram.png 2026-03-17 17:35:27 +01:00
MacRimi 518bf0f217 Update notification service 2026-03-17 15:23:44 +01:00
MacRimi ac8f06c3a2 Update notification_manager.py 2026-03-17 15:04:29 +01:00
MacRimi feaf7b8abd Update notification-settings.tsx 2026-03-17 14:56:23 +01:00
MacRimi ac71057a3d Update notification-settings.tsx 2026-03-17 14:13:39 +01:00
MacRimi 0cb8900374 Update notification service 2026-03-17 14:07:47 +01:00
ProxMenuxBot dc531eaa37 Update helpers_cache.json 2026-03-17 12:13:15 +00:00
MacRimi 9a51a9e635 Update terminal-panel.tsx 2026-03-16 23:59:49 +01:00
MacRimi 754a0988ee Update terminal-panel.tsx 2026-03-16 23:48:35 +01:00
MacRimi 1985c0f815 Update terminal-panel.tsx 2026-03-16 23:35:31 +01:00
MacRimi 889b778d43 Update terminal-panel.tsx 2026-03-16 23:22:42 +01:00
MacRimi 26e90aa39e Update terminal panel 2026-03-16 23:13:13 +01:00
ProxMenuxBot 1eaabd14bd Update helpers_cache.json 2026-03-16 18:18:06 +00:00
MacRimi f406342b53 update latency modal 2026-03-16 18:19:19 +01:00
MacRimi b7c800b550 Update oci_manager.py 2026-03-16 17:58:04 +01:00
MacRimi b6780ba876 Update oci manager 2026-03-16 17:46:34 +01:00
ProxMenuxBot c9c8987cca Update helpers_cache.json 2026-03-16 12:14:04 +00:00
MacRimi f74d336072 Update health_persistence.py 2026-03-16 09:48:58 +01:00
MacRimi 6aaaa910af Update health_monitor.py 2026-03-16 09:36:40 +01:00
MacRimi 35eee03aa5 Update setupWebSocket 2026-03-16 09:20:45 +01:00
ProxMenuxBot 06dc6ea23f Update helpers_cache.json 2026-03-16 00:17:59 +00:00
MacRimi c1f8e7f511 Update lxc-terminal-modal.tsx 2026-03-15 21:34:45 +01:00
MacRimi 6d39acc627 Update terminal modal 2026-03-15 21:24:04 +01:00
MacRimi 11f768d26c Update oci_manager.py 2026-03-15 21:06:39 +01:00
MacRimi 602afc2954 Update jwt 2026-03-15 20:43:05 +01:00
MacRimi b7203b8219 update simple_jwt 2026-03-15 20:00:10 +01:00
MacRimi 513774bb7b Update SSL WebSocket 2026-03-15 19:04:35 +01:00
MacRimi 7375e306fb Update health_persistence.py 2026-03-15 18:27:55 +01:00
MacRimi 785d58cb59 Update health monitor 2026-03-15 18:12:42 +01:00
MacRimi af61d145da Update oci manager 2026-03-15 17:59:47 +01:00
MacRimi 0b75e967f3 Update oci manager 2026-03-15 17:19:35 +01:00
MacRimi cfa4210b0a Update terminal panel 2026-03-15 16:40:28 +01:00
MacRimi 0d6d570ae8 Update terminal panel 2026-03-15 16:27:37 +01:00
MacRimi 65add36b2f Update terminal panel 2026-03-15 16:16:03 +01:00
MacRimiandGitHub 793b3dde12 Upgrade GitHub Actions to latest versions 2026-03-15 14:27:07 +01:00
MacRimiandGitHub 8b3a76dfc5 Upgrade GitHub Actions to latest versions 2026-03-15 14:14:00 +01:00
ProxMenuxBot 60398210c7 Update helpers_cache.json 2026-03-15 12:06:54 +00:00
MacRimi 9112bcc52f Update health_monitor.py 2026-03-15 10:54:37 +01:00
MacRimi e534cffcf7 Update health_monitor.py 2026-03-15 10:41:34 +01:00
MacRimi a184dcc38f Update health_monitor.py 2026-03-15 10:36:19 +01:00
MacRimi 2c80223fc4 Update health_persistence.py 2026-03-15 10:28:53 +01:00
MacRimi 59a578fb2d Update health_persistence.py 2026-03-15 10:23:38 +01:00
MacRimi 91c3f3520b Update health_persistence.py 2026-03-15 10:13:44 +01:00
MacRimi e169200f40 Update health monitor 2026-03-15 10:03:35 +01:00
ProxMenuxBot 486c7ef530 Update helpers_cache.json 2026-03-15 00:18:01 +00:00
MacRimi 26c75e8309 Update health_persistence.py 2026-03-15 00:00:24 +01:00
MacRimi bc6eb0b5a0 Update secure-gateway-setup.tsx 2026-03-14 23:35:47 +01:00
MacRimi 9a057ef646 Update secure-gateway-setup.tsx 2026-03-14 23:29:17 +01:00
MacRimi a7b06bd5fc Update secure-gateway-setup.tsx 2026-03-14 23:09:46 +01:00
MacRimi 9d706d3aa3 Update secure-gateway-setup.tsx 2026-03-14 22:56:55 +01:00
MacRimi fdc4253117 Update secure-gateway-setup.tsx 2026-03-14 22:50:07 +01:00
MacRimi ff168937aa Update vpn seervice 2026-03-14 22:38:48 +01:00
MacRimi b7d060a1f3 Update vpn service 2026-03-14 22:10:17 +01:00
MacRimi c37466e948 Update secure-gateway-setup.tsx 2026-03-14 21:45:00 +01:00
MacRimi 09513c0beb Update vpn service 2026-03-14 21:29:16 +01:00
MacRimi a3f4277bdc Update secure-gateway-setup.tsx 2026-03-14 20:53:42 +01:00
MacRimi b43d8918bd update vpn service 2026-03-14 20:43:56 +01:00
MacRimi 4546adb894 Update vpn service 2026-03-14 20:30:53 +01:00
MacRimi db5ac37ad3 Update secure-gateway-setup.tsx 2026-03-14 20:07:25 +01:00
MacRimi 098c14f9e0 Update secure-gateway-setup.tsx 2026-03-14 19:55:54 +01:00
ProxMenuxBot 94131097a5 Update helpers_cache.json 2026-03-14 18:06:03 +00:00
MacRimiandGitHub 6d69e009dc Rebuild Helper Scripts catalog for new data architecture
Rebuilt the Helper Scripts catalog to connect directly to the PocketBase API, enhancing data structure and script options. Acknowledged contributions from Community Scripts maintainers for their support in the integration.
2026-03-14 18:29:27 +01:00
MacRimiandGitHub 6d9b132ab8 Bump version from 1.1.8 to 1.1.9 2026-03-14 18:18:44 +01:00
MacRimi 461a353e92 Update menu_Helper_Scripts.sh 2026-03-14 18:18:05 +01:00
MacRimiandGitHub efec1aff18 Update script version and improve loading logic
Updated version to 1.3 and last updated date to 14/03/2025. Removed dependency on metadata.json and improved script loading and error handling.
2026-03-14 18:16:55 +01:00
MacRimiandGitHub 258d6d9a49 Update menu_Helper_Scripts.sh 2026-03-14 18:15:15 +01:00
MacRimiandGitHub 1c4b7c7b97 Update menu_Helper_Scripts.sh 2026-03-14 18:13:34 +01:00
ProxMenuxBot 8bc6306813 Update helpers_cache.json 2026-03-14 17:04:43 +00:00
MacRimiandGitHub 2923c00738 Add files via upload 2026-03-14 18:04:09 +01:00
MacRimiandGitHub b30b6a062a Delete .github/scripts/generate_helpers_cache.py 2026-03-14 18:03:51 +01:00
MacRimiandGitHub 8f5df889ab Rename helpers_cache__.json to helpers_cache.json 2026-03-14 17:50:43 +01:00
MacRimiandGitHub 4ec8b19251 Add backup JSON cache file 2026-03-14 17:50:25 +01:00
MacRimiandGitHub 1035a94775 Rename helpers_cache_back.json to helpers_cache.json 2026-03-14 17:32:12 +01:00
MacRimiandGitHub 3ca2ae7175 Add helpers_cache__.json file 2026-03-14 17:31:56 +01:00
ProxMenuxBot 4ba1ca890c Update helpers_cache.json 2026-03-14 15:44:57 +00:00
MacRimiandGitHub cba012bd15 Add backup JSON helpers cache file 2026-03-14 16:44:28 +01:00
MacRimiandGitHub 9515ccd816 Add files via upload 2026-03-14 16:43:44 +01:00
MacRimiandGitHub 46622f5028 Add generate_helpers_cache_back.py script 2026-03-14 16:40:25 +01:00
MacRimi 1fd896fb72 Update oci_manager.py 2026-03-13 20:12:35 +01:00
MacRimi d081cc6c21 Update oci_manager.py 2026-03-13 19:19:32 +01:00
MacRimiandGitHub 9190c8e5bf Update API_URL for JSON content retrieval 2026-03-13 17:15:01 +01:00
MacRimiandGitHub 109498e2df Update API_URL to point to Frontend-Archive 2026-03-13 17:08:57 +01:00
MacRimi 21cfc63fc0 Update oci_manager.py 2026-03-13 00:13:52 +01:00
MacRimi a5f14146b9 Update oci_manager.py 2026-03-12 23:47:37 +01:00
MacRimi 2c18f6d975 update oci manager 2026-03-12 23:37:21 +01:00
MacRimi 84d9146c04 update oci manager 2026-03-12 22:50:20 +01:00
MacRimi 6d4006fd93 update oci manager 2026-03-12 22:13:56 +01:00
MacRimi b4a2e5ee11 Create oci manager 2026-03-12 21:30:44 +01:00
MacRimi 304b814bb1 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-03-12 19:40:31 +01:00
MacRimi c5e4774b29 Update notification service 2026-03-12 19:40:13 +01:00
MacRimiandGitHub e90651b55b Update Node.js version and add environment variable 2026-03-12 19:37:25 +01:00
MacRimiandGitHub 60d7c395bc Update Node.js version and add environment variable 2026-03-12 19:35:23 +01:00
MacRimi 2b5c9c2d61 Update health_persistence.py 2026-03-12 19:22:06 +01:00
MacRimi a703f1db73 Update health_persistence.py 2026-03-12 18:55:15 +01:00
MacRimi 4aaba7619e Update notification service 2026-03-12 18:12:04 +01:00
MacRimi 1a88dd801d Update proxmox-dashboard.tsx 2026-03-10 19:31:07 +01:00
MacRimi 83352ab9fe Update proxmox-dashboard.tsx 2026-03-10 19:25:03 +01:00
MacRimi 6e268a1bf4 Update proxmox-dashboard.tsx 2026-03-10 19:15:31 +01:00
MacRimi 4d65e54576 Update proxmox-dashboard.tsx 2026-03-10 19:06:28 +01:00
MacRimi c131ec722e Update proxmox-dashboard.tsx 2026-03-10 18:54:03 +01:00
MacRimi 574e12f336 Update proxmox-dashboard.tsx 2026-03-10 18:37:37 +01:00
MacRimi 1705868457 Update proxmox-dashboard.tsx 2026-03-10 18:22:59 +01:00
MacRimi 8392d111dc Update health-status-modal.tsx 2026-03-10 18:06:40 +01:00
MacRimi 8c5ccbadac Update notification service 2026-03-10 18:00:56 +01:00
MacRimi e4aa081e64 Update health-status-modal.tsx 2026-03-10 17:32:18 +01:00
MacRimi 8cc74eceb6 Update notification service 2026-03-10 17:22:27 +01:00
MacRimi 45365e3860 Update storage-overview.tsx 2026-03-08 23:22:18 +01:00
MacRimi 3739560956 Update notification service 2026-03-08 22:47:04 +01:00
MacRimi b8cff3e699 Update notification service 2026-03-08 20:01:02 +01:00
MacRimi d1d44afc9d Update notification service 2026-03-08 19:37:04 +01:00
ProxMenuxBot 782d847e54 Update helpers_cache.json 2026-03-08 18:04:29 +00:00
MacRimi be2bfa0087 Update health_monitor.py 2026-03-08 18:21:34 +01:00
MacRimi 1ea28d66df Update notification service 2026-03-08 18:15:36 +01:00
MacRimi 8c51957bfa Update notification service 2026-03-08 16:33:52 +01:00
MacRimi 858a1bba4f Update health_monitor.py 2026-03-08 10:23:53 +01:00
MacRimi 17e4227978 Update notification service 2026-03-08 10:15:55 +01:00
MacRimi f8b5e07518 Update notification service 2026-03-08 10:00:17 +01:00
ProxMenuxBot d96e4019aa Update helpers_cache.json 2026-03-08 00:15:29 +00:00
MacRimi acc9760690 Update health_monitor.py 2026-03-08 00:58:37 +01:00
MacRimi 56dab535c3 Update notification service 2026-03-08 00:53:35 +01:00
MacRimi 94670711e7 Update flask_server.py 2026-03-08 00:30:05 +01:00
MacRimi 673c206e02 Update latency-detail-modal.tsx 2026-03-07 23:47:24 +01:00
MacRimi decd3bd134 Update latency-detail-modal.tsx 2026-03-07 23:40:19 +01:00
MacRimi 6e5c7aeab5 Update notification service 2026-03-07 23:33:13 +01:00
MacRimi 2647550324 Update notification service 2026-03-07 23:18:25 +01:00
MacRimi 424a63011b Update notification service 2026-03-07 23:05:51 +01:00
MacRimi 0e6a125c60 update notification service 2026-03-07 22:55:42 +01:00
MacRimi 758cae4f86 Update latency-detail-modal.tsx 2026-03-07 22:36:20 +01:00
MacRimi 6e8368c62a Update latency-detail-modal.tsx 2026-03-07 22:14:47 +01:00
MacRimi a14e554323 Update latency-detail-modal.tsx 2026-03-07 21:58:57 +01:00
MacRimi 6435202fa1 Update latency-detail-modal.tsx 2026-03-07 21:37:24 +01:00
MacRimi cf8425ff14 update notification service 2026-03-07 21:17:13 +01:00
MacRimi 9bb1c1b233 Update notification service 2026-03-07 20:38:18 +01:00
MacRimi c9ccc5e27e update notification service 2026-03-07 20:17:40 +01:00
MacRimi 7115b2ff54 Update notification service 2026-03-07 20:06:59 +01:00
MacRimi b89e234ba4 Update latency-detail-modal.tsx 2026-03-07 19:45:03 +01:00
MacRimi 70fbaa0bfd Update latency-detail-modal.tsx 2026-03-07 19:27:31 +01:00
MacRimi f6cdd4ff36 Update latency-detail-modal.tsx 2026-03-07 19:02:14 +01:00
MacRimi 1857f46452 Update notification service 2026-03-07 18:44:51 +01:00
MacRimi f95a6f4fd7 Update latency-detail-modal.tsx 2026-03-07 17:57:57 +01:00
MacRimi b0bc66f548 Update latency-detail-modal.tsx 2026-03-07 17:21:44 +01:00
MacRimi 34b4a6c3d8 Update latency-detail-modal.tsx 2026-03-07 17:06:47 +01:00
MacRimi b4c7463226 Update latency-detail-modal.tsx 2026-03-07 16:59:13 +01:00
MacRimi ca5b33ef69 Update latency-detail-modal.tsx 2026-03-07 16:42:01 +01:00
MacRimi acd980091d Update latency-detail-modal.tsx 2026-03-07 16:23:11 +01:00
MacRimi de5317987e Update latency-detail-modal.tsx 2026-03-07 12:02:20 +01:00
ProxMenuxBot 6b438bc4aa Update helpers_cache.json 2026-03-07 00:14:59 +00:00
MacRimi 9b1495a490 Update notification service 2026-03-06 21:25:14 +01:00
MacRimi f638011d63 Update latency-detail-modal.tsx 2026-03-06 20:54:40 +01:00
MacRimi 8447a95c8a Update latency-detail-modal.tsx 2026-03-06 20:34:59 +01:00
MacRimi 4feceaa1d1 Update latency-detail-modal.tsx 2026-03-06 20:13:31 +01:00
MacRimi 8383e381d1 Update latency-detail-modal.tsx 2026-03-06 20:02:49 +01:00
MacRimi a064a7471e Update notification service 2026-03-06 19:32:10 +01:00
MacRimi f0e3d7d09a Update notification_events.py 2026-03-06 19:05:08 +01:00
MacRimi 2b7f4ccd6c Update notification_manager.py 2026-03-06 18:57:43 +01:00
MacRimi 46fa89233b Update notification service 2026-03-06 18:44:27 +01:00
MacRimi 591099e42b Update storage-overview.tsx 2026-03-06 15:24:18 +01:00
MacRimi d08398ea57 Update flask_server.py 2026-03-06 14:43:18 +01:00
MacRimi 83f49742b6 Update notification service 2026-03-06 14:32:23 +01:00
ProxMenuxBot 50d07f81fd Update helpers_cache.json 2026-03-06 12:08:42 +00:00
MacRimi 594ee21fcd Update notification_events.py 2026-03-06 12:16:06 +01:00
MacRimi ea2763c48c Update notification service 2026-03-06 12:06:53 +01:00
MacRimi 925fe1cce0 Update notification service 2026-03-05 21:28:48 +01:00
MacRimi d927b462b6 Update notification service 2026-03-05 20:44:51 +01:00
MacRimi 5a79556ab2 Update flask_server.py 2026-03-05 19:59:17 +01:00
MacRimi 260870ad8a Update notification service 2026-03-05 19:49:35 +01:00
ProxMenuxBot 7d69e64adc Update helpers_cache.json 2026-03-05 18:34:30 +00:00
MacRimi 5af51096d8 Update notification service 2026-03-05 19:25:05 +01:00
MacRimi 898392725a Update notification service 2026-03-05 17:29:07 +01:00
MacRimi 9089035f18 Update notification service 2026-03-04 19:11:38 +01:00
MacRimi 66d2a68167 Update notification service 2026-03-03 21:12:19 +01:00
MacRimi 4a41e40592 Update notification_channels.py 2026-03-03 20:59:43 +01:00
MacRimi 2a75b920a0 Update notification_channels.py 2026-03-03 20:49:36 +01:00
MacRimi 2851eae423 Update notification_channels.py 2026-03-03 20:21:43 +01:00
MacRimi efc2295b8d Update notification service 2026-03-03 20:09:38 +01:00
MacRimi 2bee28a1d8 Update notification service 2026-03-03 19:45:54 +01:00
MacRimi a8cc995558 Update notification service 2026-03-03 19:36:33 +01:00
MacRimi 9a11c41424 Update notification service 2026-03-03 19:19:56 +01:00
MacRimi 2a4d056b59 Update notification service 2026-03-03 18:48:54 +01:00
MacRimi 5a77a398bd Update notification-settings.tsx 2026-03-03 17:50:56 +01:00
MacRimi 4cf2238c99 Update notification-settings.tsx 2026-03-03 17:23:38 +01:00
MacRimi 58df4f1481 update notification service 2026-03-03 16:42:12 +01:00
MacRimi da3f99a254 Update notification service 2026-03-03 13:40:46 +01:00
ProxMenuxBot c2fa6095cc Update helpers_cache.json 2026-03-03 12:08:48 +00:00
MacRimi f0b8ed20a2 update notification service 2026-03-02 23:21:40 +01:00
ProxMenuxBot 0b8b72be5c Update helpers_cache.json 2026-03-02 18:12:26 +00:00
MacRimi 18c6455837 Update notification service 2026-03-02 18:55:02 +01:00
MacRimi e0477015c4 Update notification-settings.tsx 2026-03-02 18:42:58 +01:00
MacRimi e99a4e2b08 Update notification-settings.tsx 2026-03-02 18:36:50 +01:00
MacRimi c44d06b0dc Update notification-settings.tsx 2026-03-02 18:26:23 +01:00
MacRimi 0e8327c085 Update notification-settings.tsx 2026-03-02 18:12:47 +01:00
MacRimi 5c5a86c7fc Update notification-settings.tsx 2026-03-02 18:06:27 +01:00
MacRimi a785213cb2 Update notification service 2026-03-02 18:01:34 +01:00
MacRimi e041440c97 Update notification_events.py 2026-03-02 17:27:45 +01:00
MacRimi 688ca8a604 Update notification service 2026-03-02 17:16:22 +01:00
ProxMenuxBot fd6f0967b0 Update helpers_cache.json 2026-03-02 12:08:55 +00:00
MacRimi 9fe58935c4 Update notification service 2026-03-01 22:56:25 +01:00
MacRimi 0dfb35730f Update notification service 2026-03-01 22:29:58 +01:00
MacRimi dc52f4c692 Update notification service 2026-03-01 18:44:11 +01:00
MacRimi bcf5395868 update notification service 2026-03-01 17:24:13 +01:00
MacRimi 3e96a89adf update notification service 2026-02-28 20:32:58 +01:00
MacRimi c0a882251d Update notification service 2026-02-28 20:22:24 +01:00
MacRimi 6a53b895e5 Update health-status-modal.tsx 2026-02-28 19:40:14 +01:00
MacRimi c5354d014c Update notification service 2026-02-28 19:18:13 +01:00
MacRimi 5e9ef37646 Update notification service 2026-02-28 18:07:00 +01:00
MacRimi cb96bea73d Update health_monitor.py 2026-02-28 17:31:03 +01:00
MacRimi 95fa2440ce Update notification service 2026-02-28 17:18:03 +01:00
ProxMenuxBot ca9698f75d Update helpers_cache.json 2026-02-28 12:05:10 +00:00
MacRimi 0f1413f130 Update notification service 2026-02-28 00:01:01 +01:00
MacRimi 52a4b604dd Update notification service 2026-02-27 23:49:26 +01:00
MacRimi 3c64ee7af2 Update notification service 2026-02-27 23:45:18 +01:00
MacRimi 026719cd88 Update health_monitor.py 2026-02-27 23:28:27 +01:00
MacRimi 9bac00ee29 Update health-status-modal.tsx 2026-02-27 23:11:57 +01:00
MacRimi 828c0f66a6 Update health_monitor.py 2026-02-27 20:07:01 +01:00
MacRimi 9841e92634 Update health_monitor.py 2026-02-27 19:55:02 +01:00
MacRimi 171e7ddcae Update notification service 2026-02-27 19:47:36 +01:00
ProxMenuxBot 968a5bd789 Update helpers_cache.json 2026-02-27 18:10:15 +00:00
MacRimi be119a69af Update health_monitor.py 2026-02-27 18:42:44 +01:00
MacRimi 800c3c11be Update health_monitor.py 2026-02-27 18:36:51 +01:00
MacRimi 1242da5ed1 Update notification service 2026-02-27 18:27:24 +01:00
MacRimi 8bf4fa0cf1 Update notification service 2026-02-26 21:28:14 +01:00
MacRimi 0693acc07b Update notification service 2026-02-26 20:58:40 +01:00
MacRimi 17eecfca9d Update notification service 2026-02-26 20:31:44 +01:00
MacRimi 4d24d6d17b Update notification service 2026-02-26 18:21:01 +01:00
ProxMenuxBot 1fe4ee5b81 Update helpers_cache.json 2026-02-26 12:12:55 +00:00
ProxMenuxBot 137aeac91a Update helpers_cache.json 2026-02-25 18:21:39 +00:00
ProxMenuxBot ccb0b58a2d Update helpers_cache.json 2026-02-25 12:11:18 +00:00
MacRimi ffc202f6a3 Update notification_events.py 2026-02-24 20:11:29 +01:00
MacRimi f7fd728683 Update notification service 2026-02-24 19:52:27 +01:00
MacRimi 46c04e5a81 Update notification service 2026-02-24 19:27:43 +01:00
MacRimi f43feb825f Update notification service 2026-02-24 18:20:43 +01:00
MacRimi 05cd21d44e Update notification service 2026-02-24 18:10:12 +01:00
MacRimi 4182af75ff Update notification service 2026-02-24 17:55:03 +01:00
ProxMenuxBot 680123eb64 Update helpers_cache.json 2026-02-24 12:11:48 +00:00
ProxMenuxBot aec04f0b8c Update helpers_cache.json 2026-02-24 00:15:52 +00:00
ProxMenuxBot e75bbc0a22 Update helpers_cache.json 2026-02-23 18:20:58 +00:00
ProxMenuxBot 81fc625c5d Update helpers_cache.json 2026-02-23 12:10:39 +00:00
ProxMenuxBot f85683239f Update helpers_cache.json 2026-02-22 12:05:24 +00:00
MacRimi 507f769357 Update notification service 2026-02-21 22:36:58 +01:00
MacRimi e3f7e8c97a Update notification service 2026-02-21 22:19:45 +01:00
MacRimi 49e9e26bff Update flask_notification_routes.py 2026-02-21 21:55:37 +01:00
MacRimi fccd4c12ca Update notification service 2026-02-21 21:36:27 +01:00
MacRimi 06c9ff481e Update flask_notification_routes.py 2026-02-21 21:11:57 +01:00
MacRimi 50e5775062 Update flask_notification_routes.py 2026-02-21 20:55:18 +01:00
MacRimi 91da8db589 Update flask_notification_routes.py 2026-02-21 20:49:59 +01:00
MacRimi 0d854ae42b Update notification service 2026-02-21 20:33:22 +01:00
MacRimi ec21050fad Update notification service 2026-02-21 19:56:50 +01:00
MacRimi 67c61a5829 Update notification service 2026-02-21 18:47:15 +01:00
MacRimi e685668959 update notificacion service 2026-02-21 18:13:38 +01:00
MacRimi de13eb5b96 Update notification service 2026-02-21 17:23:03 +01:00
ProxMenuxBot c0f54c334e Update helpers_cache.json 2026-02-21 00:16:10 +00:00
MacRimi f134fcb528 Update notification service 2026-02-20 17:55:05 +01:00
MacRimi d5954a3a32 Update notification service 2026-02-19 20:51:54 +01:00
MacRimi bd28e312fc Update notification-settings.tsx 2026-02-19 20:30:11 +01:00
MacRimi 7208d5b2bf Update notification service 2026-02-19 19:56:20 +01:00
ProxMenuxBot 5c2d4e4718 Update helpers_cache.json 2026-02-19 18:16:51 +00:00
MacRimi 8cdeae6c3f Update notification service 2026-02-19 18:37:42 +01:00
MacRimi e7bc6d09f2 Update flask_notification_routes.py 2026-02-19 17:46:50 +01:00
MacRimi 4ce2699a48 Update notication service 2026-02-19 17:26:36 +01:00
MacRimi 7c5cdb9161 Update notification service 2026-02-19 17:02:02 +01:00
ProxMenuxBot 64a0aa6157 Update helpers_cache.json 2026-02-19 12:11:34 +00:00
MacRimi 34d04e57dd Update notification service 2026-02-18 17:24:26 +01:00
MacRimi 1317c5bddc Update health monitor 2026-02-17 20:01:45 +01:00
MacRimi 74b6f565e9 Update health monitor 2026-02-17 17:57:49 +01:00
MacRimi 08f49d4d0b Update health-status-modal.tsx 2026-02-17 17:24:47 +01:00
MacRimi 99605b6a55 Update health monitor 2026-02-17 17:09:00 +01:00
MacRimi beeeabc377 Update health monitor 2026-02-17 16:49:26 +01:00
ProxMenuxBot ff2e40d49a Update helpers_cache.json 2026-02-17 12:10:47 +00:00
MacRimi 31c5eeb6c3 Update health monitor 2026-02-17 11:35:11 +01:00
MacRimi 8004ee48c9 Update health monitor 2026-02-16 22:53:16 +01:00
MacRimi a1d48a28e9 Update health monitor 2026-02-16 22:26:43 +01:00
MacRimi 0f81f45c5f update health monitor 2026-02-16 22:07:10 +01:00
MacRimi 05f7957557 Update health_monitor.py 2026-02-16 18:39:36 +01:00
MacRimi 1ed8f5d124 Update health monitor 2026-02-16 18:19:29 +01:00
MacRimi 2ee5be7402 Update health monitor 2026-02-16 15:48:41 +01:00
ProxMenuxBot 1226e7bee1 Update helpers_cache.json 2026-02-16 12:10:34 +00:00
MacRimi dcbc52efc6 Update spinner 2026-02-16 12:11:37 +01:00
MacRimi 92b0a1478a Update log 2026-02-16 11:43:12 +01:00
MacRimi f27c7fdf31 Update system-logs.tsx 2026-02-16 10:55:26 +01:00
MacRimi 18a427b501 Update logs 2026-02-16 09:52:33 +01:00
MacRimi b7951b730d Update spinner 2026-02-16 09:48:03 +01:00
ProxMenuxBot 342203bb81 Update helpers_cache.json 2026-02-16 00:16:44 +00:00
ProxMenuxBot e4bc526a09 Update helpers_cache.json 2026-02-15 18:05:49 +00:00
MacRimi f75e30afd0 Update security.tsx 2026-02-14 18:34:36 +01:00
MacRimi 9f11238d43 Update security.tsx 2026-02-14 18:21:53 +01:00
MacRimi 070a1b47e5 Update security.tsx 2026-02-14 17:59:44 +01:00
MacRimi 3e8661f5ca Update temperature-detail-modal.tsx 2026-02-14 17:40:09 +01:00
MacRimi 9f8c27ddc1 Update modal 2026-02-14 17:28:35 +01:00
MacRimi bafaaf9c47 Update security.tsx 2026-02-14 17:01:14 +01:00
MacRimi 1ee5863da7 Update firewall 2026-02-14 16:49:08 +01:00
MacRimi ace4d83789 Update security.tsx 2026-02-14 16:39:24 +01:00
MacRimi 1da1c178d0 Update security.tsx 2026-02-14 16:08:11 +01:00
MacRimi c429cb2ed1 Update flask_server.py 2026-02-14 14:32:30 +01:00
MacRimi 40c40f81fc Update flask_auth_routes.py 2026-02-14 12:23:08 +01:00
MacRimi 6647a3b083 Update security.tsx 2026-02-14 12:07:51 +01:00
MacRimi 782eaef440 Update flask_server.py 2026-02-14 11:12:54 +01:00
MacRimi 6003310a39 Update system-overview.tsx 2026-02-13 20:34:09 +01:00
MacRimi 229ac5006b Update temperature-detail-modal.tsx 2026-02-13 20:23:56 +01:00
MacRimi 322687c658 Update modal temperature 2026-02-13 20:09:50 +01:00
MacRimi fe3963dfe2 Update temperature-detail-modal.tsx 2026-02-13 19:58:57 +01:00
MacRimi e4a57b97b7 Update modal temperature 2026-02-13 19:40:22 +01:00
MacRimi 9b48c498f5 new modal temperature 2026-02-13 19:07:24 +01:00
MacRimi 4228177920 Update auto_post_install.sh 2026-02-13 18:21:28 +01:00
MacRimi e98637321d Update auto_post_install.sh 2026-02-13 18:17:32 +01:00
ProxMenuxBot 5941bd4b68 Update helpers_cache.json 2026-02-13 12:08:41 +00:00
MacRimi a686360c1f Update memory speed 2026-02-13 12:30:27 +01:00
MacRimi 20ee9da1ec Update flask_auth_routes.py 2026-02-13 11:02:53 +01:00
MacRimi c89baf34a8 Update 2FA 2026-02-13 10:51:27 +01:00
MacRimi 00230d1b8f Update security_manager.py 2026-02-12 20:13:53 +01:00
MacRimi 4396d57e3d Update security_manager.py 2026-02-12 19:43:52 +01:00
MacRimi 86789f677a Update security_manager.py 2026-02-12 19:23:55 +01:00
MacRimi 8fb2deeab0 Update security_manager.py 2026-02-12 19:15:08 +01:00
MacRimi 2099bbe58f Update security_manager.py 2026-02-12 18:58:39 +01:00
ProxMenuxBot 1c95319608 Update helpers_cache.json 2026-02-11 12:14:33 +00:00
ProxMenuxBot eeea948844 Update helpers_cache.json 2026-02-11 00:20:59 +00:00
MacRimi c4b1820d08 Update security_manager.py 2026-02-10 19:32:49 +01:00
MacRimi 59cc2741b8 Update security_manager.py 2026-02-10 19:04:04 +01:00
MacRimi cc34d33090 Update security 2026-02-10 18:28:43 +01:00
ProxMenuxBot 59bb0070e9 Update helpers_cache.json 2026-02-10 12:15:22 +00:00
ProxMenuxBot ec2206ade0 Update helpers_cache.json 2026-02-09 18:16:40 +00:00
MacRimi 06a3e6b472 Update root access 2026-02-09 18:20:09 +01:00
MacRimi 9108882921 Update security.tsx 2026-02-09 17:18:42 +01:00
ProxMenuxBot 7796f7d3bc Update helpers_cache.json 2026-02-09 12:14:11 +00:00
MacRimi 00a0ae6561 Update security.tsx 2026-02-09 10:55:42 +01:00
MacRimi 6310293190 Update security.tsx 2026-02-08 20:54:04 +01:00
MacRimi 809930df9a Update security.tsx 2026-02-08 20:38:27 +01:00
MacRimi f1874d4ab1 Update security.tsx 2026-02-08 20:25:20 +01:00
MacRimi cc0f401855 Update Lynis 2026-02-08 20:00:23 +01:00
MacRimi 42626f3bce Update security_manager.py 2026-02-08 19:37:44 +01:00
MacRimi 22d570b024 Update lynis 2026-02-08 19:24:40 +01:00
MacRimi 6d0a07f212 Update security_manager.py 2026-02-08 18:52:18 +01:00
MacRimi a512b5a110 Update security 2026-02-08 18:30:18 +01:00
MacRimi bde3dade14 Update lynis 2026-02-08 17:51:20 +01:00
MacRimi de2058d966 Update fail2ban 2026-02-08 17:23:53 +01:00
MacRimi 7f191764be Update security 2026-02-08 17:01:49 +01:00
MacRimi 7f9da757aa update firewall 2026-02-08 16:19:48 +01:00
MacRimi f07e8cfe14 Update security 2026-02-08 13:19:24 +01:00
ProxMenuxBot b806bf80b1 Update helpers_cache.json 2026-02-08 12:05:47 +00:00
ProxMenuxBot 173ea58701 Update helpers_cache.json 2026-02-08 00:20:03 +00:00
MacRimi 3ad5b72ebf Update flask_server.py 2026-02-07 19:02:08 +01:00
MacRimi 567e2e5d6d Update flask_server.py 2026-02-07 18:53:21 +01:00
MacRimi 616bd0ac91 Backend SSL config manager and API endpoints 2026-02-07 18:36:14 +01:00
MacRimi 108a169e7c Update 2FA 2026-02-07 18:03:46 +01:00
MacRimi eab902d68e Update Log 2026-02-07 17:37:55 +01:00
MacRimi 985f6e89ec Update modal LXC 2026-02-07 11:45:28 +01:00
MacRimi 0480989fd2 Update virtual-machines.tsx 2026-02-07 11:25:33 +01:00
MacRimi 72ffe420b7 Update virtual-machines.tsx 2026-02-07 10:55:06 +01:00
ProxMenuxBot 775b6ff4fd Update helpers_cache.json 2026-02-07 00:15:38 +00:00
ProxMenuxBot b0f18461b3 Update helpers_cache.json 2026-02-06 18:13:29 +00:00
ProxMenuxBot b8ccbfd222 Update helpers_cache.json 2026-02-06 12:09:32 +00:00
ProxMenuxBot c2fa497137 Update helpers_cache.json 2026-02-05 18:16:47 +00:00
ProxMenuxBot bdcfa6929c Update helpers_cache.json 2026-02-05 12:09:37 +00:00
ProxMenuxBot 8470b58b60 Update helpers_cache.json 2026-02-04 18:14:29 +00:00
ProxMenuxBot 002413c067 Update helpers_cache.json 2026-02-04 12:08:26 +00:00
ProxMenuxBot ecce59e734 Update helpers_cache.json 2026-02-04 00:14:14 +00:00
MacRimi f5d169eaa2 Update flask_server.py 2026-02-03 23:17:44 +01:00
MacRimi b3b921e1ae Update flask_server.py 2026-02-03 23:02:19 +01:00
MacRimi 91f15b723e Update modal lxc 2026-02-03 22:53:37 +01:00
MacRimi 303dcb1eb6 Update flask_server.py 2026-02-03 22:30:55 +01:00
MacRimi 4eaeb1b020 Create textarea.tsx 2026-02-03 22:18:00 +01:00
MacRimi f13427ca27 Update modal lxc 2026-02-03 22:10:53 +01:00
MacRimi 458f2cdf16 Update virtual-machines.tsx 2026-02-03 18:29:00 +01:00
MacRimi df588f25bf Update virtual-machines.tsx 2026-02-03 18:14:40 +01:00
MacRimi bd0fdff29c Update virtual-machines.tsx 2026-02-03 18:12:37 +01:00
MacRimi 774da61da1 Update virtual-machines.tsx 2026-02-03 18:07:55 +01:00
MacRimi 42e67e01aa Update virtual-machines.tsx 2026-02-03 17:41:34 +01:00
MacRimi 36e201e824 Update virtual-machines.tsx 2026-02-03 17:33:18 +01:00
MacRimi 497233c9f1 Update virtual-machines.tsx 2026-02-03 17:10:19 +01:00
MacRimi 4b7c9a1bd3 Update virtual-machines.tsx 2026-02-03 17:00:43 +01:00
MacRimi 7c2d6d6618 Update virtual-machines.tsx 2026-02-03 16:57:50 +01:00
MacRimi c44e0afb81 Update virtual-machines.tsx 2026-02-03 16:50:29 +01:00
MacRimi d3ef3c7452 Update virtual-machines.tsx 2026-02-03 16:45:01 +01:00
ProxMenuxBot 1935c76f30 Update helpers_cache.json 2026-02-02 18:11:22 +00:00
MacRimi 71056d8f15 Update virtual-machines.tsx 2026-02-02 19:01:07 +01:00
MacRimi 8d34119e7a Update modal lxc 2026-02-02 18:49:18 +01:00
MacRimi f159ee77cd Update modal vm 2026-02-02 17:46:23 +01:00
MacRimi d336c4f5b7 Update modal vm 2026-02-02 17:29:14 +01:00
ProxMenuxBot 81b7a3e665 Update helpers_cache.json 2026-02-02 12:08:58 +00:00
MacRimi 1870f74f0c Update terminal-panel.tsx 2026-02-01 01:32:20 +01:00
MacRimi d19f9c6888 Create dropdown-menu.tsx 2026-02-01 01:18:20 +01:00
ProxMenuxBot a68bf6fc8f Update helpers_cache.json 2026-02-01 00:16:53 +00:00
MacRimi c2d2745777 Update terminal modal 2026-02-01 01:13:13 +01:00
MacRimi fc8bf841bf Update lxc-terminal-modal.tsx 2026-02-01 00:50:53 +01:00
MacRimi 08f435597a Update lxc-terminal-modal.tsx 2026-02-01 00:42:51 +01:00
MacRimi 58a4e475ad Update lxc-terminal-modal.tsx 2026-01-31 18:29:19 +01:00
MacRimi 5bfc911e1b Update lxc-terminal-modal.tsx 2026-01-31 18:17:50 +01:00
MacRimi d2c7362736 Update terminal modal 2026-01-31 18:10:55 +01:00
MacRimi 82cac690fa Update terminal panel 2026-01-31 17:40:08 +01:00
MacRimi 3c3c902087 Update terminal modal 2026-01-31 17:19:50 +01:00
MacRimi 964538eb43 Update lxc-terminal-modal.tsx 2026-01-31 17:02:11 +01:00
MacRimi 5e8b2bdb50 Update terminal panel 2026-01-31 16:48:22 +01:00
MacRimi e3d10495f3 Update terminal modal 2026-01-31 16:17:36 +01:00
MacRimi 6c3886ad24 Update virtual-machines.tsx 2026-01-31 15:57:59 +01:00
MacRimi ba727f53c4 Update flask_server.py 2026-01-31 15:53:56 +01:00
MacRimi 35a4737e43 Update node-metrics-charts.tsx 2026-01-31 15:34:11 +01:00
MacRimi abde8652b2 Update system-overview.tsx 2026-01-31 15:25:15 +01:00
MacRimi cadef0bf81 Update terminal-panel.tsx 2026-01-31 14:34:26 +01:00
MacRimi caac696244 Update terminal panel 2026-01-31 12:25:23 +01:00
MacRimi 6910a0b4bd Update intel_gpu_tools.sh 2026-01-31 11:52:34 +01:00
MacRimi 74e2584e4d Update flask_server.py 2026-01-31 11:44:32 +01:00
ProxMenuxBot 459dd2d9c7 Update helpers_cache.json 2026-01-31 00:14:20 +00:00
MacRimi 0f5c83c1c2 Update intel_gpu_tools.sh 2026-01-29 21:26:11 +01:00
ProxMenuxBot fed4cc2a97 Update helpers_cache.json 2026-01-29 19:56:29 +00:00
MacRimi c238711b3e Update hardware.tsx 2026-01-29 20:03:39 +01:00
MacRimi f42334917e Create intel_gpu_tools.sh 2026-01-29 19:59:12 +01:00
MacRimi 09004d4c09 Update share-common.func 2026-01-29 19:54:54 +01:00
MacRimi 68da9b2f69 Create amd_gpu_tools.sh 2026-01-29 19:41:48 +01:00
MacRimi 454ff37a72 Update gpu monitor 2026-01-29 19:04:52 +01:00
MacRimi ca13d18d7d Update backend monitor 2026-01-29 18:27:36 +01:00
MacRimi 1657a7dbe3 Update hardware_monitor.py 2026-01-29 18:21:23 +01:00
MacRimi 61e925eaab Update hardware_monitor.py 2026-01-29 18:07:49 +01:00
MacRimi 09d3313e15 Update flask_server.py 2026-01-29 17:59:04 +01:00
MacRimi a20d61037e Update backend monitor 2026-01-29 17:47:10 +01:00
ProxMenuxBot 7eaa692712 Update helpers_cache.json 2026-01-28 18:06:50 +00:00
ProxMenuxBot 691bae9a96 Update helpers_cache.json 2026-01-28 12:05:34 +00:00
ProxMenuxBot d5a8c9b7d1 Update helpers_cache.json 2026-01-26 18:05:37 +00:00
ProxMenuxBot 8c20e7c661 Update helpers_cache.json 2026-01-25 00:14:01 +00:00
ProxMenuxBot 47a2d28c6a Update helpers_cache.json 2026-01-24 00:12:27 +00:00
ProxMenuxBot 31f8961e27 Update helpers_cache.json 2026-01-23 18:04:28 +00:00
ProxMenuxBot 424bd0bc28 Update helpers_cache.json 2026-01-23 12:05:47 +00:00
ProxMenuxBot 9c078583dd Update helpers_cache.json 2026-01-22 12:05:55 +00:00
ProxMenuxBot ca27048679 Update helpers_cache.json 2026-01-22 00:13:22 +00:00
MacRimi 4e65663748 Update coral lxc 2026-01-20 20:17:53 +01:00
ProxMenuxBot c7c5cbde83 Update helpers_cache.json 2026-01-20 00:12:48 +00:00
ProxMenuxBot a4905ad207 Update helpers_cache.json 2026-01-19 18:04:06 +00:00
MacRimi bebf0e692a Update License 2026-01-19 17:15:00 +01:00
ProxMenuxBot 8ff9a87dfe Update helpers_cache.json 2026-01-19 12:06:36 +00:00
ProxMenuxBot 62f2d8ac16 Update helpers_cache.json 2026-01-19 00:12:53 +00:00
ProxMenuxBot 8fef2a6232 Update helpers_cache.json 2026-01-18 18:04:16 +00:00
ProxMenuxBot 94064fe78c Update helpers_cache.json 2026-01-17 12:05:12 +00:00
ProxMenuxBot 2ffcc43adc Update helpers_cache.json 2026-01-16 18:05:23 +00:00
ProxMenuxBot 3846fce73a Update helpers_cache.json 2026-01-16 12:05:44 +00:00
ProxMenuxBot ea950e9dbc Update helpers_cache.json 2026-01-15 18:07:27 +00:00
ProxMenuxBot f2639c4ff1 Update helpers_cache.json 2026-01-14 18:05:47 +00:00
ProxMenuxBot 32c1798eb8 Update helpers_cache.json 2026-01-13 12:05:29 +00:00
ProxMenuxBot 75e3167b65 Update helpers_cache.json 2026-01-12 18:04:41 +00:00
ProxMenuxBot ad07a61aa7 Update helpers_cache.json 2026-01-08 18:04:25 +00:00
ProxMenuxBot c91b6329f3 Update helpers_cache.json 2026-01-07 00:11:18 +00:00
ProxMenuxBot 9cc60efd5a Update helpers_cache.json 2026-01-03 12:04:28 +00:00
ProxMenuxBot 08eeea6b9c Update helpers_cache.json 2026-01-02 12:05:29 +00:00
ProxMenuxBot 8dea7335de Update helpers_cache.json 2025-12-30 18:04:18 +00:00
ProxMenuxBot 2ad6d43422 Update helpers_cache.json 2025-12-29 00:12:44 +00:00
ProxMenuxBot 12c2e7aefb Update helpers_cache.json 2025-12-28 12:04:17 +00:00
ProxMenuxBot 6b62e46950 Update helpers_cache.json 2025-12-28 00:13:42 +00:00
ProxMenuxBot 853c58e0a0 Update helpers_cache.json 2025-12-26 18:03:55 +00:00
ProxMenuxBot eb0abc425a Update helpers_cache.json 2025-12-26 12:05:50 +00:00
ProxMenuxBot c808e40bf6 Update helpers_cache.json 2025-12-25 12:29:08 +00:00
ProxMenuxBot f0bbb14f3f Update helpers_cache.json 2025-12-24 18:20:56 +00:00
ProxMenuxBot 95dd0ea6fb Update helpers_cache.json 2025-12-24 01:06:53 +00:00
ProxMenuxBot 7f34102ae6 Update helpers_cache.json 2025-12-23 01:07:03 +00:00
ProxMenuxBot 7623962da5 Update helpers_cache.json 2025-12-22 01:10:38 +00:00
ProxMenuxBot cfb34b59df Update helpers_cache.json 2025-12-21 12:25:59 +00:00
ProxMenuxBot e5004bb55e Update helpers_cache.json 2025-12-20 01:03:45 +00:00
ProxMenuxBot c0193fdf73 Update helpers_cache.json 2025-12-19 01:08:01 +00:00
ProxMenuxBot 6cbafd557c Update helpers_cache.json 2025-12-16 12:30:30 +00:00
ProxMenuxBot ee8ab75907 Update helpers_cache.json 2025-12-15 12:32:47 +00:00
MacRimi f2e93ad69e Update route.ts 2025-12-14 02:37:02 +01:00
MacRimi 5faf3fd61c Update route.ts 2025-12-14 02:20:07 +01:00
MacRimi 956a8f4864 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-12-14 02:12:40 +01:00
MacRimi d26bc56b5c Update route.ts 2025-12-14 02:12:26 +01:00
ProxMenuxBot 7457770ef8 Update helpers_cache.json 2025-12-14 01:11:59 +00:00
MacRimi 54af9073cb Update web 2025-12-14 01:57:36 +01:00
MacRimi a8dcf5e8f5 Update web 2025-12-14 01:38:07 +01:00
MacRimi 9e3334d75f Update web 2025-12-13 21:19:08 +01:00
MacRimi cca6e71911 Update web 2025-12-13 20:20:21 +01:00
ProxMenuxBot 7fbd377ab2 Update helpers_cache.json 2025-12-13 18:18:09 +00:00
MacRimi 24417feba3 Update next.config.mjs 2025-12-13 19:09:26 +01:00
ProxMenuxBot f8c24964e3 Update helpers_cache.json 2025-12-13 01:02:58 +00:00
MacRimi 1ae2ebfaf0 Update nvidia_installer.sh 2025-12-11 22:07:01 +01:00
MacRimi 4feea6d153 Update script-terminal-modal.tsx 2025-12-11 21:30:30 +01:00
MacRimi ec6b658685 Update script-terminal-modal.tsx 2025-12-11 21:22:15 +01:00
MacRimi fb0f05a08d Update script-terminal-modal.tsx 2025-12-11 19:56:20 +01:00
MacRimi 11bc477f1f Update script-terminal-modal.tsx 2025-12-11 19:06:53 +01:00
MacRimi 9760375855 Update script-terminal-modal.tsx 2025-12-11 18:44:56 +01:00
MacRimi a6e20bd9f0 Update script-terminal-modal.tsx 2025-12-11 18:27:11 +01:00
MacRimi 90fedbf9a2 Update script-terminal-modal.tsx 2025-12-11 18:19:38 +01:00
MacRimi eb03262abc Update script-terminal-modal.tsx 2025-12-11 18:13:37 +01:00
MacRimi 59eb6e5f1b Update script-terminal-modal.tsx 2025-12-11 18:06:24 +01:00
MacRimi edf513aca9 Update script-terminal-modal.tsx 2025-12-11 17:55:19 +01:00
MacRimi efed63519a Update script-terminal-modal.tsx 2025-12-11 17:44:23 +01:00
MacRimi d78f781506 Update script-terminal-modal.tsx 2025-12-11 17:30:12 +01:00
ProxMenuxBot 93fe269b09 Update helpers_cache.json 2025-12-11 12:31:23 +00:00
ProxMenuxBot 8cad6c4e56 Update helpers_cache.json 2025-12-11 01:07:29 +00:00
MacRimi f92049dc71 Update script-terminal-modal.tsx 2025-12-10 23:48:43 +01:00
MacRimi a3497a9d39 Update script-terminal-modal.tsx 2025-12-10 23:43:05 +01:00
MacRimi bfc0a2ed57 Update script-terminal-modal.tsx 2025-12-10 23:34:30 +01:00
MacRimi c49b45d262 Update script-terminal-modal.tsx 2025-12-10 23:26:11 +01:00
MacRimi 15678cf96a Update script-terminal-modal.tsx 2025-12-10 23:15:50 +01:00
MacRimi feeaaa7f2b Update script-terminal-modal.tsx 2025-12-10 23:06:03 +01:00
MacRimi 50df1a2212 Update script-terminal-modal.tsx 2025-12-10 22:57:19 +01:00
MacRimi ac9254d049 Update script-terminal-modal.tsx 2025-12-10 22:48:07 +01:00
MacRimi e15eeb36a5 Update script-terminal-modal.tsx 2025-12-10 22:30:11 +01:00
MacRimi e275e03d4e Update script-terminal-modal.tsx 2025-12-10 22:19:41 +01:00
MacRimi 41c8826ca8 Update script-terminal-modal.tsx 2025-12-10 22:10:41 +01:00
MacRimi d8af31ba5b Update script-terminal-modal.tsx 2025-12-10 21:57:16 +01:00
MacRimi 2eb7cb1687 Update AppImage 2025-12-10 21:47:52 +01:00
MacRimi 207e75f5b9 Update script-terminal-modal.tsx 2025-12-10 20:17:13 +01:00
MacRimi 7b9e1a71a3 Update script-terminal-modal.tsx 2025-12-10 20:12:03 +01:00
MacRimi 345838c6ce Update script-terminal-modal.tsx 2025-12-10 19:54:39 +01:00
MacRimi b02a60f4b3 Update script-terminal-modal.tsx 2025-12-10 19:44:39 +01:00
MacRimi ecd3a4e490 Update script-terminal-modal.tsx 2025-12-10 19:26:19 +01:00
MacRimi 8c0c9bd60a Update script-terminal-modal.tsx 2025-12-10 19:14:57 +01:00
MacRimi 943a8bf02d Update script-terminal-modal.tsx 2025-12-10 18:54:35 +01:00
MacRimi d3beb72652 Update script-terminal-modal.tsx 2025-12-10 18:36:31 +01:00
MacRimi c62dd2014e Update script-terminal-modal.tsx 2025-12-10 18:22:30 +01:00
MacRimi 62fee7827b Update script-terminal-modal.tsx 2025-12-10 18:15:45 +01:00
MacRimi 80b9d16494 Update AppImage 2025-12-10 18:09:21 +01:00
MacRimi cb5581c49f Update AppImage 2025-12-10 17:56:11 +01:00
MacRimi 0098000ae0 Update terminal-panel.tsx 2025-12-10 17:45:38 +01:00
MacRimi ddc8429499 Update AppImage 2025-12-10 17:35:43 +01:00
MacRimi 0424961d46 Update AppImage 2025-12-10 17:08:41 +01:00
MacRimi cbf510cfd1 Update script-terminal-modal.tsx 2025-12-10 17:01:17 +01:00
MacRimi cbb44ae253 Update script-terminal-modal.tsx 2025-12-10 16:56:56 +01:00
MacRimi 4dd4f045aa Update AppImage 2025-12-10 16:50:52 +01:00
ProxMenuxBot ab0d7f8dc6 Update helpers_cache.json 2025-12-09 12:29:33 +00:00
MacRimiandGitHub 69f93fcb59 Actualizar select_nas_iso.sh 2025-12-08 22:52:34 +01:00
ProxMenuxBot de68e0d7c2 Update helpers_cache.json 2025-12-08 01:05:59 +00:00
MacRimi cdbcb451e1 Update script-terminal-modal.tsx 2025-12-06 23:41:34 +01:00
MacRimi 105c543a98 Update script-terminal-modal.tsx 2025-12-06 23:25:35 +01:00
MacRimi ab421e3184 Update AppImage 2025-12-06 23:06:18 +01:00
MacRimi d76b7a99b8 Apdate AppImage 2025-12-06 22:40:24 +01:00
MacRimi e8dae63e05 Update script-terminal-modal.tsx 2025-12-06 22:33:17 +01:00
MacRimi ea58b70435 Update script-terminal-modal.tsx 2025-12-06 22:26:42 +01:00
MacRimi f90f6f364a Update script-terminal-modal.tsx 2025-12-06 22:20:34 +01:00
MacRimi 7fc967c64c Update script-terminal-modal.tsx 2025-12-06 22:09:54 +01:00
MacRimi 8969a229d1 Update script-terminal-modal.tsx 2025-12-06 22:03:12 +01:00
MacRimi 9601e0428e Update script-terminal-modal.tsx 2025-12-06 21:55:31 +01:00
MacRimi 94fd91ce4a Update script-terminal-modal.tsx 2025-12-06 21:50:15 +01:00
MacRimi 310f972c7f Update script-terminal-modal.tsx 2025-12-06 21:37:43 +01:00
MacRimi 4378a5843c Update AppImage 2025-12-06 21:30:17 +01:00
MacRimi 9bd403ec51 Update script-terminal-modal.tsx 2025-12-06 21:20:23 +01:00
MacRimi 2f53786ca9 Update terminal-panel.tsx 2025-12-06 20:56:36 +01:00
MacRimi 07ed213c94 Update script-terminal-modal.tsx 2025-12-06 20:46:13 +01:00
MacRimi 05a2eca9a7 Update AppImage 2025-12-06 20:27:00 +01:00
MacRimi d30c836d04 Update AppImage 2025-12-06 20:10:24 +01:00
MacRimi 8c623adad8 Update AppImage 2025-12-06 19:43:41 +01:00
MacRimi 5191edfc0c Update AppImage 2025-12-06 19:31:07 +01:00
MacRimi ff99663d5c Update AppImage 2025-12-06 19:19:54 +01:00
MacRimi 360335a608 Update AppImage 2025-12-06 19:03:19 +01:00
MacRimi 1c83e5eeab Update AppImage 2025-12-06 18:36:34 +01:00
MacRimi 122ebb12f4 Update AppImage 2025-12-06 13:54:37 +01:00
MacRimi fed242315d Update AppImage 2025-12-06 13:48:46 +01:00
MacRimi 84e8e18ef8 Update script-terminal-modal.tsx 2025-12-06 13:38:19 +01:00
MacRimi 36a1916b5f Update script-terminal-modal.tsx 2025-12-06 13:28:56 +01:00
MacRimi a1089460d7 Update AppImage 2025-12-06 13:11:04 +01:00
MacRimi c62f0dea6f Update script-terminal-modal.tsx 2025-12-06 13:00:29 +01:00
MacRimi a6c121dc33 Update AppImage 2025-12-06 12:46:41 +01:00
MacRimi c627c65a7d Update AppImage 2025-12-06 12:25:57 +01:00
MacRimi 72006aff21 Update hardware.tsx 2025-12-06 12:14:08 +01:00
MacRimi 68338ebeff Update flask_terminal_routes.py 2025-12-06 12:06:12 +01:00
MacRimi 49b8503b64 Update AppImage 2025-12-06 11:54:36 +01:00
MacRimi 0fc41df7e7 Update flask_terminal_routes.py 2025-12-06 11:37:16 +01:00
MacRimi bb82c52747 Update AppImage 2025-12-06 11:30:49 +01:00
MacRimi a79367fb1c Update script-terminal-modal.tsx 2025-12-06 11:25:27 +01:00
MacRimi 4dbc6db6f0 Update script-terminal-modal.tsx 2025-12-06 11:19:26 +01:00
ProxMenuxBot a50cee62be Update helpers_cache.json 2025-12-05 18:18:53 +00:00
ProxMenuxBot 382aa5cb16 Update helpers_cache.json 2025-12-05 01:05:55 +00:00
ProxMenuxBot d1c2ff277b Update helpers_cache.json 2025-12-03 01:05:21 +00:00
ProxMenuxBot 92b08b5550 Update helpers_cache.json 2025-12-02 12:30:00 +00:00
ProxMenuxBot da85470fef Update helpers_cache.json 2025-12-02 01:04:45 +00:00
MacRimi 9d1e7d94cc Update script-terminal-modal.tsx 2025-12-01 01:40:04 +01:00
MacRimi 65438286ec Update script-terminal-modal.tsx 2025-12-01 01:26:29 +01:00
MacRimi ffa7d27148 Update script-terminal-modal.tsx 2025-12-01 01:22:04 +01:00
MacRimi 4a7d951d0d Update script-terminal-modal.tsx 2025-12-01 01:15:19 +01:00
MacRimi 89f1911a6e Update package.json 2025-12-01 01:07:41 +01:00
MacRimi b990bd1792 Update AppImage 2025-12-01 01:04:31 +01:00
MacRimi 88667416d8 Update hybrid-script-monitor.tsx 2025-12-01 00:38:42 +01:00
MacRimi 216491012e Update hybrid-script-monitor.tsx 2025-12-01 00:31:23 +01:00
MacRimi c88f3dcf75 Update flask_script_runner.py 2025-12-01 00:17:04 +01:00
MacRimi 6c3e21339d Update hybrid-script-monitor.tsx 2025-11-30 23:57:08 +01:00
MacRimi e7f9f9f13d Update hybrid-script-monitor.tsx 2025-11-30 23:47:33 +01:00
MacRimi 6b8d6da5be Update AppImage 2025-11-30 23:40:13 +01:00
MacRimi 8c73c5d662 Update hybrid-script-monitor.tsx 2025-11-30 23:27:32 +01:00
MacRimi f7dc2c9a9e Update hybrid-script-monitor.tsx 2025-11-30 23:20:34 +01:00
MacRimi eadf825b67 Update hybrid-script-monitor.tsx 2025-11-30 23:13:37 +01:00
MacRimi 150999d71b Update AppImage 2025-11-30 23:00:02 +01:00
MacRimi 7cd89a594e Update AppImage 2025-11-30 22:50:40 +01:00
MacRimi b67f1cb4b8 Update hardware.tsx 2025-11-30 22:12:51 +01:00
MacRimi 4678f8c7da Update hardware.tsx 2025-11-30 21:55:37 +01:00
MacRimi 0577f48437 Update hardware.tsx 2025-11-30 21:49:13 +01:00
MacRimi 0c079482f0 Update hardware.tsx 2025-11-30 21:38:49 +01:00
MacRimi 684fe3945d Update AppImage 2025-11-30 21:19:38 +01:00
MacRimi d91d325744 Update route.ts 2025-11-30 20:36:25 +01:00
MacRimi 040d7564ed Update AppImage 2025-11-30 20:25:44 +01:00
MacRimi d1db34445e Update AppImage 2025-11-30 19:40:42 +01:00
MacRimi 9639dd422a Update AppImage 2025-11-30 19:15:07 +01:00
MacRimi f60bfe8c54 Update nvidia installer 2025-11-30 18:29:05 +01:00
MacRimi fe53c11447 Update utils.sh 2025-11-30 18:05:56 +01:00
MacRimi 9bd17bdf6f Update utils.sh 2025-11-30 17:47:30 +01:00
MacRimi 4b64308951 Update utils.sh 2025-11-30 17:12:11 +01:00
MacRimi bb7dacea91 Update AppImage 2025-11-30 16:02:44 +01:00
MacRimi 0a369621a3 Update nvidia_installer.sh 2025-11-30 15:41:40 +01:00
MacRimi e0ee1a50ae new script nvidia 2025-11-30 15:31:19 +01:00
MacRimi 6b49fc4294 Update utils.sh 2025-11-30 12:57:29 +01:00
ProxMenuxBot ed20ea6af4 Update helpers_cache.json 2025-11-30 01:11:38 +00:00
ProxMenuxBot 73fe4dc7a0 Update helpers_cache.json 2025-11-29 18:18:19 +00:00
MacRimi c4967de530 Update utils.sh 2025-11-29 11:04:28 +01:00
ProxMenuxBot bcf3d36ba1 Update helpers_cache.json 2025-11-28 18:19:11 +00:00
MacRimi d52bd7f012 Update update-pve9_2.sh 2025-11-28 19:10:26 +01:00
MacRimi e6232be244 Update update-pve9_2.sh 2025-11-28 17:31:32 +01:00
MacRimi b33f313e2e Update update-pve9_2.sh 2025-11-28 17:06:59 +01:00
ProxMenuxBot 0b4372fe88 Update helpers_cache.json 2025-11-27 18:19:16 +00:00
MacRimi 4e07c7f2dc Update system-overview.tsx 2025-11-27 18:11:56 +01:00
MacRimi 941e194df3 Update system-overview.tsx 2025-11-27 17:50:26 +01:00
MacRimi 2b8f94f457 Update health_monitor.py 2025-11-27 17:30:19 +01:00
MacRimi 7ec8c0cea5 Update health_monitor.py 2025-11-27 14:56:12 +01:00
MacRimi c69384dabd Update health_monitor.py 2025-11-27 13:39:02 +01:00
MacRimi 8c92216a1d Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-27 13:29:27 +01:00
MacRimi 41537c0bad Update health_monitor.py 2025-11-27 13:29:15 +01:00
ProxMenuxBot c112f56b37 Update helpers_cache.json 2025-11-27 12:28:29 +00:00
MacRimi f22de50527 Update flask_server.py 2025-11-27 12:45:57 +01:00
MacRimi a22e08f39d Update AppImage 2025-11-27 12:34:51 +01:00
MacRimi 210d470473 Update AppImage 2025-11-27 12:17:52 +01:00
MacRimi 0eebb77438 Update AppImage 2025-11-27 11:58:20 +01:00
MacRimi f819cb9c5f Update hardware.tsx 2025-11-27 09:32:41 +01:00
MacRimi 240963f1f3 Update hardware.tsx 2025-11-27 09:29:12 +01:00
MacRimi 16819d98fa Update AppImage 2025-11-27 09:19:45 +01:00
MacRimi 8be7e0f0cb Update hardware.tsx 2025-11-26 21:33:25 +01:00
MacRimi 3a51daf51b Update hardware.tsx 2025-11-26 21:27:56 +01:00
MacRimi 7622e72b70 Update flask_server.py 2025-11-26 21:15:35 +01:00
MacRimi b59173cac4 Update flask_server.py 2025-11-26 20:46:56 +01:00
MacRimi 18411ee5bd Update AppImage 2025-11-26 20:31:09 +01:00
MacRimi 6e1c6fab2d Update flask_server.py 2025-11-26 20:22:10 +01:00
MacRimi 98eb2d8836 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-26 19:38:28 +01:00
MacRimi 504e32f922 Update flask_server.py 2025-11-26 19:38:24 +01:00
ProxMenuxBot c096054b1f Update helpers_cache.json 2025-11-26 18:17:30 +00:00
MacRimi ac2f198851 Update flask_server.py 2025-11-26 18:57:01 +01:00
MacRimi 9aed659f17 Update AppImage 2025-11-26 18:44:37 +01:00
MacRimi 0b8f5d3b22 Update AppImage 2025-11-26 18:00:01 +01:00
MacRimi 55c74e8891 Update AppImage 2025-11-26 17:36:23 +01:00
MacRimi 3a49aa6a67 Update hardware_monitor.py 2025-11-26 16:48:24 +01:00
MacRimi 10770b6fe1 Update AppImage 2025-11-26 12:27:25 +01:00
MacRimi c81ea08f42 Update terminal-panel.tsx 2025-11-25 22:54:27 +01:00
MacRimi 73b6ab4a18 Update terminal-panel.tsx 2025-11-25 22:42:14 +01:00
MacRimi 7497235d7b Update terminal-panel.tsx 2025-11-25 22:35:23 +01:00
MacRimi 27191e4234 Update terminal-panel.tsx 2025-11-25 22:23:02 +01:00
MacRimi 7b0110ce42 Update AppImage 2025-11-25 19:44:40 +01:00
MacRimi 117a635a1e Update sidebar.tsx 2025-11-25 19:31:22 +01:00
MacRimi 98c922fb3e Update AppImage 2025-11-25 19:26:50 +01:00
MacRimi bf84d04f1f Update AppImage 2025-11-25 19:08:00 +01:00
MacRimi f4e358b509 Update AppImge 2025-11-25 19:04:54 +01:00
MacRimi 060ad7966e Update proxmox-dashboard.tsx 2025-11-25 17:21:29 +01:00
MacRimi f0301fd1a4 Update AppImage 2025-11-25 17:21:20 +01:00
MacRimi ae8212a51d Update terminal-panel.tsx 2025-11-24 23:54:46 +01:00
MacRimi 393a0d5cdc Update terminal-panel.tsx 2025-11-24 23:26:36 +01:00
MacRimi 4cf43a8d74 Update terminal-panel.tsx 2025-11-24 23:10:50 +01:00
MacRimi 74b2f47e3a Update terminal-panel.tsx 2025-11-24 22:57:09 +01:00
ProxMenuxBot 1e727db09a Update helpers_cache.json 2025-11-24 18:21:47 +00:00
MacRimi 1daa120d06 Update terminal-panel.tsx 2025-11-24 19:11:11 +01:00
MacRimi a1d2445ae6 Update terminal-panel.tsx 2025-11-24 19:01:15 +01:00
MacRimi 4d4e35e24b Update terminal-panel.tsx 2025-11-24 18:41:42 +01:00
MacRimi 400cc599e3 Update terminal-panel.tsx 2025-11-24 18:28:06 +01:00
MacRimi e55352346b Update terminal-panel.tsx 2025-11-24 18:18:10 +01:00
MacRimi cca226dec0 Update terminal-panel.tsx 2025-11-24 17:50:41 +01:00
MacRimi fec95c91f8 Update terminal-panel.tsx 2025-11-24 17:29:38 +01:00
MacRimiandGitHub 9955418a8e Remove 'Contributing' section from README 2025-11-24 15:43:10 +01:00
MacRimiandGitHub 90c7539956 Remove contributing and development setup sections
Removed the contributing section and development setup instructions from the README.
2025-11-24 15:41:52 +01:00
MacRimiandGitHub a751e45602 Update README.md 2025-11-24 15:40:49 +01:00
MacRimi b50d388f9e Update terminal-panel.tsx 2025-11-24 13:37:17 +01:00
MacRimi fd60292b5d Update terminal-panel.tsx 2025-11-24 13:25:20 +01:00
MacRimi 4ebb0c432e Update terminal-panel.tsx 2025-11-24 13:16:35 +01:00
MacRimi 897b2478e8 Update terminal-panel.tsx 2025-11-24 13:02:04 +01:00
MacRimi b8ebb7f6c4 Update terminal-panel.tsx 2025-11-24 12:24:16 +01:00
MacRimi f32dba72b4 Update terminal-panel.tsx 2025-11-24 12:10:07 +01:00
MacRimi 498ad280e0 Update terminal-panel.tsx 2025-11-24 11:49:20 +01:00
MacRimi 32358de718 Update AppImage 2025-11-24 11:37:00 +01:00
MacRimi 2474a6ce01 Update terminal-panel.tsx 2025-11-24 11:21:50 +01:00
MacRimi 1ba45200ee Update AppImage 2025-11-24 11:01:48 +01:00
MacRimi da793856ce Update terminal-panel.tsx 2025-11-23 23:46:34 +01:00
MacRimi d950588c36 Update terminal-panel.tsx 2025-11-23 23:42:19 +01:00
MacRimi 2b4a5d2ce7 Update terminal-panel.tsx 2025-11-23 23:29:49 +01:00
MacRimi 86daedc802 Update AppImage 2025-11-23 23:23:59 +01:00
MacRimi 3788487196 Update terminal-panel.tsx 2025-11-23 23:10:23 +01:00
MacRimi 25559b7e3e Update terminal-panel.tsx 2025-11-23 22:51:37 +01:00
MacRimi 246db33ee6 Update terminal-panel.tsx 2025-11-23 22:47:30 +01:00
MacRimi d435e9b58b Update terminal-panel.tsx 2025-11-23 22:45:09 +01:00
MacRimi 09ecc79050 Update terminal-panel.tsx 2025-11-23 22:39:46 +01:00
MacRimi 1914435707 Update terminal-panel.tsx 2025-11-23 22:34:50 +01:00
MacRimi f6c237afc5 Update terminal-panel.tsx 2025-11-23 22:30:14 +01:00
MacRimi a1f2579047 Update terminal-panel.tsx 2025-11-23 22:25:21 +01:00
MacRimi 1ea6617a5d Update terminal-panel.tsx 2025-11-23 22:21:24 +01:00
MacRimi 489175aa45 Update AppImage 2025-11-23 22:17:45 +01:00
MacRimi cb72f43b03 Update globals.css 2025-11-23 22:01:50 +01:00
MacRimi 4bbbcc7c39 Update terminal-panel.tsx 2025-11-23 21:43:17 +01:00
MacRimi af1e4884b7 Update AppImage 2025-11-23 21:39:45 +01:00
MacRimi 5213d6255a Update globals.css 2025-11-23 21:32:46 +01:00
MacRimi a9af689aa5 Update terminal-panel.tsx 2025-11-23 21:22:52 +01:00
MacRimi 407a9f7780 Update terminal-panel.tsx 2025-11-23 21:15:54 +01:00
MacRimi a0ca667ca7 Update terminal-panel.tsx 2025-11-23 21:08:14 +01:00
MacRimi c2f6f97c34 Update terminal-panel.tsx 2025-11-23 20:59:50 +01:00
MacRimi 2daefbe2f4 Update terminal-panel.tsx 2025-11-23 20:47:42 +01:00
MacRimi 84b0c9d4b7 Update terminal-panel.tsx 2025-11-23 20:26:26 +01:00
MacRimi 0d848569f0 Update terminal-panel.tsx 2025-11-23 20:19:07 +01:00
MacRimi 611f8397ca Update terminal-panel.tsx 2025-11-23 20:16:05 +01:00
MacRimi 11ed0a1367 Update terminal-panel.tsx 2025-11-23 20:06:33 +01:00
MacRimi ff51966fbb Update terminal-panel.tsx 2025-11-23 19:58:31 +01:00
MacRimi 5491d51eba Update terminal-panel.tsx 2025-11-23 19:53:39 +01:00
MacRimi 61a5a7e929 Update terminal-panel.tsx 2025-11-23 19:46:57 +01:00
MacRimi 3de000bc94 Update terminal-panel.tsx 2025-11-23 19:41:36 +01:00
MacRimi ef456e6ea0 Update terminal-panel.tsx 2025-11-23 19:30:01 +01:00
MacRimi 2a8b67e22a Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-23 19:25:58 +01:00
MacRimi c35b66f6e1 Update terminal-panel.tsx 2025-11-23 19:25:49 +01:00
ProxMenuxBot c8348dcaaa Update helpers_cache.json 2025-11-23 18:18:06 +00:00
MacRimi e38174110e Update terminal-panel.tsx 2025-11-23 19:14:18 +01:00
MacRimi a95130c01f Update terminal-panel.tsx 2025-11-23 19:10:22 +01:00
MacRimi 0e93417090 Update terminal-panel.tsx 2025-11-23 18:56:14 +01:00
MacRimi 07054bf55a Update terminal-panel.tsx 2025-11-23 18:51:46 +01:00
MacRimi 368eab476a Update AppImage 2025-11-23 18:41:58 +01:00
MacRimi 996679a2d2 Update AppImage 2025-11-23 18:34:47 +01:00
MacRimi 85a6943cd5 Update terminal-panel.tsx 2025-11-23 18:17:00 +01:00
MacRimi 0b96893f3b Update terminal-panel.tsx 2025-11-23 18:12:09 +01:00
MacRimi 846e2e27ba Update terminal-panel.tsx 2025-11-23 17:57:46 +01:00
MacRimi 43ea9b7696 Update globals.css 2025-11-23 17:42:00 +01:00
MacRimi 9dd4df2ca9 Update terminal-panel.tsx 2025-11-23 17:29:11 +01:00
MacRimi 2b4fb55526 Update globals.css 2025-11-23 17:17:26 +01:00
MacRimi 72cf16301f Update AppImage 2025-11-23 17:09:25 +01:00
MacRimi c512dde028 Update terminal-panel.tsx 2025-11-23 16:56:41 +01:00
MacRimi 1e13c7ab31 Update globals.css 2025-11-23 16:48:32 +01:00
MacRimi cdbab86dee Update AppImage 2025-11-23 16:32:01 +01:00
MacRimi fec03d1fd4 Update AppImage 2025-11-23 16:10:41 +01:00
MacRimi 6aa24e23c0 Update terminal-panel.tsx 2025-11-23 14:16:21 +01:00
MacRimi 78770d1da5 Update terminal-panel.tsx 2025-11-23 14:04:43 +01:00
MacRimi 6f72447e2e Update terminal-panel.tsx 2025-11-23 13:52:11 +01:00
MacRimi cb75a15a6f Update terminal-panel.tsx 2025-11-23 13:47:40 +01:00
MacRimi c3555237b3 Update terminal-panel.tsx 2025-11-23 13:39:54 +01:00
MacRimi e4a2cc7ac8 Update terminal-panel.tsx 2025-11-23 13:28:30 +01:00
MacRimi 3900d305b9 Update terminal-panel.tsx 2025-11-23 12:15:55 +01:00
MacRimi cb3d501649 Update terminal-panel.tsx 2025-11-23 12:02:34 +01:00
MacRimi 28323a486a Update terminal-panel.tsx 2025-11-23 11:56:38 +01:00
MacRimi dfcad4b9fd Update terminal-panel.tsx 2025-11-23 11:51:51 +01:00
MacRimi 6fb2869cd8 Update terminal-panel.tsx 2025-11-23 11:42:30 +01:00
MacRimi e764e39ba9 Update terminal-panel.tsx 2025-11-23 11:21:17 +01:00
MacRimi 128077dcbc Update AppImage 2025-11-23 10:57:28 +01:00
MacRimi 1c51107f1e Update AppImage 2025-11-22 23:59:55 +01:00
MacRimi d154cab054 Update proxmox-dashboard.tsx 2025-11-22 23:55:10 +01:00
MacRimi 7ed4368d5b Update appImage 2025-11-22 23:46:43 +01:00
MacRimi ee64df2376 Update AppImage 2025-11-22 23:34:09 +01:00
MacRimi b13f03eb97 Update terminal-panel.tsx 2025-11-22 23:19:10 +01:00
MacRimi 8d20829428 Update AppImage 2025-11-22 23:08:11 +01:00
MacRimi 97401f609e Update terminal-panel.tsx 2025-11-22 22:57:49 +01:00
MacRimi fe074729ea Update AppImage 2025-11-22 22:50:23 +01:00
MacRimi db5141e010 Update AppImage 2025-11-22 22:28:34 +01:00
MacRimi 4564fdc6aa Update AppImage 2025-11-22 22:15:39 +01:00
MacRimi a477b36a57 Update terminal-panel.tsx 2025-11-22 22:06:07 +01:00
MacRimi 3b8ae2c879 Update terminal-panel.tsx 2025-11-22 21:58:29 +01:00
MacRimi ebe3a51398 Update appImage 2025-11-22 21:43:14 +01:00
MacRimi 76d22f0cb5 Update AppImage 2025-11-22 21:35:22 +01:00
MacRimi c61d676dfb Update terminal-panel.tsx 2025-11-22 21:26:35 +01:00
MacRimi b1913e7204 Update terminal-panel.tsx 2025-11-22 21:12:05 +01:00
MacRimi b6609e0a14 Update AppImage 2025-11-22 21:06:44 +01:00
MacRimi 55fa759344 Update AppImage 2025-11-22 20:58:06 +01:00
MacRimi 8992a713cc Update AppImage 2025-11-22 20:50:05 +01:00
MacRimi c55dcec252 Update AppImage 2025-11-22 20:41:36 +01:00
MacRimi e3dd6cbef5 Update AppImage 2025-11-22 20:27:41 +01:00
MacRimi dd3e5ea368 Update globals.css 2025-11-22 20:20:57 +01:00
MacRimi ac2e77e0d6 Update globals.css 2025-11-22 20:17:10 +01:00
MacRimi 9f57622f54 Update globals.css 2025-11-22 20:13:02 +01:00
MacRimi cfed460eba Update globals.css 2025-11-22 20:09:36 +01:00
MacRimi 06f97b671f Update globals.css 2025-11-22 20:06:43 +01:00
MacRimi aebf83d735 Update AppImage 2025-11-22 20:02:42 +01:00
MacRimi 31894dd117 Update globals.css 2025-11-22 19:44:00 +01:00
MacRimi e041d802ec Update globals.css 2025-11-22 19:37:31 +01:00
MacRimi 82ea15388c Update globals.css 2025-11-22 19:33:58 +01:00
MacRimi bf9ed8ff00 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-22 19:27:57 +01:00
MacRimi c02606df6a Update Appimage 2025-11-22 19:27:45 +01:00
ProxMenuxBot 7372e2e385 Update helpers_cache.json 2025-11-22 18:17:41 +00:00
MacRimi ba86fa6d3e Update terminal-panel.tsx 2025-11-22 19:07:27 +01:00
MacRimi 0e434cbd1c Update AppImage 2025-11-22 18:15:12 +01:00
MacRimi c89300022a Update AppImage 2025-11-22 17:54:30 +01:00
MacRimi 1300756d6f Update AppImage 2025-11-22 17:40:47 +01:00
MacRimi c4ad02ff92 Update terminal-panel.tsx 2025-11-22 17:32:47 +01:00
MacRimi b3f47f140a Update AppImage 2025-11-22 17:16:18 +01:00
MacRimi 2206b3d5b5 Update terminal-panel.tsx 2025-11-22 11:56:29 +01:00
MacRimi b08f8a450d Update terminal-panel.tsx 2025-11-22 11:43:17 +01:00
MacRimi 37c8be8a6e Update terminal-panel.tsx 2025-11-22 11:21:11 +01:00
MacRimi ae58c265a0 Update AppImage 2025-11-22 11:04:21 +01:00
MacRimi 54e6d1aa16 Update AppImage 2025-11-22 10:33:35 +01:00
MacRimi 4ddb5f14d9 Update flask_terminal_routes.py 2025-11-21 20:12:07 +01:00
MacRimi 623aec495b Update proxmox-dashboard.tsx 2025-11-21 19:56:22 +01:00
MacRimi f6d2b9bad0 Update AppImage 2025-11-21 19:49:42 +01:00
MacRimi 08b5a278f3 Update proxmox-dashboard.tsx 2025-11-21 19:44:15 +01:00
MacRimi f62b30b50d Update AppImage 2025-11-21 19:32:27 +01:00
MacRimi 50e3b8e7d4 Update terminal-panel.tsx 2025-11-21 19:25:23 +01:00
MacRimi e26956dbe8 Update terminal-panel.tsx 2025-11-21 19:15:35 +01:00
MacRimi cff2c12d70 Update build_appimage.sh 2025-11-21 18:53:30 +01:00
MacRimi 5781d532a4 Update build_appimage.sh 2025-11-21 18:47:56 +01:00
MacRimi f161a593f8 Update page.tsx 2025-11-21 18:40:19 +01:00
MacRimi 5725d5a2fe Update AppImage 2025-11-21 18:36:09 +01:00
MacRimi 23280fd97b Update AppImage 2025-11-21 18:32:10 +01:00
MacRimi fe6679f16a Update update-pve9_2.sh 2025-11-21 18:01:34 +01:00
MacRimi 19a95a3670 Update network-traffic-chart.tsx 2025-11-19 22:54:41 +01:00
MacRimi 90cffb3791 Update format-network.ts 2025-11-19 22:47:24 +01:00
MacRimi 31168fbeca Update network-metrics.tsx 2025-11-19 22:33:28 +01:00
MacRimi c4cce5d184 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-19 22:05:49 +01:00
MacRimi 08b59dd082 Update network-metrics.tsx 2025-11-19 22:05:45 +01:00
ProxMenuxBot 4aaf1a5868 Update helpers_cache.json 2025-11-19 18:20:10 +00:00
MacRimi 6e78fa0b1f Update AppImage 2025-11-19 18:43:08 +01:00
MacRimi e1a42189a6 Update system-overview.tsx 2025-11-19 18:18:47 +01:00
MacRimi 386e0c9b6b Update format-network.ts 2025-11-19 18:09:52 +01:00
MacRimi 3b1b423936 Update AppImage 2025-11-19 17:58:03 +01:00
MacRimi 8e8e8161bb Update AppImage 2025-11-19 17:30:01 +01:00
MacRimi b368fde82d Update appImage 2025-11-19 17:15:32 +01:00
ProxMenuxBot 7267111083 Update helpers_cache.json 2025-11-19 12:28:30 +00:00
MacRimi d05dab6633 Update AppImage 2025-11-18 22:05:54 +01:00
MacRimi e1409a8045 Update AppImage 2025-11-18 21:27:24 +01:00
MacRimi ae69fec7ce Update AppImage 2025-11-18 21:11:56 +01:00
MacRimi a2862f22f6 Update AppImage 2025-11-18 20:56:15 +01:00
MacRimi 7db8e18bcc Update AppImage 2025-11-18 19:43:18 +01:00
MacRimi 0ffe1272fe Update virtual-machines.tsx 2025-11-18 19:24:23 +01:00
MacRimi 92b54075c4 Update AppImage 2025-11-18 19:11:14 +01:00
MacRimi ce5c679d6b Update AppImage 2025-11-18 19:00:51 +01:00
MacRimi 4f61386b21 Update flask_server.py 2025-11-18 18:33:57 +01:00
MacRimi 2738ae1abc Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-18 17:14:46 +01:00
MacRimi f5e43ff7b4 Update flask_server.py 2025-11-18 17:14:30 +01:00
MacRimiandGitHub 63c499bf2c Merge pull request #93 from riri-314/networkGraph
Add option to change network unit
2025-11-18 17:00:23 +01:00
riri-314 9e72720bda revert code formating 2025-11-18 13:30:37 +01:00
ProxMenuxBot bbe10b2dab Update helpers_cache.json 2025-11-18 12:29:38 +00:00
riri-314 f3b0784651 Network metrics take network unit into acount 2025-11-18 13:19:23 +01:00
riri-314 9c0ea9b1c7 System oberview take network unit into account 2025-11-18 13:08:39 +01:00
riri-314 620a088c6c Removed debug code 2025-11-18 11:32:32 +01:00
riri-314 867a74cffb Added optional prop to display network traffic in bits or bytes 2025-11-18 11:26:49 +01:00
riri-314 f2316fdd3a Add setting to change network unit 2025-11-18 10:58:06 +01:00
MacRimi 7d49d4f948 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-17 19:26:44 +01:00
MacRimi f85b2b889c Update flask_server.py 2025-11-17 19:26:30 +01:00
ProxMenuxBot 9471ac4a52 Update helpers_cache.json 2025-11-17 18:20:20 +00:00
MacRimi db520c39e3 Update flask_server.py 2025-11-17 18:48:20 +01:00
MacRimi cc59fbe2ba Delete ProxMenux-1.0.2-deb.AppImage 2025-11-17 18:22:15 +01:00
MacRimi e260af58f2 Create ProxMenux-1.0.2-deb.AppImage 2025-11-17 17:53:50 +01:00
MacRimi 166fc6dad9 Update AppImage 2025-11-17 17:47:58 +01:00
MacRimi 959433d737 Update install_proxmenux.sh 2025-11-17 17:11:41 +01:00
MacRimi f9fa9ce6d8 Update install_proxmenux.sh 2025-11-17 16:58:45 +01:00
MacRimi 6b3a41dfe0 Update install_proxmenux.sh 2025-11-17 16:40:56 +01:00
ProxMenuxBot 37428ecca4 Update helpers_cache.json 2025-11-16 18:18:01 +00:00
MacRimi 6934df253f update menu 2025-11-16 10:19:50 +01:00
MacRimi 00782598a4 Update menu 2025-11-16 01:21:07 +01:00
MacRimi 565c500810 Update menu 2025-11-16 01:17:54 +01:00
MacRimi e3c16166e6 Update menu 2025-11-16 01:15:48 +01:00
MacRimi cfa8d1b689 Update menu 2025-11-16 01:13:24 +01:00
MacRimi a19397f9b5 Update menu 2025-11-16 01:11:49 +01:00
MacRimi ddfc80b45f Update menu 2025-11-16 01:07:25 +01:00
MacRimi 8591f9b2a1 Update menu 2025-11-16 01:04:16 +01:00
MacRimi cb26a55e65 Update menu 2025-11-16 00:59:54 +01:00
MacRimi ef92394685 Update menu 2025-11-16 00:56:04 +01:00
MacRimi d588ef438e Update menu 2025-11-16 00:53:40 +01:00
MacRimi 09cd363b11 Update menu 2025-11-16 00:51:12 +01:00
MacRimi 2d5c7fdbb5 Update menu 2025-11-16 00:47:26 +01:00
MacRimi 2f0e28368d Update menu 2025-11-16 00:44:00 +01:00
MacRimi f7f1a2a3b3 Update menu 2025-11-16 00:33:26 +01:00
MacRimi 30afb85260 Update menu 2025-11-16 00:23:14 +01:00
MacRimi 78d883a1b4 Update menu 2025-11-16 00:17:22 +01:00
MacRimi 7913b673a3 Update menu 2025-11-16 00:13:52 +01:00
MacRimi 5edc27297f Update menu 2025-11-16 00:02:52 +01:00
MacRimi ebc24c2476 Update menu 2025-11-15 23:58:26 +01:00
MacRimi ed7dd037e5 Update menu 2025-11-15 23:52:39 +01:00
MacRimi 277924c04d Update menu 2025-11-15 23:48:50 +01:00
MacRimi 26ea0feddb Update menu 2025-11-15 23:46:02 +01:00
MacRimi 63c1eab930 Update menu 2025-11-15 23:42:17 +01:00
MacRimi 813e7711df Update menu 2025-11-15 23:40:46 +01:00
MacRimiandGitHub 6c1f50a230 Remove Nginx configuration example from README
Removed example Nginx configuration from README.
2025-11-15 16:17:31 +01:00
MacRimiandGitHub 470b6359ba Remove onboarding image sections from README
Removed multiple image sections related to storage management, network monitoring, virtual machines, hardware information, and system logs from the README.
2025-11-15 16:16:30 +01:00
MacRimiandGitHub 2f45233748 Fix formatting issues in menu file 2025-11-15 15:20:12 +01:00
MacRimiandGitHub 82fd52f572 Add LOCAL_SCRIPTS variable to menu configuration 2025-11-15 14:30:34 +01:00
MacRimi ed6331e6a4 Update config_menu.sh 2025-11-14 22:17:11 +01:00
MacRimi 2ae9188535 Update version.txt 2025-11-14 21:44:50 +01:00
MacRimi 1a55a5394a Update menu 2025-11-14 21:43:38 +01:00
MacRimi 99d2f37cfc Update version.txt 2025-11-14 21:42:15 +01:00
MacRimi 09b531e0c1 Update version.txt 2025-11-14 21:40:38 +01:00
MacRimi 232e872c0d Update menu 2025-11-14 21:37:55 +01:00
MacRimi acdb0d2838 Update menu 2025-11-14 21:30:58 +01:00
MacRimi bd0ea1379f Update menu 2025-11-14 21:02:43 +01:00
MacRimi 5461ea1a3a Update menu 2025-11-14 20:48:38 +01:00
MacRimi 200ee075b5 Update menu 2025-11-14 20:38:07 +01:00
MacRimi 79e9e5fcf1 Update version.txt 2025-11-14 20:33:07 +01:00
MacRimi a2df23d562 Update version.txt 2025-11-14 20:23:30 +01:00
MacRimi 55af3d7f65 Update version.txt 2025-11-14 20:21:12 +01:00
MacRimi ef54f3fe59 Update ChangeLog 2025-11-14 20:14:03 +01:00
MacRimi 9d84ff6aa7 Update version.txt 2025-11-14 20:10:57 +01:00
MacRimi ee26006f3c Update CHANGELOG.md 2025-11-14 20:08:07 +01:00
MacRimi be03035574 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-14 19:35:39 +01:00
MacRimi 619f3ca700 Create ProxMenux_offline.png 2025-11-14 19:35:37 +01:00
ProxMenuxBot 8553e63338 Update helpers_cache.json 2025-11-14 18:19:26 +00:00
MacRimi be4d9fe24b Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-14 18:54:33 +01:00
MacRimi 497f727b08 Update menu_Helper_Scripts.sh 2025-11-14 18:54:32 +01:00
ProxMenuxBot 74a7569f4c Update helpers_cache.json 2025-11-14 17:18:37 +00:00
MacRimi 66185e3b91 Update cache 2025-11-14 18:17:23 +01:00
MacRimi 1b2beda695 Update workflow 2025-11-14 18:15:56 +01:00
MacRimi feb3b5ef5f Update config_menu.sh 2025-11-14 17:44:19 +01:00
MacRimi b2439331b3 Update install_proxmenux.sh 2025-11-14 17:26:08 +01:00
MacRimi f1000afc27 Delete ProxMenux-1.0.0.AppImage 2025-11-14 17:06:58 +01:00
github-actions[bot] 5fc2a82423 Update AppImage build (2025-11-14 16:02:55) 2025-11-14 16:02:55 +00:00
MacRimi a27f884418 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-14 16:59:35 +01:00
MacRimi cae4b73226 Update AppImage 2025-11-14 16:59:18 +01:00
MacRimiandGitHub 50ed293de2 Merge pull request #76 from c78-contrib/main
Proxmenux offline mode
2025-11-14 16:56:04 +01:00
cod378 616b772a45 chore: remove test installer script
- Delete install_proxmenux_test.sh (1048 lines)
- Test installer no longer needed after validation
2025-11-14 02:40:48 +00:00
cod378 c1d00e21db fix: suppress systemctl output in ProxMenux Monitor uninstaller
- Redirect stdout and stderr to /dev/null for stop, disable, daemon-reload, and reset-failed commands
- Maintain clean console output during uninstallation process
2025-11-14 02:37:43 +00:00
cod378 2e8e2b61d3 fix: use MONITOR_SERVICE constant instead of MONITOR_SERVICE_NAME in uninstall function 2025-11-14 02:30:19 +00:00
cod378 ba595c9719 feat: add test installer script with offline support and ProxMenux Monitor uninstaller 2025-11-14 02:21:00 +00:00
cod378 e392f6a2b7 feat: add cod378 to contributors list in config_menu.sh 2025-11-14 02:20:07 +00:00
cod378andGitHub 7457e71776 Merge branch 'MacRimi:main' into main 2025-11-13 23:08:13 -03:00
cod378 982d0dd72e Merge branch 'main' of github.com:c78-contrib/ProxMenuxOffline 2025-11-14 01:49:58 +00:00
cod378 d345f96518 feat: add ProxMenux Monitor uninstallation to config menu
- Add uninstall_proxmenux_monitor() function with systemd service cleanup
- Stop and disable monitor service if active
- Remove systemd unit file and reload daemon
- Integrate monitor uninstallation into main uninstall_proxmenu() workflow
- Define MONITOR_UNIT_FILE constant for service file path
2025-11-14 01:49:39 +00:00
ProxMenuxBot 469874e975 Update helpers_cache.json 2025-11-14 01:37:35 +00:00
ProxMenuxBot 6ba817cd43 Update helpers_cache.json 2025-11-14 01:03:21 +00:00
MacRimi 42f2e69e3a Update AppImagen 2025-11-13 21:12:32 +01:00
MacRimi 12442b4bd3 Update settings.tsx 2025-11-13 20:59:36 +01:00
MacRimi 305d37a13b Update settings.tsx 2025-11-13 20:43:13 +01:00
MacRimi 4baf60174f Update settings.tsx 2025-11-13 20:36:35 +01:00
MacRimi 8cd1ac6a4b Update settings.tsx 2025-11-13 20:29:22 +01:00
MacRimi c65fef638e Update settings.tsx 2025-11-13 20:23:08 +01:00
MacRimi a030cd7e28 Update flask_auth_routes.py 2025-11-13 20:16:39 +01:00
MacRimi 59a3b7eac5 Update settings.tsx 2025-11-13 20:10:00 +01:00
MacRimi 2faac48adf Update settings.tsx 2025-11-13 20:01:08 +01:00
MacRimi 3883039764 Update AppImage 2025-11-13 19:51:42 +01:00
MacRimi 307ed0c637 Update AppImage 2025-11-13 19:43:17 +01:00
MacRimi 96ffdb65d0 Update README.md 2025-11-13 19:36:45 +01:00
MacRimi 5ca55798b2 Update README.md 2025-11-13 19:20:06 +01:00
MacRimi cd32e11c6d Update AppImage 2025-11-13 19:11:56 +01:00
MacRimi 774cbe4c9d Update AppImage 2025-11-13 18:32:44 +01:00
MacRimi 1d0bb20506 Update AppImage 2025-11-13 18:21:37 +01:00
MacRimi 8064e107f4 Update AppImage 2025-11-13 17:56:42 +01:00
MacRimi c1d1121ed1 Update AppImage 2025-11-13 17:46:07 +01:00
MacRimi 07603f11db Update api-config.ts 2025-11-13 17:25:51 +01:00
MacRimi ec22c857d5 Update api-config.ts 2025-11-13 17:20:31 +01:00
MacRimi 364e808261 Update api-config.ts 2025-11-13 17:14:47 +01:00
MacRimi 1d47ad0c4b Update AppImage 2025-11-13 16:58:45 +01:00
cod378 c9d0eac6cc Merge branch 'main' of github.com:c78-contrib/ProxMenuxOffline 2025-11-13 03:23:32 +00:00
cod378 97fc72b78a fix: add validation for missing ProxMenux Monitor AppImage
- Check if AppImage exists before attempting installation
- Display clear error message when AppImage is not found
- Update config to track installation failure state
2025-11-13 03:23:11 +00:00
cod378 7b1111430b chore: remove unused offline installer script 2025-11-13 03:22:52 +00:00
cod378andGitHub 4f3306cd0f Merge branch 'MacRimi:main' into main 2025-11-12 23:56:22 -03:00
cod378 d3f7056ece Merge branch 'main' of github.com:c78-contrib/ProxMenuxOffline 2025-11-13 02:52:26 +00:00
cod378 9f3286c570 feat: migrate to offline installer with enhanced monitor deployment
- Restructured installer to use local repository files instead of remote downloads for improved reliability
- Added comprehensive logging functions (spinner, type_text, msg_* helpers) and dual logo support for SSH/noVNC terminals
- Implemented AppImage version detection, SHA256 verification, and systemd service management for ProxMenux Monitor
- Updated metadata to reflect toolkit positioning and added contributor attribution
2025-11-13 02:50:41 +00:00
ProxMenuxBot 16fc737b2d Update helpers_cache.json 2025-11-12 18:29:07 +00:00
ProxMenuxBot 3e0ae709d9 Update helpers_cache.json 2025-11-12 18:19:51 +00:00
ProxMenuxBot 39ddb7c8f9 Update helpers_cache.json 2025-11-12 12:41:44 +00:00
ProxMenuxBot 3c509ce0e4 Update helpers_cache.json 2025-11-12 12:38:40 +00:00
cod378 048cf2fb8f docs: update project name references from ProxMenuxDotDeb to ProxMenuxOffline 2025-11-12 05:21:33 +00:00
cod378 0a20821c41 refactor: remove verbose cleanup messages from temporary file removal 2025-11-12 05:00:06 +00:00
cod378 e0eaf6267f fix: suppress git clone output to reduce installation noise 2025-11-12 04:53:37 +00:00
cod378 3ddf98277f refactor: update utils script source URL to offline repository 2025-11-12 04:48:29 +00:00
cod378 85294bcd33 fix: correct utils.sh download URL format 2025-11-12 04:40:10 +00:00
cod378 acff4523f3 refactor: simplify utils.sh loading with inline sourcing
- Replaced conditional file check with direct curl sourcing using process substitution
- Streamlined error handling to single-line check
2025-11-12 04:29:40 +00:00
cod378 bf71e1f9b8 refactor: update comment for utils.sh loading 2025-11-12 04:23:43 +00:00
cod378 f0bcdc1c25 refactor: move utils.sh loading to script initialization because this is an installer dependency 2025-11-12 04:22:11 +00:00
cod378 43526c58bd refactor: reorganize installer to use git-based offline installation
- Changed from local script loading to cloning repository into temporary directory
- Added cleanup function with trap to ensure temporary files are removed on exit
- Added git as a required dependency for the installation process
2025-11-12 04:11:41 +00:00
cod378 ce3c7a545e feat: add GitHub authentication script to gitignore 2025-11-12 04:04:10 +00:00
cod378andGitHub 9498e4e7eb Merge branch 'MacRimi:main' into main 2025-11-11 23:39:28 -03:00
ProxMenuxBot 4ec7c207f4 Update helpers_cache.json 2025-11-12 01:29:08 +00:00
ProxMenuxBot 000479463f Update helpers_cache.json 2025-11-12 01:04:12 +00:00
MacRimi 6b2065e43c Update AppImage 2025-11-11 22:00:44 +01:00
MacRimi e97e1363ae Update release-notes-modal.tsx 2025-11-11 21:37:39 +01:00
MacRimi 697a1f8e31 Update release-notes-modal.tsx 2025-11-11 21:30:57 +01:00
MacRimi 035f43311a Update release-notes-modal.tsx 2025-11-11 21:25:10 +01:00
MacRimi c597f1252e Update release-notes-modal.tsx 2025-11-11 21:12:09 +01:00
MacRimi cc1e7a715c Update settings.tsx 2025-11-11 19:48:02 +01:00
MacRimi 80057e3014 Update AppImage 2025-11-11 19:36:37 +01:00
MacRimi 79ffba873f Update AppImage 2025-11-11 19:20:59 +01:00
MacRimi 673e1cf212 Update virtual-machines.tsx 2025-11-11 18:21:30 +01:00
MacRimi 7e878ecff2 Update virtual-machines.tsx 2025-11-11 18:07:03 +01:00
MacRimi 88cf51a602 Update AppImage 2025-11-11 17:59:36 +01:00
MacRimi 1860fffe07 Update system-logs.tsx 2025-11-11 17:26:47 +01:00
MacRimi fa925543db Update AppImage 2025-11-11 17:12:56 +01:00
MacRimi 825e99c59b Update api-config.ts 2025-11-11 17:04:26 +01:00
MacRimi 955bed80fb Update AppImage 2025-11-11 17:01:25 +01:00
ProxMenuxBot 03b9ac3ec4 Update helpers_cache.json 2025-11-11 12:40:51 +00:00
ProxMenuxBot c255d9a5d8 Update helpers_cache.json 2025-11-11 12:27:59 +00:00
ProxMenuxBot 401d973a51 Update helpers_cache.json 2025-11-11 06:31:46 +00:00
ProxMenuxBot a507d559e1 Update helpers_cache.json 2025-11-11 01:29:31 +00:00
MacRimi 9225982ca5 Create ProxMenux-1.0.1-beta2.AppImage 2025-11-10 19:03:30 +01:00
MacRimi 6f831530cc Update system-logs.tsx 2025-11-10 18:38:33 +01:00
MacRimi e6b4443074 Update virtual-machines.tsx 2025-11-10 18:22:44 +01:00
MacRimi 1c800cbd8f Update storage-overview.tsx 2025-11-10 17:45:40 +01:00
MacRimi a65924799e Update storage-overview.tsx 2025-11-10 17:38:46 +01:00
MacRimi adbfa1e73e Update AppImge 2025-11-10 17:25:22 +01:00
cod378andGitHub 44a4226ad2 Merge branch 'MacRimi:main' into main 2025-11-10 11:04:47 -03:00
ProxMenuxBot 07ca3f13a0 Update helpers_cache.json 2025-11-10 12:41:30 +00:00
ProxMenuxBot 87a052b89c Update helpers_cache.json 2025-11-10 12:27:40 +00:00
MacRimi 2216543ac3 Update storage-overview.tsx 2025-11-09 23:59:21 +01:00
MacRimi 4254d57d12 Update storage-overview.tsx 2025-11-09 23:46:32 +01:00
MacRimi 30d93898d8 Update storage-overview.tsx 2025-11-09 23:36:50 +01:00
MacRimi 4c7ed2c2c5 Update health_monitor.py 2025-11-09 21:50:10 +01:00
MacRimi 4fb327cef8 Create ProxMenux-1.0.1-beat1.AppImage 2025-11-09 21:37:01 +01:00
MacRimi 588af3613b Update AppImage 2025-11-09 21:20:39 +01:00
MacRimi 5b5f325a4e Update health_monitor.py 2025-11-09 21:03:00 +01:00
MacRimi ae62196dff Update AppImage 2025-11-09 20:52:39 +01:00
MacRimi 27e66ee770 Update health_monitor.py 2025-11-09 20:02:38 +01:00
MacRimi 8fb8134898 Update AppImage 2025-11-09 18:23:27 +01:00
MacRimi a59489f804 Update health_persistence.py 2025-11-09 18:11:55 +01:00
MacRimi cbf3938784 Update AppImage 2025-11-09 18:05:35 +01:00
MacRimi c45ebfe598 Update AppImage 2025-11-09 17:56:37 +01:00
MacRimi a75aad1fdc Update build_appimage.sh 2025-11-09 17:34:11 +01:00
MacRimi a0635a1026 Update AppImage 2025-11-09 17:28:20 +01:00
MacRimi 27353e160f Update AppImage 2025-11-09 16:43:45 +01:00
MacRimi b9619efbbf Update AppImage 2025-11-09 16:30:29 +01:00
MacRimi 1712d32ef7 Update AppImage 2025-11-09 15:44:35 +01:00
MacRimi 014deb2118 Update flask_server.py 2025-11-09 15:35:01 +01:00
MacRimi 6077cf81f2 Update system-overview.tsx 2025-11-09 15:12:32 +01:00
MacRimi 0422c38096 Update system-overview.tsx 2025-11-09 14:48:51 +01:00
MacRimi 8c902ae04d Update system-overview.tsx 2025-11-09 14:39:06 +01:00
MacRimi 0a0b916067 Update system-overview.tsx 2025-11-09 14:34:32 +01:00
MacRimi 6822635a0b Update flask_server.py 2025-11-09 13:55:09 +01:00
MacRimi f9b15fd110 Update node-metrics-charts.tsx 2025-11-09 13:31:16 +01:00
MacRimi 131a458e69 Update node-metrics-charts.tsx 2025-11-09 13:20:30 +01:00
MacRimi 7260807d78 Create use-mobile.tsx 2025-11-09 13:12:51 +01:00
MacRimi df83d8a3e5 Update node-metrics-charts.tsx 2025-11-09 13:07:42 +01:00
MacRimi 0f45424458 Update virtual-machines.tsx 2025-11-09 12:52:10 +01:00
MacRimi 60f92d019b Update virtual-machines.tsx 2025-11-09 12:26:55 +01:00
ProxMenuxBot 2189487982 Update helpers_cache.json 2025-11-08 06:27:04 +00:00
cod378andGitHub 3a44997795 Merge branch 'MacRimi:main' into main 2025-11-07 21:53:57 -03:00
MacRimi 1f04134aac Update Appimagen 2025-11-07 21:14:56 +01:00
MacRimi ce44538240 Update AppImage 2025-11-07 21:07:33 +01:00
MacRimi 5fd53883be Update two-factor-setup.tsx 2025-11-07 21:02:20 +01:00
MacRimi f064cc89ba Update AppImage 2025-11-07 20:55:00 +01:00
MacRimi 5dd8b3ee36 Update AppImage 2025-11-07 20:36:46 +01:00
MacRimi f2f9c37ee2 Update ppImage 2025-11-07 20:19:55 +01:00
MacRimi 6836777629 Update AppImage 2025-11-07 20:05:29 +01:00
MacRimi beefdd280f Update proxmox-dashboard.tsx 2025-11-07 19:52:45 +01:00
MacRimi 4b9ad0da7a Update AppImage 2025-11-07 19:43:55 +01:00
ProxMenuxBot f9fdd1686c Update helpers_cache.json 2025-11-07 18:28:17 +00:00
MacRimi 25fc3d931e Update AppImage 2025-11-07 19:25:36 +01:00
MacRimi fc7d0f2cd5 Update AppImage 2025-11-07 18:49:37 +01:00
MacRimi 60c91d9fe4 Update AppImage 2025-11-07 17:35:45 +01:00
MacRimi cc2d6849a8 Update AppImage 2025-11-07 17:00:32 +01:00
MacRimi 4a5379ea42 Update flask_server.py 2025-11-07 15:36:51 +01:00
MacRimi ba84c644df Update flask_server.py 2025-11-07 14:33:27 +01:00
MacRimi 37217b4219 Update AppImage 2025-11-07 14:14:43 +01:00
MacRimi 41dab03a5f Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-07 13:42:03 +01:00
MacRimi 6e48bf2a71 Update AppImage 2025-11-07 13:41:39 +01:00
ProxMenuxBot 1c1c6f513c Update helpers_cache.json 2025-11-07 12:40:39 +00:00
ProxMenuxBot 49c54f5593 Update helpers_cache.json 2025-11-07 12:27:33 +00:00
MacRimi d083e49d0b Update proxmox-dashboard.tsx 2025-11-07 13:06:47 +01:00
MacRimi 8dc2b833f4 Update AppImage 2025-11-07 12:54:10 +01:00
MacRimi 7d5726be50 Update proxmox-dashboard.tsx 2025-11-07 12:43:31 +01:00
MacRimi 246c1674d1 Uppdate AppImage 2025-11-07 12:37:11 +01:00
MacRimi 06b81f2b64 Update AppImage 2025-11-07 12:21:37 +01:00
MacRimi ee57797890 Updete AppImage 2025-11-07 12:17:10 +01:00
MacRimi a94000e114 Update AppImage 2025-11-07 11:05:57 +01:00
MacRimi e6655b35f3 Update AppImage 2025-11-07 10:58:50 +01:00
MacRimiandGitHub 696ffde184 Update issue template contact link description to English 2025-11-07 10:16:17 +01:00
MacRimiandGitHub 9e74e99923 Update feature_request.md 2025-11-07 10:15:09 +01:00
MacRimiandGitHub bc5c6dadfb Translate bug report template to English 2025-11-07 10:14:37 +01:00
MacRimiandGitHub 0d173a0bfe Update ISO file name for XigmaNAS version 14.3.0.5 2025-11-06 12:10:12 +01:00
MacRimiandGitHub cd78920edd Fix ISO URL for XigmaNAS version 14.3.0.5 2025-11-06 09:08:09 +01:00
MacRimiandGitHub 9a7ec62cf9 Update select_nas_iso.sh 2025-11-06 09:06:52 +01:00
cod378andGitHub 2b4580cfe8 Merge branch 'MacRimi:main' into main 2025-11-05 23:24:21 -03:00
MacRimiandGitHub b790c06294 Merge pull request #62 from MrCaringi/main
Add Issue Templates and Configuration for Categorization
2025-11-05 21:21:33 +01:00
JFCandGitHub 61d87b46d9 Create feature request issue template
Adds a feature request template for GitHub issues.
2025-11-05 12:37:45 -06:00
JFCandGitHub 143cb4cbab Modify bug report template and assign to MacRimi
Updated bug report template to include mandatory screenshots and assigned to 'MacRimi'.
2025-11-05 12:36:50 -06:00
JFCandGitHub 22709dac36 Add issue template configuration for GitHub 2025-11-05 12:35:11 -06:00
ProxMenuxBot d97be93449 Update helpers_cache.json 2025-11-05 18:29:25 +00:00
ProxMenuxBot 5864de7dea Update helpers_cache.json 2025-11-05 18:19:51 +00:00
MacRimi 4ea5890e92 Update health-status-modal.tsx 2025-11-05 18:46:19 +01:00
MacRimi 876d51b009 Update health-status-modal.tsx 2025-11-05 18:38:29 +01:00
MacRimi 5b0d55c1a2 Update health_monitor.py 2025-11-05 18:30:31 +01:00
cod378 3ddb1421c3 feat: add offline installer script for ProxMenux
- Clones ProxMenux repository to temporary location and executes installation
- Includes automatic cleanup of temporary files and git dependency check
- Adds colored output and error handling for better user experience
2025-11-04 22:47:52 +00:00
cod378 58f9a7bc02 refactor: simplify utils.sh loading error handling 2025-11-04 22:47:00 +00:00
MacRimi e8e4b728ce Update proxmox-dashboard.tsx 2025-11-04 23:00:37 +01:00
MacRimi 0a4868192d Update proxmox-dashboard.tsx 2025-11-04 22:55:41 +01:00
MacRimi 9d81ffffe8 Update proxmox-dashboard.tsx 2025-11-04 22:47:11 +01:00
MacRimi e6fe4a09e5 Update AppImage 2025-11-04 22:28:42 +01:00
MacRimi 77c5ad7b09 Update AppImage 2025-11-04 21:59:28 +01:00
MacRimi b850e9615a Update proxmox-dashboard.tsx 2025-11-04 21:48:54 +01:00
MacRimi c2ea307821 Update AppImage 2025-11-04 21:42:38 +01:00
MacRimi fb588c0d60 Update flask_auth_routes.py 2025-11-04 21:36:31 +01:00
MacRimi fecbdf6190 Update build_appimage.sh 2025-11-04 21:32:14 +01:00
MacRimi bbbbf6892f Update flask_server.py 2025-11-04 21:27:29 +01:00
MacRimi e1a11053a6 Update flask_server.py 2025-11-04 21:16:16 +01:00
MacRimi f0a62191ea Updae AppImage 2025-11-04 21:02:56 +01:00
MacRimi a8311923fb Update AppImage 2025-11-04 19:58:09 +01:00
MacRimi cd1d88760d Update proxmox-dashboard.tsx 2025-11-04 19:39:50 +01:00
MacRimi 004949d3a0 Update proxmox-dashboard.tsx 2025-11-04 19:21:31 +01:00
MacRimi f6d26042da Update AppImage 2025-11-04 19:13:47 +01:00
MacRimi 270a73a470 Update auth-setup.tsx 2025-11-04 18:48:27 +01:00
MacRimi 018e80e59d Update AppImage 2025-11-04 18:45:54 +01:00
MacRimi cb5cb1e594 Create checkbox.tsx 2025-11-04 18:11:42 +01:00
MacRimi 6c5eb156a1 Update AppImage 2025-11-04 18:07:13 +01:00
MacRimi 8abef33840 Update build_appimage.sh 2025-11-04 17:37:32 +01:00
MacRimi 1d6b8951e8 Update hardware.tsx 2025-11-04 15:28:27 +01:00
MacRimi 711d57d91f Update flask_server.py 2025-11-04 15:09:23 +01:00
MacRimi 65fd847251 Update flask_server.py 2025-11-04 14:24:34 +01:00
MacRimi 73a170a5f1 Update hardware.tsx 2025-11-04 14:00:01 +01:00
MacRimi 9a32d1c0f7 Update flask_server.py 2025-11-04 13:47:00 +01:00
MacRimi 59918032c6 Update hardware.tsx 2025-11-04 13:18:39 +01:00
MacRimi 55394cbf09 Update AppImage 2025-11-04 12:47:26 +01:00
MacRimi 83dcc0c4f2 Update storage-overview.tsx 2025-11-04 12:17:32 +01:00
MacRimi b4b93f0572 Update AppImage 2025-11-04 12:11:08 +01:00
MacRimi ab0e59215c Aupdate version ProxMenux Monitor 2025-11-04 11:34:46 +01:00
MacRimi 5669ce207c Update flask_server.py 2025-11-04 11:03:09 +01:00
MacRimi 37f6cd96a4 Update flask_server.py 2025-11-04 09:46:11 +01:00
MacRimi c0ec74fb12 Update AppImage 2025-11-04 09:14:29 +01:00
cod378andGitHub 226dc45190 Merge branch 'MacRimi:main' into main 2025-11-03 21:24:30 -03:00
MacRimi 11e3f53a2f Update AppImage 2025-11-03 23:26:04 +01:00
MacRimi 31d7f7e3e9 Update appImage 2025-11-03 23:17:27 +01:00
MacRimi 128edc08e2 Update flask_server.py 2025-11-03 23:13:24 +01:00
MacRimi 5158c5f359 Update flask_server.py 2025-11-03 19:12:07 +01:00
MacRimi a70b33ce13 Update AppImage 2025-11-03 19:02:41 +01:00
MacRimi d787c3caa0 Update AppImage 2025-11-03 18:35:16 +01:00
MacRimi a554af939e Create build-appimage.yml 2025-11-03 18:24:43 +01:00
MacRimiandGitHub 06604ff0d1 Add manual build workflow for AppImage 2025-11-03 18:15:17 +01:00
MacRimi 9490f79c6d Update build-appimage.yml 2025-11-03 18:14:13 +01:00
MacRimi 311a624698 Update customizable_post_install.sh 2025-11-03 18:08:28 +01:00
cod378andGitHub 3e2e77f9fb Merge branch 'MacRimi:main' into main 2025-11-03 10:15:41 -03:00
cod378 b2e02cd0e7 refactor: switch from remote URL to local script execution 2025-11-03 12:32:19 +00:00
ProxMenuxBot 87ead71766 Update helpers_cache.json 2025-11-03 12:28:15 +00:00
cod378 b8517a5b3e Merge branch 'ProxMenux-Offline' 2025-11-03 03:54:10 +00:00
cod378 c29cdf44fb refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 02:12:25 +00:00
cod378 4b2ab2894a refactor: switch from remote URLs to local script execution 2025-11-03 02:11:15 +00:00
cod378 c9a01ab5ad refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 02:10:46 +00:00
cod378 90d1046312 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 02:10:18 +00:00
cod378 14e749a18d refactor: switch from remote URLs to local script execution 2025-11-03 02:09:56 +00:00
cod378 a4be1af0ef refactor: switch from remote URLs to local script execution 2025-11-03 02:08:36 +00:00
cod378 f4185d0a2a refactor: switch from remote URLs to local script execution 2025-11-03 02:07:20 +00:00
cod378 ffb8324b5a refactor: switch from remote URLs to local script execution 2025-11-03 02:06:43 +00:00
cod378 6df44f1632 refactor: switch from remote URLs to local script execution 2025-11-03 02:03:26 +00:00
cod378 9570819f59 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:56:34 +00:00
cod378 f2afc94ed2 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:56:23 +00:00
cod378 050b95946c refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:55:45 +00:00
cod378 e33ef92334 refactor: switch from remote URLs to local script execution 2025-11-03 01:54:52 +00:00
cod378 0d7ff46aec refactor: switch from remote URLs to local script execution 2025-11-03 01:53:10 +00:00
cod378 042913e080 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:51:57 +00:00
cod378 98bc8be642 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:51:43 +00:00
cod378 2c6d2f4255 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:51:24 +00:00
cod378 6293556837 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:51:06 +00:00
cod378 641721d199 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:48:35 +00:00
cod378 036a2b9014 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:47:52 +00:00
cod378 9ad092d340 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:47:17 +00:00
cod378 d24884f651 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:46:50 +00:00
cod378 fa1c498716 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:46:19 +00:00
cod378 25635239d4 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:45:43 +00:00
cod378 c816688de3 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:44:48 +00:00
cod378 43d79bd1e9 refactor: switch from remote URLs to local script execution (unused path) 2025-11-03 01:44:06 +00:00
cod378 ba88c7b0f6 refactor: switch from remote URLs to local script execution 2025-11-03 01:41:39 +00:00
cod378 4359d92ffe refactor: switch from remote URLs to local script execution 2025-11-03 01:40:38 +00:00
cod378 16c7513e82 refactor: switch from remote URLs to local script execution 2025-11-03 01:39:15 +00:00
cod378 4572478ad8 refactor: switch from remote URLs to local script execution 2025-11-03 01:38:05 +00:00
cod378 02b5cd61bd refactor: switch from remote URLs to local script execution 2025-11-03 01:36:57 +00:00
cod378 bff07311b2 refactor: switch from remote URLs to local script execution 2025-11-03 01:34:44 +00:00
cod378 44cc89b9d5 refactor: switch from remote URLs to local file paths 2025-11-03 01:33:04 +00:00
cod378 fa1e6c6c64 refactor: switch from remote URLs to local script execution 2025-11-03 01:27:37 +00:00
cod378 4ebbdb284b refactor: switch from remote URLs to local script execution 2025-11-03 01:26:28 +00:00
cod378 51302a7c5a refactor: switch from remote URLs to local script execution 2025-11-03 01:25:19 +00:00
cod378 ba984592ed refactor: replace remote script fetching with local file execution 2025-11-03 01:22:24 +00:00
cod378 60a97e5815 feat: replace remote script fetching with local file execution 2025-11-03 01:20:11 +00:00
cod378 3275a1ecb4 refactor: replace remote script loading with local paths 2025-11-03 01:17:21 +00:00
cod378 af72c7a2d3 refactor: switch from remote URLs to local script paths 2025-11-03 01:16:32 +00:00
cod378 c07ada1fc4 refactor: switch from remote to local script execution 2025-11-03 01:14:54 +00:00
cod378 c19c8f9c5d refactor: switch network menu from remote URL to local scripts 2025-11-03 01:10:44 +00:00
cod378 43fe7ae7db refactor: replace remote script fetching with local file execution 2025-11-03 01:09:12 +00:00
cod378 22916868df feat: switch script paths from remote repo to local directory 2025-11-03 01:06:04 +00:00
cod378 7d00ff8869 feat: switch menu scripts from remote URLs to local paths 2025-11-03 01:03:39 +00:00
cod378 4ea2088485 refactor: replace remote script loading with local file execution
- Changed script loading from curl-based remote fetching to local file execution for improved security and reliability
- Removed dependency on external repository access for core menu functionality
- Fixed missing semicolon in case statement default branch
2025-11-03 00:59:48 +00:00
cod378 e421b40093 refactor: switch from remote scripts to local execution 2025-11-03 00:50:18 +00:00
cod378 a9dd7562ac feat: switch from remote URLs to local script paths
- Changed script sourcing from GitHub URLs to local filesystem paths for improved reliability
- Added error handling for missing script files with descriptive messages
- Removed redundant utility file sourcing and consolidated into single conditional block
- Updated script execution to use direct paths instead of curl commands
- Removed unused start_vm_configuration function that was duplicated elsewhere
2025-11-03 00:46:23 +00:00
cod378 8f62ed67d3 refactor: switch from remote URL to local script paths 2025-11-03 00:22:59 +00:00
cod378 cfd89a14f7 refactor: update script paths to use local resources 2025-11-03 00:16:28 +00:00
cod378 55011842f5 refactor: update script paths to use local resources 2025-11-03 00:15:42 +00:00
cod378 3079a3f51c refactor: update script paths to use local resources 2025-11-03 00:15:08 +00:00
cod378 c4ec390ca0 refactor: update scripts paths to use local references 2025-11-03 00:13:19 +00:00
cod378 f99b7f3589 refactor: switch script to use local paths instead of remote URLs 2025-11-03 00:07:34 +00:00
cod378 887b170c0e refactor: switch script to use local paths instead of remote URLs 2025-11-03 00:06:12 +00:00
cod378 c696cfd8d8 refactor: switch update script to use local file paths 2025-11-03 00:04:47 +00:00
cod378 25966973a2 refactor: update script paths to use local resources 2025-11-03 00:03:21 +00:00
cod378 d0a57d4b7c refactor: update script paths to use local resources 2025-11-03 00:01:36 +00:00
cod378 9341b49fd1 refactor: update script paths to use local references 2025-11-03 00:00:52 +00:00
cod378 bbc3c922a6 refactor: switch from remote repo to local script paths 2025-11-02 23:58:48 +00:00
cod378 17b8d63e6c refactor: switch from remote URLs to local script paths 2025-11-02 23:55:37 +00:00
cod378 c751a8168a refactor: switch backup script from remote URL to local file paths 2025-11-02 23:36:13 +00:00
cod378 f2509dbe5d refactor: switch from remote to local script loading. 2025-11-02 23:26:45 +00:00
cod378 6d44c22982 refactor: switch from remote to local script loading. 2025-11-02 23:25:04 +00:00
cod378 4bed489610 refactor: switch backup scripts from remote URL to local paths 2025-11-02 23:08:34 +00:00
cod378 8edf488636 refactor: update script paths to use local references 2025-11-02 03:32:41 +00:00
cod378 8fe7d249f8 refactor: update script paths to use local resources 2025-11-02 03:32:02 +00:00
cod378 6ed14e1d3c feat: switch from remote to local script loading 2025-11-02 03:29:56 +00:00
cod378 a5459acdaf feat: switch help menu from remote to local script loading 2025-11-02 03:28:14 +00:00
cod378 61cd198d35 feat: switch disk passthrough script to use local scripts 2025-11-02 03:27:18 +00:00
cod378 49ea2b304d feat: switch disk passthrough script to use local scripts 2025-11-02 03:26:27 +00:00
cod378 27231d1764 feat: switch iGPU configuration to use local scripts 2025-11-02 03:25:41 +00:00
cod378 8744620220 feat: improve log2ram installation and system checks
- Enhanced log2ram size calculation to support both MB and GB configurations
- Updated RAM detection to use MB-level precision before converting to GB
- Fixed typos in status messages ("successfull" → "successful")
- Switched from remote repo URL to local scripts directory for better reliability
- Added registration of LVM repair tool after successful header checks
- Improved log2ram monitoring script to properly handle different size units (M
2025-11-02 03:24:26 +00:00
cod378 4590be6d42 fix: replace remote script loading with local file execution 2025-11-02 03:22:54 +00:00
cod378 fa93b43c32 feat: switch telegram notifier to use local scripts
- Changed script source from GitHub repository to local directory (/usr/local/share/proxmenux/scripts)
- Updated path configuration to ensure consistent local file access
- Removed dependency on external repository for improved reliability and security
2025-11-02 02:59:33 +00:00
cod378 3c47f84a24 refactor: switch from remote repo to local scripts path
- Changed repository URL reference from GitHub to local scripts directory (/usr/local/share/proxmenux/scripts)
- Fixed spacing in info2 message formatting by adding space after HOLD variable
- Simplified script dependencies to use local installation instead of remote fetching
2025-11-02 02:55:19 +00:00
cod378 8a371c26de refactor: switch from remote to local script execution
- Changed script loading from remote URL to local directory path for offline usage
- Updated REPO_URL to LOCAL_SCRIPTS path (/usr/local/share/proxmenux/scripts)
- Disabled check_updates function since it's not applicable for local version
- Added comments explaining update functionality will be handled via .deb package in future
2025-11-02 02:51:24 +00:00
ProxMenuxBot 088a594468 Update helpers_cache.json 2025-11-02 01:06:57 +00:00
MacRimi c551913551 Update uninstall-tools.sh 2025-11-02 00:53:04 +01:00
code78 05e81053e0 feat: switch to local file installation and improve monitor setup
- Replaced remote file downloads with local file copying for more reliable installation
- Added proper cleanup of existing monitor service before reinstallation
- Enhanced error handling and logging for monitor service startup
- Improved SHA256 verification for monitor AppImage
- Added copying of install script and all utility scripts to base directory
- Updated progress messages to be more descriptive and accurate
- Increased monitor
2025-11-01 23:47:45 +00:00
MacRimi 981c0ab980 Update remove-banner-pve-v3.sh 2025-11-02 00:42:22 +01:00
MacRimi 1f083b335f Update remove banner v3 2025-11-02 00:37:59 +01:00
code78 10603900df update: Progress status 2025-11-01 23:33:25 +00:00
MacRimi c22e36d219 Update script PVE 9 2025-11-01 17:22:51 +01:00
MacRimi 26fc2ae9db Update install_proxmenux.sh 2025-11-01 17:09:38 +01:00
code78 2a0b298ae5 feat: add comprehensive project documentation and analysis
- Created detailed documentation covering ProxMenux project structure, installation flow, and core components
- Added in-depth analysis of script architecture, execution patterns, and key functionalities
- Documented system configuration, translation mechanism, and component interactions
- Included detailed breakdown of file organization, menu system, and installation processes
- Added technical specifications for ProxMenux Monitor web dashboar
2025-11-01 03:01:51 +00:00
ProxMenuxBot 96f0a9bc5d Update helpers_cache.json 2025-11-01 01:06:00 +00:00
MacRimi 5054e78864 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-11-01 00:11:05 +01:00
MacRimi f1fa6b03d5 change license CC-BY-NC-4.0 2025-11-01 00:10:46 +01:00
MacRimiandGitHub 8f15bf9668 change license to CC-BY-NC-4.0 2025-10-31 23:48:46 +01:00
MacRimi 4b8e7b19a3 change licente to CC-BY-NC-4.0 2025-10-31 23:47:17 +01:00
MacRimi 67bba1dd09 Update version.txt 2025-10-31 23:38:45 +01:00
MacRimi b826dec79d Update CHANGELOG.md 2025-10-31 23:34:21 +01:00
MacRimi 62b200c5d9 Update CHANGELOG.md 2025-10-31 23:27:11 +01:00
MacRimi c2ed772f34 Update coral TPU pve9 2025-10-31 22:55:13 +01:00
MacRimi bbce6d4ad0 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-31 22:45:01 +01:00
MacRimi 45f6a0ec02 Update Install ProxMenux 2025-10-31 22:44:59 +01:00
github-actions[bot] 6b681278e0 Update AppImage build (2025-10-31 20:13:11) 2025-10-31 20:13:11 +00:00
MacRimiandGitHub 2281ff06c7 Update GitHub Actions workflow permissions
Added permissions for write access to contents.
2025-10-31 21:10:47 +01:00
MacRimiandGitHub 572a81fd4e Enhance workflow to include SHA256 checksum generation
Added steps to generate SHA256 checksum and upload AppImage.
2025-10-31 21:04:28 +01:00
MacRimi 5fd7df69fd Update update-pve9_2.sh 2025-10-31 20:47:46 +01:00
MacRimi 3401c6305e Update remove-banner-pve-v3.sh 2025-10-31 20:25:55 +01:00
MacRimi 269f9ac52c Update update-pve9_2.sh 2025-10-31 20:20:24 +01:00
MacRimi 4712171d43 Updsate Post Install 2025-10-31 20:16:42 +01:00
MacRimi 632c7b91f4 Update proxmox_update.sh 2025-10-31 20:09:52 +01:00
MacRimi f72dd79dff Update remove-banner-pve-v3.sh 2025-10-31 20:07:57 +01:00
MacRimi 4d109c0481 Update Post Install 2025-10-31 20:03:34 +01:00
MacRimi c046b77223 Update auto_post_install.sh 2025-10-31 18:44:30 +01:00
MacRimi 56ba3b5e5f Update auto_post_install.sh 2025-10-31 18:39:11 +01:00
MacRimi fa99247cb7 Update update-pve.sh 2025-10-31 18:35:49 +01:00
MacRimi 653fd37c08 Update update-pve.sh 2025-10-31 18:31:02 +01:00
MacRimi 9d053beafb Update Post Install menu 2025-10-31 18:28:11 +01:00
MacRimi f33c451a19 Update Post Install 2025-10-31 17:57:09 +01:00
MacRimi 9543148887 remove subscription banner V3 2025-10-31 17:49:28 +01:00
MacRimi 029ee4ed2f Update onboarding-carousel.tsx 2025-10-31 17:02:09 +01:00
MacRimi 688e826e9d Update onboarding-carousel.tsx 2025-10-30 23:30:58 +01:00
MacRimi deea0c54d4 Updarte AppImage 2025-10-30 23:14:00 +01:00
MacRimi 028f62aa9c Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-30 21:08:31 +01:00
MacRimi d3aef9c1d1 Update AppImage 2025-10-30 21:08:08 +01:00
ProxMenuxBot 72edce511a Update helpers_cache.json 2025-10-30 12:27:00 +00:00
MacRimi 37fade8f7a Update AppImage 2025-10-29 22:16:40 +01:00
MacRimi 0d2aa6738c Create ProxMenux-rc2.AppImage 2025-10-29 21:39:35 +01:00
MacRimi e3e0e5cba8 Create Heriberto.AppImage 2025-10-29 21:33:09 +01:00
MacRimi e40de189c3 Update flask_server.py 2025-10-29 21:28:23 +01:00
MacRimi c9c99f7b2a Create ProxMenux-rc.AppImage 2025-10-29 21:07:28 +01:00
MacRimi e6400918c8 Update AppImage 2025-10-29 20:57:06 +01:00
MacRimi d08557ad0e Update flask_server.py 2025-10-29 20:42:11 +01:00
MacRimi ba84dce7a7 Update build_appimage.sh 2025-10-29 20:29:43 +01:00
MacRimi 7044725bf1 Update AppImage 2025-10-29 20:27:39 +01:00
MacRimi 1812966fe6 Update AppImage 2025-10-29 20:22:55 +01:00
MacRimi a9bac25c9b Update proxmox-dashboard.tsx 2025-10-29 20:14:30 +01:00
MacRimi 5c967f11f0 Update AppImage 2025-10-29 20:03:17 +01:00
MacRimi 1b5f080495 Update system-logs.tsx 2025-10-29 19:45:32 +01:00
MacRimi 50a27fa3f6 Update virtual-machines.tsx 2025-10-29 19:22:53 +01:00
MacRimi f8ed53c1b9 Update virtual-machines.tsx 2025-10-29 19:04:01 +01:00
MacRimi 2163830a54 Update virtual-machines.tsx 2025-10-29 18:41:01 +01:00
MacRimi cae5e3b99f Update virtual-machines.tsx 2025-10-29 18:27:00 +01:00
MacRimi cc0a7941ea Update AppImage 2025-10-29 18:14:09 +01:00
MacRimi 18901c0e2d Update flask_server.py 2025-10-29 17:47:53 +01:00
ProxMenuxBot d4ea239185 Update helpers_cache.json 2025-10-29 12:29:04 +00:00
MacRimi 813c6aab13 Update AppImage 2025-10-28 23:18:32 +01:00
MacRimi f606e131a7 Update AppImage 2025-10-28 23:07:22 +01:00
MacRimi ccefa61b3d Update virtual-machines.tsx 2025-10-28 23:01:51 +01:00
MacRimi 606cae411f Update virtual-machines.tsx 2025-10-28 22:51:54 +01:00
MacRimi 901e4012cc Update AppImage 2025-10-28 22:45:15 +01:00
MacRimi d03b667194 Update AppImage 2025-10-28 22:12:57 +01:00
MacRimi d30954167e Update AppImage 2025-10-28 21:44:39 +01:00
MacRimi 0ee514ea15 Update flask_server.py 2025-10-28 19:59:37 +01:00
MacRimi 7e60792be8 Update flask_server.py 2025-10-28 19:38:56 +01:00
MacRimi 244a325394 Update flask_server.py 2025-10-28 19:07:08 +01:00
MacRimi 53df16a7ca Update flask_server.py 2025-10-28 18:48:33 +01:00
MacRimi 420576da09 Update flask_server.py 2025-10-28 18:45:31 +01:00
MacRimi d5a9d8ffdb Update flask_server.py 2025-10-28 18:40:11 +01:00
MacRimi 1873ad1a02 Update flask_server.py 2025-10-28 18:28:37 +01:00
MacRimi 9dec238f41 Update flask_server.py 2025-10-28 17:53:06 +01:00
MacRimi b93a018dc1 Update flask_server.py 2025-10-28 17:47:37 +01:00
ProxMenuxBot 6b1d5bf7db Update helpers_cache.json 2025-10-28 12:26:47 +00:00
ProxMenuxBot 4580866281 Update helpers_cache.json 2025-10-28 01:00:36 +00:00
ProxMenuxBot 5b743772ac Update helpers_cache.json 2025-10-27 18:19:45 +00:00
MacRimi 0ecf08e8e6 Update flask_server.py 2025-10-27 00:18:23 +01:00
MacRimi 7ca53d30b2 Update flask_server.py 2025-10-27 00:15:26 +01:00
MacRimi 18a0ba1981 Update flask_server.py 2025-10-27 00:03:26 +01:00
MacRimi 11a35ed589 Update flask_server.py 2025-10-26 23:58:02 +01:00
MacRimi eb5aa12d7f Update flask_server.py 2025-10-26 23:33:17 +01:00
MacRimi 1065b67073 Update flask_server.py 2025-10-26 23:17:18 +01:00
MacRimi 0d7b278003 Update flask_server.py 2025-10-26 22:58:56 +01:00
MacRimi 05093a9d49 Update AppImage 2025-10-26 22:53:13 +01:00
MacRimi 87f9b2b72c Update AppImage 2025-10-26 22:30:14 +01:00
MacRimi 2d4833d199 Update AppImage 2025-10-26 22:11:02 +01:00
MacRimi 610e08e690 Update AppImage 2025-10-26 21:42:12 +01:00
MacRimi 1192224c15 Update proxmox-dashboard.tsx 2025-10-26 21:30:48 +01:00
MacRimi 4c3a9928e7 Update metrics-dialog.tsx 2025-10-26 21:11:55 +01:00
MacRimi 433a4359e6 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-26 21:00:02 +01:00
MacRimi f1854b5120 Update AppImage 2025-10-26 20:59:59 +01:00
ProxMenuxBot 6961b0c2f5 Update helpers_cache.json 2025-10-26 18:17:36 +00:00
MacRimi 8b26f30e37 Create ProxMenux-beta7.AppImage 2025-10-26 18:28:54 +01:00
MacRimi 071724949f Update AppImage 2025-10-26 18:03:09 +01:00
MacRimi 28898aa1db Update network-metrics.tsx 2025-10-26 17:39:19 +01:00
MacRimi 11fae19e33 Update AppImage 2025-10-26 16:23:46 +01:00
MacRimi 13b9dd0262 Update network-metrics.tsx 2025-10-26 16:17:23 +01:00
MacRimi b47520c938 Update flask_server.py 2025-10-26 15:39:52 +01:00
MacRimi f6869b9e1c Update AppImage 2025-10-26 15:36:53 +01:00
MacRimi 96046a5d1f Update AppImage 2025-10-26 15:18:54 +01:00
MacRimi 524d0b278b Update network-metrics.tsx 2025-10-26 14:44:41 +01:00
MacRimi 6e2348eb06 Update AppImage 2025-10-26 14:31:10 +01:00
MacRimi e4b57e6ca3 Updae AppImage 2025-10-26 14:28:35 +01:00
MacRimi 9640e558cd Update AppImage 2025-10-26 14:25:23 +01:00
MacRimi 07b13d1374 Update network-traffic-chart.tsx 2025-10-26 14:17:22 +01:00
MacRimi 7e4389abd9 Update network-traffic-chart.tsx 2025-10-26 14:08:42 +01:00
MacRimi 0f424e7f0d Update network-traffic-chart.tsx 2025-10-26 13:31:14 +01:00
MacRimi 455e5735ff Update network-traffic-chart.tsx 2025-10-26 12:44:18 +01:00
MacRimi 6577d2ae3c Update AppImage 2025-10-26 12:32:40 +01:00
MacRimi 56ed543dfb Update AppImage 2025-10-26 12:17:22 +01:00
MacRimi 5549e3a398 Update network-metrics.tsx 2025-10-26 12:00:42 +01:00
MacRimi 2ff7c111af Update network-metrics.tsx 2025-10-26 11:49:13 +01:00
MacRimi a7af072ca7 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-26 11:41:20 +01:00
MacRimi a4a455f31e Update network-metrics.tsx 2025-10-26 11:41:18 +01:00
ProxMenuxBot 99d55b4314 Update helpers_cache.json 2025-10-26 01:05:33 +00:00
MacRimi 811e2155a6 Update flask_server.py 2025-10-26 01:46:12 +02:00
MacRimi ab7a49351d Update network-metrics.tsx 2025-10-26 00:34:05 +02:00
MacRimi efdfba0575 Update AppImage 2025-10-26 00:21:33 +02:00
MacRimi af9b5f6ca4 Update network-metrics.tsx 2025-10-25 23:44:54 +02:00
MacRimi 65144b9a3d Update AppImage 2025-10-25 23:39:23 +02:00
MacRimi 621f57d702 Update AppImage 2025-10-25 23:33:18 +02:00
MacRimi a0444fbeee Update node-metrics-charts.tsx 2025-10-25 22:57:21 +02:00
MacRimi cff81eea14 Update AppImage 2025-10-25 22:43:17 +02:00
MacRimi 5738c90721 Update network-metrics.tsx 2025-10-25 22:31:13 +02:00
MacRimi a229231c0c Update AppImage 2025-10-25 22:10:08 +02:00
MacRimi 6bf5bd97b5 Update network-metrics.tsx 2025-10-25 21:47:04 +02:00
MacRimi 35c50a7c60 Update network-metrics.tsx 2025-10-25 21:36:46 +02:00
MacRimi 042e6584eb Update network-metrics.tsx 2025-10-25 21:19:43 +02:00
MacRimi bcce1b7ea8 Update network-traffic-chart.tsx 2025-10-25 21:04:21 +02:00
MacRimi 73181f9e33 Update network-metrics.tsx 2025-10-25 20:55:24 +02:00
MacRimi b0a7b6c7cd Update AppImage 2025-10-25 18:47:24 +02:00
MacRimi 09744818dc Update AppImage 2025-10-25 18:09:48 +02:00
MacRimi f93b3109b9 Update uninstall-tools.sh 2025-10-25 17:45:57 +02:00
MacRimi 48d4836f0a Update auto_post_install.sh 2025-10-25 17:33:05 +02:00
MacRimi 5d4f70e943 Update auto_post_install.sh 2025-10-25 17:22:52 +02:00
ProxMenuxBot 9e05197a9a Update helpers_cache.json 2025-10-25 12:22:51 +00:00
MacRimi 11671e884d Update auto_post_install.sh 2025-10-25 11:44:00 +02:00
MacRimi dcce818678 Update post Install 2025-10-25 11:28:07 +02:00
MacRimi f6c23bc9a0 Update virtual-machines.tsx 2025-10-24 23:35:08 +02:00
MacRimi 15eca895fb Update virtual-machines.tsx 2025-10-24 23:28:10 +02:00
MacRimi d5d5dd7855 Update hardware.tsx 2025-10-24 23:15:53 +02:00
MacRimi c79f5fd8a5 Update hardware.tsx 2025-10-24 23:11:24 +02:00
MacRimi 409e40f3b7 Update hardware.tsx 2025-10-24 23:04:03 +02:00
MacRimi 67a83cb164 Update hardware.tsx 2025-10-24 22:57:06 +02:00
MacRimi 908bdc7c86 Update hardware.tsx 2025-10-24 22:50:03 +02:00
MacRimi b1c2bd3d64 Update hardware.tsx 2025-10-24 22:42:08 +02:00
MacRimi ffd317aff0 Update hardware.tsx 2025-10-24 22:34:29 +02:00
MacRimi 84a10afea1 Update network-metrics.tsx 2025-10-24 22:00:16 +02:00
MacRimi 32036ef64d Update AppImage 2025-10-24 21:54:34 +02:00
MacRimi a8b8036311 Update network-metrics.tsx 2025-10-24 21:40:19 +02:00
MacRimi b813716f7c Update network-metrics.tsx 2025-10-24 21:24:57 +02:00
MacRimi 7bd6061a59 Update network-metrics.tsx 2025-10-24 21:06:45 +02:00
MacRimi 7682a6e708 Update AppImage 2025-10-24 20:55:45 +02:00
MacRimi fe9c592107 Update network-metrics.tsx 2025-10-24 20:31:56 +02:00
MacRimi 9ff24dc446 Update network-metrics.tsx 2025-10-24 20:22:24 +02:00
MacRimi 3036711fb4 Update network-metrics.tsx 2025-10-24 20:08:33 +02:00
MacRimi 53363f293b Update network-metrics.tsx 2025-10-24 19:58:04 +02:00
MacRimi e9791984ee Update AppImage 2025-10-24 19:30:10 +02:00
MacRimi ddca96a60e Update AppImage 2025-10-24 19:20:37 +02:00
MacRimi be3607dd4d Update AppImage 2025-10-24 18:56:39 +02:00
MacRimi 6000a7a60f Update proxmox-dashboard.tsx 2025-10-24 18:39:00 +02:00
MacRimi cc64c9f9d8 Update install_coral_pve9.sh 2025-10-24 18:23:09 +02:00
MacRimi 5c699d956c Delete 0001-fix-apex-group-and-udev-rules.patch 2025-10-24 18:17:46 +02:00
MacRimi e1757e5ac5 Update 0001-fix-apex-group-and-udev-rules.patch 2025-10-24 18:11:33 +02:00
MacRimi 31167234be Update 0001-fix-apex-group-and-udev-rules.patch 2025-10-24 18:05:27 +02:00
MacRimi 807638ca04 Update coral TPU 2025-10-24 18:00:26 +02:00
MacRimi ff6b78252c Create 0001-fix-apex-group-and-udev-rules.patch .sh 2025-10-24 17:58:37 +02:00
MacRimi 93bdcaab7f Update auto_post_install.sh 2025-10-24 17:26:32 +02:00
MacRimi d06c580bbc Update AppImage 2025-10-24 17:17:14 +02:00
MacRimi a5b32b356c Create ProxMenux-beta6.AppImage 2025-10-23 22:44:55 +02:00
MacRimi d117e666fd Update proxmox-dashboard.tsx 2025-10-23 22:40:00 +02:00
MacRimi 09da94b2ab Update proxmox-dashboard.tsx 2025-10-23 22:32:43 +02:00
MacRimi 9ebf5919a2 Update proxmox-dashboard.tsx 2025-10-23 22:25:59 +02:00
MacRimi 9c46452a4d Update proxmox-dashboard.tsx 2025-10-23 22:16:02 +02:00
MacRimi b34536491b Update proxmox-dashboard.tsx 2025-10-23 22:10:21 +02:00
MacRimi a7c1e240c1 Update proxmox-dashboard.tsx 2025-10-23 22:00:44 +02:00
MacRimi 44618d3d73 Update proxmox-dashboard.tsx 2025-10-23 21:54:22 +02:00
MacRimi 1f92af64f0 Update proxmox-dashboard.tsx 2025-10-23 21:41:55 +02:00
MacRimi 5bcd081e88 Update proxmox-dashboard.tsx 2025-10-23 21:36:02 +02:00
MacRimi b141622e75 Update proxmox-dashboard.tsx 2025-10-23 21:31:21 +02:00
MacRimi f96bdee71d Update proxmox-dashboard.tsx 2025-10-23 21:28:00 +02:00
MacRimi 0af70d3298 Update proxmox-dashboard.tsx 2025-10-23 21:20:56 +02:00
MacRimi e044c59627 Update proxmox-dashboard.tsx 2025-10-23 21:13:17 +02:00
MacRimi cce1c902e5 Update proxmox-dashboard.tsx 2025-10-23 21:09:01 +02:00
MacRimi d845474644 Update proxmox-dashboard.tsx 2025-10-23 21:02:56 +02:00
MacRimi cd67aba2ad Update proxmox-dashboard.tsx 2025-10-23 20:56:11 +02:00
MacRimi 1e47162357 Update proxmox-dashboard.tsx 2025-10-23 20:48:52 +02:00
MacRimi 230400846f Update proxmox-dashboard.tsx 2025-10-23 20:35:58 +02:00
MacRimi ebb29ad04b Update proxmox-dashboard.tsx 2025-10-23 20:29:39 +02:00
MacRimi 2cd603357d Update proxmox-dashboard.tsx 2025-10-23 20:22:12 +02:00
MacRimi bee26838e1 Update proxmox-dashboard.tsx 2025-10-23 20:12:56 +02:00
MacRimi 5b0572879d Update proxmox-dashboard.tsx 2025-10-23 20:04:56 +02:00
MacRimi 6e86275dce Update proxmox-dashboard.tsx 2025-10-23 19:57:11 +02:00
MacRimi 03a007b9b6 Update AppImage 2025-10-23 19:47:26 +02:00
MacRimi dadb215ce0 Update AppImage 2025-10-23 19:39:24 +02:00
MacRimi a1e3e12c6b Update globals.css 2025-10-23 19:34:30 +02:00
MacRimi 4274c817d3 Update AppImage 2025-10-23 19:29:19 +02:00
MacRimi 5abedc15dc Update proxmox-dashboard.tsx 2025-10-23 18:41:37 +02:00
MacRimi 947c9639e8 Update proxmox-dashboard.tsx 2025-10-23 18:31:44 +02:00
MacRimi c4cdf4a834 Update AppImage 2025-10-23 18:22:00 +02:00
MacRimi 44b9bfee68 Update system-overview.tsx 2025-10-23 17:56:56 +02:00
MacRimi ac9d43892b Update system-overview.tsx 2025-10-23 17:33:48 +02:00
MacRimi 9f62f8eff9 Update AppImage 2025-10-23 17:21:48 +02:00
MacRimi 13dd400795 Update hardware.tsx 2025-10-23 15:31:25 +02:00
MacRimi 37343a4114 Update hardware.tsx 2025-10-23 14:58:46 +02:00
MacRimi 3df6a4048a Update hardware.tsx 2025-10-23 14:38:17 +02:00
MacRimi c9fb87b571 Update ProxMenux-beta5.AppImage 2025-10-23 13:27:36 +02:00
MacRimi 44ca1d507d Update metrics-dialog.tsx 2025-10-23 12:55:31 +02:00
MacRimi 30b236548a Update metrics-dialog.tsx 2025-10-23 12:47:28 +02:00
MacRimi 21b7d1c3fb Update virtual-machines.tsx 2025-10-23 12:36:48 +02:00
MacRimi 8d5ea66ecc Update network-metrics.tsx 2025-10-23 12:25:17 +02:00
MacRimi b5ed10689d Update node-metrics-charts.tsx 2025-10-23 12:13:22 +02:00
MacRimi a55cdfd7fa Update node-metrics-charts.tsx 2025-10-23 11:56:51 +02:00
MacRimi 39a4c10ac9 Update node-metrics-charts.tsx 2025-10-23 11:38:03 +02:00
MacRimi c542cd4d7d Update node-metrics-charts.tsx 2025-10-23 11:00:36 +02:00
MacRimi 01de338a65 Update AppImage 2025-10-23 09:59:27 +02:00
MacRimi f23f7b1983 Updata AppImage 2025-10-23 09:32:46 +02:00
MacRimi a349ab62ec Update node-metrics-charts.tsx 2025-10-22 20:02:32 +02:00
MacRimi e620010f10 Update AppImage 2025-10-22 19:50:26 +02:00
MacRimi 70509355de Uodate AppImage 2025-10-22 19:38:04 +02:00
MacRimi f25654ead7 Updte AppImage 2025-10-22 19:25:04 +02:00
MacRimi f1741d4dac Update AppImage 2025-10-22 19:07:18 +02:00
MacRimi f3245d092b Update virtual-machines.tsx 2025-10-22 18:49:41 +02:00
MacRimi a039a8600e Update virtual-machines.tsx 2025-10-22 18:38:04 +02:00
MacRimi ee56f4a7a2 Update virtual-machines.tsx 2025-10-22 18:27:27 +02:00
MacRimi c40b6ca7f4 Update AppImage 2025-10-22 18:17:57 +02:00
MacRimi 2c0e1e498b Update globals.css 2025-10-22 18:10:05 +02:00
MacRimi 0262ea31eb Update AppImage 2025-10-22 18:05:33 +02:00
MacRimi 4b671d7fb0 Update virtual-machines.tsx 2025-10-22 17:50:36 +02:00
MacRimi d8f9419eb9 Update virtual-machines.tsx 2025-10-22 17:34:46 +02:00
MacRimi aa65bab486 Update flask_server.py 2025-10-22 17:19:42 +02:00
MacRimi 849c3967fd Update virtual-machines.tsx 2025-10-22 17:03:27 +02:00
MacRimi 83562cf7d8 Update virtual-machines.tsx 2025-10-22 16:49:15 +02:00
MacRimi 2631a44410 Update AppImage 2025-10-22 16:15:49 +02:00
MacRimi ddfea43b79 Update virtual-machines.tsx 2025-10-22 12:50:53 +02:00
MacRimi 68f19ffa5f Update virtual-machines.tsx 2025-10-22 12:37:52 +02:00
MacRimi 8800d42c32 Update virtual-machines.tsx 2025-10-22 12:19:03 +02:00
MacRimi 416a8a7cb2 Update virtual-machines.tsx 2025-10-22 12:03:51 +02:00
MacRimi 7088f249a5 Update virtual-machines.tsx 2025-10-22 11:45:06 +02:00
MacRimi a2301cc980 Update virtual-machines.tsx 2025-10-22 11:24:56 +02:00
MacRimi af545404e8 Update virtual-machines.tsx 2025-10-22 11:09:46 +02:00
MacRimi 2e96f19476 Update virtual-machines.tsx 2025-10-22 10:59:20 +02:00
MacRimi e3f26b7f75 Update virtual-machines.tsx 2025-10-22 10:27:14 +02:00
MacRimi f45c98a6a7 Update AppImage 2025-10-22 09:28:46 +02:00
ProxMenuxBot a565a3c909 Update helpers_cache.json 2025-10-22 01:02:42 +00:00
MacRimi b5e3dd6c06 Update AppImage 2025-10-21 20:47:04 +02:00
MacRimi 928f592d9c Update virtual-machines.tsx 2025-10-21 20:31:15 +02:00
MacRimi 8ee8edcd36 Update AppImage 2025-10-21 20:12:00 +02:00
MacRimi 1e128348e5 Update AppImage 2025-10-21 19:50:17 +02:00
MacRimi 6ef5655b7d Update virtual-machines.tsx 2025-10-21 19:16:44 +02:00
MacRimi f6ba5329ce Update AppImage 2025-10-21 19:05:38 +02:00
MacRimi 93cef0d580 Update update-pve.sh 2025-10-21 18:56:33 +02:00
MacRimi 797b088cc8 aupdate AppImage 2025-10-21 18:43:56 +02:00
MacRimi 6d23d3510f Update AppImage 2025-10-21 18:04:35 +02:00
MacRimi f2d7d0af43 Update AppImage 2025-10-21 17:57:05 +02:00
MacRimi e55c0461db Update virtual-machines.tsx 2025-10-21 17:45:12 +02:00
MacRimi 8eca511a53 Uodate AppImage 2025-10-21 17:33:53 +02:00
MacRimi f20e46dee0 Update AppImage 2025-10-21 17:20:16 +02:00
MacRimiandGitHub b79f22f4fe Add log directories for pveproxy with permissions
Create directories for pveproxy logs and set permissions
2025-10-21 14:26:37 +02:00
MacRimiandGitHub 3287dc77e2 Update auto_post_install.sh 2025-10-21 14:24:41 +02:00
MacRimiandGitHub 78a08b35e7 Update auto_post_install.sh 2025-10-21 14:06:10 +02:00
MacRimiandGitHub e86196999a Update auto_post_install.sh 2025-10-21 13:57:32 +02:00
MacRimiandGitHub 4d50339041 Update journald configuration in auto_post_install.sh 2025-10-21 13:56:37 +02:00
MacRimiandGitHub edc5a2c0f2 Enhance Log2RAM installation script
Refactor Log2RAM installation and configuration script to improve error handling, cleanup previous installations, and adjust systemd-journald limits based on Log2RAM size.
2025-10-21 09:29:58 +02:00
MacRimi 598b88b1f0 Update virtual-machines.tsx 2025-10-20 23:48:07 +02:00
MacRimi c22b9f8ff5 Update AppImage 2025-10-20 23:30:18 +02:00
MacRimi 3c654ab495 Update virtual-machines.tsx 2025-10-20 23:24:38 +02:00
MacRimi 2f0fabea7a Update virtual-machines.tsx 2025-10-20 23:16:56 +02:00
MacRimi 099b14efc3 Update virtual-machines.tsx 2025-10-20 23:02:52 +02:00
MacRimi ee42dee366 Update virtual-machines.tsx 2025-10-20 22:40:37 +02:00
MacRimi deae081cb3 Update AppImage 2025-10-20 22:15:08 +02:00
MacRimi 6479f14d3d Update AppImage 2025-10-20 20:56:54 +02:00
MacRimi 60707c3868 Update create VM 2025-10-20 20:22:31 +02:00
MacRimi e78d8e1ae6 Update metrics-dialog.tsx 2025-10-20 20:17:46 +02:00
MacRimi fee0d0aed9 Update metrics-dialog.tsx 2025-10-20 20:00:29 +02:00
MacRimi 178abc77ce Update metrics-dialog.tsx 2025-10-20 19:40:59 +02:00
MacRimi 7001f97d96 Update vm_creator.sh 2025-10-20 19:20:50 +02:00
MacRimi 55432e61ff Update metrics-dialog.tsx 2025-10-20 19:00:00 +02:00
MacRimi 4ee993ef3b Update metrics-dialog.tsx 2025-10-20 18:39:12 +02:00
MacRimi 64d471bb9b Update metrics-dialog.tsx 2025-10-20 17:30:39 +02:00
MacRimi 4dfcdcb0b2 Update metrics-dialog.tsx 2025-10-20 17:11:30 +02:00
MacRimi 8e69a84e7a Update metrics-dialog.tsx 2025-10-20 16:52:16 +02:00
MacRimi 8ec643d882 Update metrics-dialog.tsx 2025-10-20 16:43:07 +02:00
ProxMenuxBot f4d6192c80 Update helpers_cache.json 2025-10-20 12:28:47 +00:00
MacRimi c0aa2b85fc Update metrics-dialog.tsx 2025-10-19 17:53:39 +02:00
MacRimi 61a376fb6d Update virtual-machines.tsx 2025-10-19 17:46:47 +02:00
MacRimi 8786cb5180 Update AppImage 2025-10-19 17:40:13 +02:00
MacRimi c305ef1360 Update AppImage 2025-10-19 17:29:23 +02:00
MacRimi f662ce0b7a Update AppImage 2025-10-19 17:16:35 +02:00
MacRimi 71af9345a5 Update AppImage 2025-10-19 16:51:52 +02:00
MacRimi a819a19c77 Update virtual-machines.tsx 2025-10-19 16:19:47 +02:00
MacRimi c65fad06b7 Update virtual-machines.tsx 2025-10-19 16:06:19 +02:00
MacRimi 9e6e1931b1 Update customizable_post_install.sh 2025-10-19 09:48:38 +02:00
MacRimi 3b22273f5a Update virtual-machines.tsx 2025-10-18 18:57:14 +02:00
MacRimi fc5ff1782b Update virtual-machines.tsx 2025-10-18 18:48:01 +02:00
MacRimi 4d3b3d984d Update virtual-machines.tsx 2025-10-18 18:37:22 +02:00
MacRimi 514976561f Update hardware.tsx 2025-10-18 18:32:13 +02:00
MacRimi fb4998d21b Update auto_post_install.sh 2025-10-18 18:24:05 +02:00
MacRimi 0feec978d3 Update system-logs.tsx 2025-10-18 18:18:57 +02:00
MacRimi 5f1c39aba5 Update system-logs.tsx 2025-10-18 18:04:05 +02:00
MacRimi 17973619de Update system-logs.tsx 2025-10-18 17:54:06 +02:00
MacRimi 478d7a2d2d Update system-logs.tsx 2025-10-18 17:45:13 +02:00
MacRimi f2af0be1e1 Update system-logs.tsx 2025-10-18 17:36:08 +02:00
MacRimi d0725f5098 Update system-logs.tsx 2025-10-18 17:18:41 +02:00
MacRimi 646d614d94 Update update-pve.sh 2025-10-18 17:12:11 +02:00
MacRimi 4c337ef5e9 Update system-logs.tsx 2025-10-18 17:02:16 +02:00
MacRimi 2b633b8566 Update system-logs.tsx 2025-10-18 16:47:04 +02:00
MacRimi 6b16454217 Update system-logs.tsx 2025-10-18 16:36:31 +02:00
MacRimi b7086deeac Update zimaos.sh 2025-10-18 16:16:48 +02:00
MacRimi f021afb6a4 Update AppImage and ZimaOS 2025-10-18 16:11:28 +02:00
ProxMenuxBot 99622bd3d6 Update helpers_cache.json 2025-10-18 12:23:38 +00:00
MacRimi 50a76519ea Update system-logs.tsx 2025-10-18 12:39:26 +02:00
MacRimi 1e806054ab Update system-logs.tsx 2025-10-18 12:26:27 +02:00
MacRimi 89d7f335fc Update system-logs.tsx 2025-10-18 11:58:01 +02:00
MacRimi 7a664ec4ec Update system-logs.tsx 2025-10-18 11:41:49 +02:00
MacRimi a8a4d029f8 Update system-logs.tsx 2025-10-18 11:35:14 +02:00
MacRimi 501b5dce76 Update system-logs.tsx 2025-10-18 11:25:16 +02:00
MacRimi e9b3504370 Update system-logs.tsx 2025-10-18 11:13:01 +02:00
MacRimi 7b20c78e73 Update system-logs.tsx 2025-10-18 11:05:27 +02:00
MacRimi a343ce69aa Update system-logs.tsx 2025-10-18 10:58:08 +02:00
MacRimi d52ce400fb Update AppImage 2025-10-18 10:43:58 +02:00
MacRimi 0ee574eaaa Update system-logs.tsx 2025-10-18 10:34:34 +02:00
MacRimi 74c4392b6d Update AppImage 2025-10-18 10:28:04 +02:00
MacRimi d844c330e9 Update system-logs.tsx 2025-10-18 10:11:43 +02:00
MacRimi b50cb78fa6 Update system-logs.tsx 2025-10-18 09:56:53 +02:00
MacRimi 26c138f42c Update AppImage 2025-10-17 20:32:17 +02:00
MacRimi 0ec7e65926 Update proxmox-dashboard.tsx 2025-10-17 20:27:02 +02:00
MacRimi 3588cc4c03 Update proxmox-dashboard.tsx 2025-10-17 20:19:37 +02:00
MacRimi da8c7749c8 Update proxmox-dashboard.tsx 2025-10-17 20:09:26 +02:00
MacRimi 79fe999e77 Update proxmox-dashboard.tsx 2025-10-17 20:01:10 +02:00
MacRimi 439c65ad6d Update AppImage 2025-10-17 19:51:41 +02:00
MacRimi 81b3aa5ac1 Update system-logs.tsx 2025-10-17 19:44:19 +02:00
MacRimi c4beb9ae4d Update hardware.tsx 2025-10-17 19:34:35 +02:00
MacRimi 9d286d8378 Update network-metrics.tsx 2025-10-17 19:24:14 +02:00
MacRimi 19e7a43fe3 Update network-metrics.tsx 2025-10-17 19:16:55 +02:00
MacRimi ab59e2deac Update AppImage 2025-10-17 19:06:15 +02:00
MacRimi 043f22e6ec Update AppImage 2025-10-17 18:52:18 +02:00
MacRimi 4abb6af31e Update AppImagen 2025-10-17 18:30:18 +02:00
MacRimi a17ba4a81f Update virtual-machines.tsx 2025-10-17 18:15:46 +02:00
MacRimi bc8a6847e3 Update virtual-machines.tsx 2025-10-17 18:10:12 +02:00
MacRimi 18f97f9df2 Update AppImage 2025-10-17 18:03:01 +02:00
MacRimi 4a204d8d89 Update AppImage 2025-10-17 17:53:06 +02:00
MacRimi 062c6c2364 Update AppImage 2025-10-17 17:38:13 +02:00
MacRimi 477716ef67 Update virtual-machines.tsx 2025-10-17 17:22:10 +02:00
MacRimi ef973df7c9 Update flask_server.py 2025-10-17 17:04:55 +02:00
MacRimi c40bb6a4d5 Create rafa.AppImage 2025-10-16 21:29:45 +02:00
MacRimi 20e942dccd Update storage-overview.tsx 2025-10-16 21:19:03 +02:00
MacRimi 598cbc4d11 Update AppImage 2025-10-16 19:57:55 +02:00
MacRimi 70a3d5af07 Update flask_server.py 2025-10-16 19:34:45 +02:00
MacRimi 9cabb1afbd Update hardware.tsx 2025-10-16 19:23:41 +02:00
MacRimi 4267224f59 Create ProxMenux-beta5.AppImage 2025-10-16 09:41:04 +02:00
MacRimi 02d910f53c Update flask_server.py 2025-10-16 09:31:35 +02:00
MacRimi e81b7b5b9f Update AppImage 2025-10-16 09:07:39 +02:00
ProxMenuxBot 17e0a8eec1 Update helpers_cache.json 2025-10-16 01:00:35 +00:00
MacRimi eb322c9d41 Create ProxMenux-beta4.AppImage 2025-10-15 20:51:42 +02:00
MacRimi 751af92c21 Update proxmox-dashboard.tsx 2025-10-15 20:42:51 +02:00
MacRimi fb4962a41a Update proxmox-dashboard.tsx 2025-10-15 20:35:10 +02:00
MacRimi daf6598599 Update AppImage 2025-10-15 20:27:30 +02:00
MacRimi e1e4f71f3a Update virtual-machines.tsx 2025-10-15 20:13:01 +02:00
MacRimi a2fa7ec9c4 Update virtual-machines.tsx 2025-10-15 19:51:24 +02:00
MacRimi 5cd37b74b4 Update flask_server.py 2025-10-15 19:22:16 +02:00
MacRimi beed7e83f2 Update storage-overview.tsx 2025-10-15 19:06:33 +02:00
MacRimi a7726edca6 Update storage-overview.tsx 2025-10-15 18:56:02 +02:00
MacRimi eed0c21c41 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-15 18:41:21 +02:00
MacRimi 094a43157e Update storage-overview.tsx 2025-10-15 18:41:03 +02:00
MacRimiandGitHub 4033958bf5 Merge pull request #37 from MrCaringi/main
🌶️ Contributors rocks! 🌶️
2025-10-15 18:23:37 +02:00
MacRimi f18784ecc1 Update flask_server.py 2025-10-15 18:22:04 +02:00
JFCandGitHub 418494b7f3 Merge pull request #1 from MrCaringi/MrCaringi-contributors
Update README.md
2025-10-15 10:20:16 -06:00
JFCandGitHub 343753ee2a Update README.md 2025-10-15 10:18:43 -06:00
MacRimi 1ceda066c4 Update hardware.tsx 2025-10-15 18:08:49 +02:00
MacRimi 3c13dea55f Update hardware.tsx 2025-10-15 17:54:04 +02:00
MacRimi 6ced2cbf7c Update flask_server.py 2025-10-15 17:24:06 +02:00
MacRimi b41f16a736 Update storage-overview.tsx 2025-10-15 17:04:10 +02:00
MacRimi 42fa02a887 Create ProxMenux-beta3.AppImage 2025-10-15 00:09:51 +02:00
MacRimi 2ce87cdac0 Update AppImage 2025-10-15 00:03:56 +02:00
MacRimi e4864d3871 Update AppImage 2025-10-14 23:57:46 +02:00
MacRimi b6e0052013 Update storage-overview.tsx 2025-10-14 23:42:00 +02:00
MacRimi 9ef83a59c7 Update flask_server.py 2025-10-14 23:21:09 +02:00
MacRimi 325724ff85 Update flask_server.py 2025-10-14 23:16:56 +02:00
MacRimi 998bfa0656 Update flask_server.py 2025-10-14 22:47:13 +02:00
MacRimi e476df5e7d Update flask_server.py 2025-10-14 22:36:50 +02:00
MacRimi 83a3601cdb Update flask_server.py 2025-10-14 22:28:28 +02:00
MacRimi 996dcc4b23 Update AppImage 2025-10-14 22:14:48 +02:00
MacRimi 04304f8283 Update storage-overview.tsx 2025-10-14 21:31:15 +02:00
MacRimi 3418f73390 Update storage-overview.tsx 2025-10-14 21:22:21 +02:00
MacRimi b07b6c8960 Update storage-overview.tsx 2025-10-14 21:11:34 +02:00
MacRimi 4f2cf37d73 Update storage-overview.tsx 2025-10-14 20:54:41 +02:00
MacRimi 408e017f2f Update virtual-machines.tsx 2025-10-14 20:31:53 +02:00
MacRimi 62b42266e8 Update virtual-machines.tsx 2025-10-14 20:22:55 +02:00
MacRimi 80e9e23965 Update virtual-machines.tsx 2025-10-14 20:04:18 +02:00
MacRimi c73154aeb1 Update virtual-machines.tsx 2025-10-14 19:48:57 +02:00
MacRimi 66c4786ec2 Update virtual-machines.tsx 2025-10-14 19:35:25 +02:00
MacRimi 143f5a2085 Update hardware.tsx 2025-10-14 19:00:24 +02:00
MacRimi 699c7df798 Update AppImage 2025-10-14 18:51:39 +02:00
MacRimi d3de7b95aa Update flask_server.py 2025-10-14 18:09:48 +02:00
MacRimi 2dc6f76da9 Update hardware.tsx 2025-10-14 17:38:26 +02:00
MacRimi 63ccf6b553 Update hardware.tsx 2025-10-14 17:23:59 +02:00
MacRimi f49ffe3cb0 Update AppImage 2025-10-14 15:34:19 +02:00
MacRimi c1b578350d Update flask_server.py 2025-10-14 15:19:18 +02:00
MacRimi 48e4af41ae Update flask_server.py 2025-10-14 14:28:08 +02:00
MacRimi 5e915f9c40 Update flask_server.py 2025-10-14 13:56:37 +02:00
MacRimi 70b5f91f82 Update AppImage 2025-10-14 13:37:48 +02:00
MacRimi 792df08c78 Update hardware.tsx 2025-10-14 12:58:53 +02:00
MacRimi 032b5d3580 Update AppImage 2025-10-14 12:40:27 +02:00
MacRimi 8f93e43bb3 Update AppImage 2025-10-14 11:37:30 +02:00
MacRimi 04f95e648b Update AppImage 2025-10-14 11:08:47 +02:00
MacRimi 511b8eb407 Update storage-metrics.tsx 2025-10-14 10:47:30 +02:00
MacRimi a6e6dd255d Update storage-metrics.tsx 2025-10-14 10:25:18 +02:00
MacRimi c3c53d4056 Update storage-metrics.tsx 2025-10-14 09:33:03 +02:00
MacRimi 2f700d9a4c Update AppImage 2025-10-14 09:15:44 +02:00
MacRimi e4da9f5afe Update storage-metrics.tsx 2025-10-14 09:06:17 +02:00
MacRimi 3f919813f2 Update AppImage 2025-10-14 08:54:56 +02:00
MacRimi 4063ffc163 Update storage-metrics.tsx 2025-10-14 00:20:15 +02:00
MacRimi 0d08b89853 Update storage-metrics.tsx 2025-10-14 00:13:10 +02:00
MacRimi 6c9da364d0 Update storage-metrics.tsx 2025-10-14 00:03:22 +02:00
MacRimi c1614e8241 Update AppImage 2025-10-13 23:50:31 +02:00
MacRimi 75a458f2be Update AppImage 2025-10-13 19:04:38 +02:00
MacRimi 602291736f Update flask_server.py 2025-10-13 17:53:34 +02:00
MacRimi 9f2d15e590 Update AppImage 2025-10-13 17:40:08 +02:00
MacRimi 5e88201d47 Update flask_server.py 2025-10-13 17:03:39 +02:00
MacRimi 598b8bd1cd Update AppImage 2025-10-13 15:22:19 +02:00
MacRimi 9186a44860 Update AppImage 2025-10-13 15:06:03 +02:00
MacRimi 61e3dae708 Update AppImage 2025-10-12 21:19:01 +02:00
MacRimi 94d46299d0 Update proxmox-dashboard.tsx 2025-10-12 21:10:24 +02:00
MacRimi 7070c05f2f Update proxmox-dashboard.tsx 2025-10-12 21:00:42 +02:00
MacRimi 0b7038cc65 Update proxmox-dashboard.tsx 2025-10-12 20:47:58 +02:00
MacRimi 4882d04ece Update system-logs.tsx 2025-10-12 20:36:12 +02:00
MacRimi 4b2d34491e Update system-logs.tsx 2025-10-12 20:28:10 +02:00
MacRimi 79d8230821 Update system-logs.tsx 2025-10-12 20:20:32 +02:00
MacRimi c7e3305a76 Update system-logs.tsx 2025-10-12 20:12:55 +02:00
MacRimi 81feccf0d2 Update AppImage 2025-10-12 20:03:40 +02:00
MacRimi 9666bee006 Update AppImage 2025-10-12 19:51:03 +02:00
MacRimi 44d54057d0 Update hardware.tsx 2025-10-12 19:40:35 +02:00
MacRimi beb4251688 Update network-metrics.tsx 2025-10-12 19:32:17 +02:00
MacRimi 598395cd38 Update system-overview.tsx 2025-10-12 19:20:05 +02:00
MacRimi 0a6913f5d0 Update system-overview.tsx 2025-10-12 19:11:39 +02:00
MacRimi fa36458303 Update AppImage 2025-10-12 18:51:38 +02:00
MacRimi 3f96f88027 Update system-overview.tsx 2025-10-12 18:19:50 +02:00
MacRimi 131ab714ba Update system-overview.tsx 2025-10-12 18:05:35 +02:00
MacRimi 333d0c933a Update system-overview.tsx 2025-10-12 17:50:15 +02:00
MacRimi 2a5c0e05cc Update flask_server.py 2025-10-12 17:37:51 +02:00
MacRimi 4bda9da860 Update AppImage 2025-10-12 17:24:13 +02:00
MacRimi 4c579cf862 Update flask_server.py 2025-10-12 17:09:01 +02:00
MacRimi 1b74ce7ac0 Update flask_server.py 2025-10-12 16:32:29 +02:00
MacRimi 29e3625c7b Update flask_server.py 2025-10-12 16:09:38 +02:00
MacRimi a41b9381a1 Update system-logs.tsx 2025-10-12 16:00:52 +02:00
MacRimi b4980a968c Update system-logs.tsx 2025-10-12 15:39:31 +02:00
MacRimi ea91751217 Update system-logs.tsx 2025-10-12 14:51:07 +02:00
MacRimi 1ceffc3391 Update system-logs.tsx 2025-10-12 03:12:15 +02:00
MacRimi 3de31427a3 Update system-logs.tsx 2025-10-12 02:58:11 +02:00
MacRimi 4abb9c2ea6 Update system-logs.tsx 2025-10-12 02:44:49 +02:00
MacRimi e7bfbe77c2 Update system-logs.tsx 2025-10-12 02:04:42 +02:00
MacRimi 776282ed6b Update system-logs.tsx 2025-10-12 01:46:48 +02:00
MacRimi d1621684df Update flask_server.py 2025-10-12 01:26:58 +02:00
MacRimi ba183e71e1 Update AppImage 2025-10-12 01:09:33 +02:00
MacRimi aac34d4fad Update system-logs.tsx 2025-10-12 00:54:23 +02:00
MacRimi 8e28e4ecbf Update AppImage 2025-10-12 00:45:38 +02:00
MacRimi 48665aa1ad Update AppImage 2025-10-12 00:41:15 +02:00
MacRimi f34968bcf5 Update calendar.tsx 2025-10-12 00:35:47 +02:00
MacRimi 4a5c1ed582 Update AppImage 2025-10-11 19:43:15 +02:00
MacRimi 6d87ab08e2 Update AppImage 2025-10-11 19:35:04 +02:00
MacRimi 5ae18bf4f9 Update calendar.tsx 2025-10-11 19:24:20 +02:00
MacRimi 1bac12259d Update system-logs.tsx 2025-10-11 19:16:44 +02:00
MacRimi 434dc408c3 Update calendar.tsx 2025-10-11 19:09:24 +02:00
MacRimi 6601ee3b12 Update AppImage 2025-10-11 19:04:02 +02:00
MacRimi fde1731365 Update AppImage 2025-10-11 18:51:50 +02:00
MacRimi 7725952776 Update AppImage 2025-10-11 18:37:26 +02:00
MacRimi e18ee08b70 Update AppImge 2025-10-11 18:13:35 +02:00
MacRimi 5aaaeb426c Update AppImage 2025-10-11 17:55:25 +02:00
MacRimi 1f55a0cbd8 Update AppImage 2025-10-11 17:34:10 +02:00
MacRimi 4ad026b398 Update flask_server.py 2025-10-11 17:29:17 +02:00
MacRimi d36825da52 Update AppImage 2025-10-11 17:18:52 +02:00
MacRimi bf2715c2be Update AppImage 2025-10-11 16:51:27 +02:00
MacRimi 80953a0148 Update AppImage 2025-10-11 16:25:22 +02:00
MacRimi bb9a08d00d Update system-logs.tsx 2025-10-11 16:04:42 +02:00
MacRimi 3e8fa7cba7 Update system-logs.tsx 2025-10-11 12:06:59 +02:00
MacRimi da8b88b6b2 Update flask_server.py 2025-10-11 11:42:02 +02:00
MacRimi 4bd21a1ccb Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-11 11:30:47 +02:00
MacRimi 29f7586b93 Update AppImage 2025-10-11 11:30:45 +02:00
ProxMenuxBot 39b6b725f5 Update helpers_cache.json 2025-10-11 00:56:17 +00:00
MacRimi 631c68029a Update AppImage 2025-10-11 01:36:42 +02:00
MacRimi 93aefc127f Update storage-overview.tsx 2025-10-11 00:21:22 +02:00
MacRimi 19ac88b560 Update flask_server.py 2025-10-11 00:11:12 +02:00
MacRimi 99e775a283 Update hardware.tsx 2025-10-11 00:00:21 +02:00
MacRimi 7fc05b96a2 Update flask_server.py 2025-10-10 23:52:06 +02:00
MacRimi 9b811da43d Update hardware.tsx 2025-10-10 23:39:31 +02:00
MacRimi 4199b609b4 Updata AppImage 2025-10-10 23:19:29 +02:00
MacRimi 958e6d8519 Update flask_server.py 2025-10-10 23:06:04 +02:00
MacRimi 9dea22ab05 Update hardware.tsx 2025-10-10 22:52:22 +02:00
MacRimi 3dc7c6b36f Update flask_server.py 2025-10-10 22:43:02 +02:00
MacRimi 44e76f36b4 Update flask_server.py 2025-10-10 22:33:45 +02:00
MacRimi 010333a190 Update flask_server.py 2025-10-10 21:43:43 +02:00
MacRimi ba833a265a Update AppImage 2025-10-10 21:38:19 +02:00
MacRimi e0bf156272 Update hardware.tsx 2025-10-10 21:18:49 +02:00
MacRimi a654e21b27 Update flask_server.py 2025-10-10 21:06:00 +02:00
MacRimi de6f149e3b Update flask_server.py 2025-10-10 17:37:30 +02:00
MacRimi 29893b89b3 Update flask_server.py 2025-10-10 17:09:23 +02:00
MacRimi 7783e9ed20 Update flask_server.py 2025-10-10 16:52:43 +02:00
MacRimi d93d1ed48a Update AppImage 2025-10-10 16:17:55 +02:00
MacRimi 32c461e93b Update AppImage 2025-10-10 15:40:41 +02:00
MacRimi d88e6153c1 Update flask_server.py 2025-10-10 12:45:08 +02:00
MacRimi 7b980ae4d4 Update flask_server.py 2025-10-10 12:40:58 +02:00
MacRimi b249d37bab Update flask_server.py 2025-10-10 12:29:47 +02:00
MacRimi fa34e081cc Update hardware.tsx 2025-10-10 01:06:17 +02:00
MacRimi 9f795d7256 Update flask_server.py 2025-10-10 00:48:56 +02:00
MacRimi c8d7d6be43 Update hardware.tsx 2025-10-10 00:38:57 +02:00
MacRimi c31124eb14 Update hardware.tsx 2025-10-10 00:27:22 +02:00
MacRimi e999b7a8f8 Update hardware.tsx 2025-10-10 00:13:54 +02:00
MacRimi 49353a5ec5 Update hardware.tsx 2025-10-09 23:56:43 +02:00
MacRimi 229fbdd306 Update hardware.tsx 2025-10-09 23:46:26 +02:00
MacRimi 4562dd08dc Update hardware.tsx 2025-10-09 23:34:44 +02:00
MacRimi f24f4ea8f9 Update hardware.tsx 2025-10-09 23:14:47 +02:00
MacRimi 6338d38ab6 Update flask_server.py 2025-10-09 23:00:25 +02:00
MacRimi 527d93c6b4 Update flask_server.py 2025-10-09 22:48:33 +02:00
MacRimi 3f5f8d9f57 Update flask_server.py 2025-10-09 22:37:02 +02:00
MacRimi 245c913ba1 Update flask_server.py 2025-10-09 22:17:09 +02:00
MacRimi 7d3ef52f03 Update flask_server.py 2025-10-09 21:16:55 +02:00
MacRimi 6cd7556bc5 Update flask_server.py 2025-10-09 20:58:43 +02:00
MacRimi 123f0594a3 Update flask_server.py 2025-10-09 20:40:29 +02:00
MacRimi 652cebc7d0 Update flask_server.py 2025-10-09 20:26:21 +02:00
MacRimi a4cb9a8923 Update flask_server.py 2025-10-09 20:18:49 +02:00
MacRimi eb954fb10d Update flask_server.py 2025-10-09 19:52:44 +02:00
MacRimi 845eab6f53 Update flask_server.py 2025-10-09 19:38:54 +02:00
MacRimi 4fe20db497 Update AppImage 2025-10-09 19:30:12 +02:00
MacRimi c40d503f6e Update flask_server.py 2025-10-09 19:24:39 +02:00
MacRimi 1ea843bde4 Update flask_server.py 2025-10-09 19:21:32 +02:00
MacRimi 9ed5d70250 Update flask_server.py 2025-10-09 19:08:43 +02:00
MacRimi f6209b97e2 Update AppImage 2025-10-09 19:00:58 +02:00
MacRimi 5221ad6da7 Update flask_server.py 2025-10-09 18:46:16 +02:00
MacRimi cd0bded428 Update flask_server.py 2025-10-09 18:09:59 +02:00
MacRimi ff5fddf353 Update flask_server.py 2025-10-09 18:01:46 +02:00
MacRimi 28b29ed086 Update flask_server.py 2025-10-09 17:03:01 +02:00
MacRimi 765b2b1d69 Update flask_server.py 2025-10-09 16:46:07 +02:00
MacRimi 599a434faa Update flask_server.py 2025-10-09 16:23:19 +02:00
MacRimi a2abee986d Update flask_server.py 2025-10-09 16:06:42 +02:00
MacRimi d57c0712b0 Update flask_server.py 2025-10-09 15:44:17 +02:00
MacRimi 4166d78e87 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-10-09 15:35:43 +02:00
MacRimi 5322291402 Update flask_server.py 2025-10-09 15:35:40 +02:00
ProxMenuxBot dc2ffd758d Update helpers_cache.json 2025-10-09 12:26:29 +00:00
MacRimi b73cdb2e7d Update flask_server.py 2025-10-09 10:29:21 +02:00
MacRimi 3a83e5d519 Update flask_server.py 2025-10-09 10:00:06 +02:00
MacRimi 3c0cdcadc0 Update flask_server.py 2025-10-09 09:48:18 +02:00
MacRimi 0a23ef8b5d Update flask_server.py 2025-10-08 18:26:28 +02:00
MacRimi c8a38ac709 Update flask_server.py 2025-10-08 18:19:19 +02:00
MacRimi 31d15fadbe Update flask_server.py 2025-10-08 18:15:38 +02:00
MacRimi 3fc481e302 Update hardware.tsx 2025-10-08 12:06:24 +02:00
MacRimi 803605c318 Update AppImage 2025-10-08 11:43:45 +02:00
MacRimi 1d138e3b4b Update flask_server.py 2025-10-07 23:42:15 +02:00
MacRimi 5b6c5326b6 Update AppImage 2025-10-07 23:36:13 +02:00
MacRimi 60a1c303da Update hardware.tsx 2025-10-07 23:04:44 +02:00
MacRimi b9f32da7b8 Update flask_server.py 2025-10-07 22:38:49 +02:00
MacRimi fde0b1d8bf Update AppImage 2025-10-07 22:26:49 +02:00
MacRimi cf07004fcd Update flask_server.py 2025-10-07 21:59:57 +02:00
MacRimi b41b52df84 Update flask_server.py 2025-10-07 21:49:29 +02:00
MacRimi 9632dd170a Update hardware.tsx 2025-10-07 21:32:54 +02:00
MacRimi 9dc334dea9 Update flask_server.py 2025-10-07 21:15:01 +02:00
MacRimi 0741079450 Update flask_server.py 2025-10-07 21:00:03 +02:00
MacRimi 562df0f48f Update flask_server.py 2025-10-07 20:26:05 +02:00
MacRimi 3b87c078f4 Update flask_server.py 2025-10-07 20:15:42 +02:00
MacRimi fc091665ff Update flask_server.py 2025-10-07 19:59:18 +02:00
MacRimi 3f2842a9a3 Update flask_server.py 2025-10-07 18:26:37 +02:00
MacRimi f2418c81d7 Update flask_server.py 2025-10-07 18:22:15 +02:00
MacRimi 3c85797cc9 Update flask_server.py 2025-10-07 18:15:33 +02:00
MacRimi 9187bf0b83 Update flask_server.py 2025-10-07 17:53:57 +02:00
MacRimi 5a2381d9dd Update flask_server.py 2025-10-07 17:43:12 +02:00
MacRimi 5fb8bb0dac Update flask_server.py 2025-10-07 17:22:14 +02:00
MacRimi 0d55da18d4 Update hardware.tsx 2025-10-07 12:08:49 +02:00
MacRimi 73148d65bb Update hardware.tsx 2025-10-07 11:22:51 +02:00
MacRimi e81438e49f Update AppImage 2025-10-07 11:10:20 +02:00
MacRimi e247d8095e Update AppImage 2025-10-07 10:49:43 +02:00
MacRimi 46ddb36c79 Update hardware.tsx 2025-10-07 03:03:45 +02:00
MacRimi a87fee906f Update flask_server.py 2025-10-07 02:51:10 +02:00
MacRimi 888d94131e Update AppImage 2025-10-07 02:46:35 +02:00
MacRimi 63dd018756 Update flask_server.py 2025-10-07 02:29:07 +02:00
MacRimi db38571646 Update flask_server.py 2025-10-07 02:23:55 +02:00
MacRimi 8111d96a20 Update flask_server.py 2025-10-07 02:02:14 +02:00
MacRimi a1b5b7c03c Update hardware.tsx 2025-10-07 01:37:43 +02:00
MacRimi 91e95b1ef2 Update flask_server.py 2025-10-07 01:31:29 +02:00
MacRimi 1491f35f5e Update AppImage 2025-10-07 01:24:02 +02:00
MacRimi 9bb127dda7 Update flask_server.py 2025-10-07 01:10:54 +02:00
MacRimi c4348b0cb2 Update flask_server.py 2025-10-07 00:56:41 +02:00
MacRimi a3fc0c7f96 Update hardware.tsx 2025-10-07 00:50:17 +02:00
MacRimi c7387068cc Update hardware.tsx 2025-10-07 00:44:26 +02:00
MacRimi 658ce390e2 Update AppImage 2025-10-07 00:35:23 +02:00
MacRimi f0b6f66be6 Update AppImage 2025-10-07 00:24:35 +02:00
MacRimi 304812e14f Update flask_server.py 2025-10-06 23:57:02 +02:00
MacRimi 92d8a05393 Update AppImage 2025-10-06 23:51:35 +02:00
MacRimi d96d98b8f4 Update AppImage 2025-10-06 23:40:54 +02:00
MacRimi 0d059187ec Update hardware.tsx 2025-10-06 23:26:26 +02:00
MacRimi 1b73b0b861 Update hardware.tsx 2025-10-06 23:16:31 +02:00
MacRimi 29f8d6b981 Update AppImage 2025-10-06 22:58:54 +02:00
MacRimi 7826de9d29 Update flask_server.py 2025-10-06 22:44:24 +02:00
MacRimi 78c56e4f28 Update AppImage 2025-10-06 22:39:37 +02:00
MacRimi 3ef7736e85 Update hardware.tsx 2025-10-06 22:23:56 +02:00
MacRimi 73e6194551 Update AppImage 2025-10-06 22:19:42 +02:00
MacRimi 7ceed3dfbc Update flask_server.py 2025-10-06 19:15:17 +02:00
MacRimi 7a0c2dc261 Update AppImage 2025-10-06 19:08:21 +02:00
MacRimi 5807c4d97f Update flask_server.py 2025-10-06 19:00:36 +02:00
MacRimi a689607e98 Update AppImage 2025-10-06 18:52:58 +02:00
MacRimi b6b3e27408 Update AppImage 2025-10-06 18:29:00 +02:00
MacRimi ac30bd6e51 Update hardware.ts 2025-10-06 18:15:56 +02:00
MacRimi 174fc4f72b Update flask_server.py 2025-10-06 18:09:07 +02:00
MacRimi 047ec982f4 Update AppImage 2025-10-06 18:02:40 +02:00
MacRimi e427f37f0e Update AppImage 2025-10-06 17:40:42 +02:00
MacRimi 810ac1fcfa Update AppImage 2025-10-06 17:25:08 +02:00
MacRimi 5ee3cc6712 Update build_appimage.sh 2025-10-06 17:00:52 +02:00
MacRimi 5ad3d5697e Update build_appimage.sh 2025-10-06 16:54:12 +02:00
MacRimi 874ab093d5 Update AppImage 2025-10-06 16:40:14 +02:00
MacRimiandGitHub fb668859b0 Update build_appimage.sh 2025-10-06 14:53:10 +02:00
MacRimi be7a2d7f41 Update AppImage 2025-10-06 14:17:14 +02:00
MacRimi 154b6b9f74 Update AppImage 2025-10-06 14:12:28 +02:00
MacRimi 23c91386dc Update AppImage 2025-10-06 13:48:02 +02:00
MacRimi 741b6ce0d9 Update AppImage 2025-10-06 13:06:27 +02:00
MacRimi 600c2f6061 Update AppImagen 2025-10-06 12:09:43 +02:00
MacRimi 359de2dbe0 Update build_appimage.sh 2025-10-06 11:06:19 +02:00
MacRimi 84eec4655a Update AppImage 2025-10-06 11:02:00 +02:00
MacRimi 7e8c69a02d Update hardware.tsx 2025-10-05 22:46:14 +02:00
MacRimi 730d47f2f7 Update AppImage 2025-10-05 22:40:38 +02:00
MacRimi 5afb74e606 Update hardware.tsx 2025-10-05 22:34:30 +02:00
MacRimi 8a21547668 Update hardware.tsx 2025-10-05 22:27:20 +02:00
MacRimi 3ee3044270 Update flask_server.py 2025-10-05 22:15:32 +02:00
MacRimi 782dc24eba Update AppImage 2025-10-05 22:10:24 +02:00
MacRimi 475b96178e Update AppImage 2025-10-05 21:59:44 +02:00
MacRimi 4beba53675 Update AppImage 2025-10-05 21:44:22 +02:00
MacRimi efb7cad993 Update flask_server.py 2025-10-05 21:24:15 +02:00
MacRimi 7b7705866d Update proxmox-dashboard.tsx 2025-10-05 21:15:51 +02:00
MacRimi b7e06d51ea Create sheet.tsx 2025-10-05 21:02:20 +02:00
MacRimi 95476276ac Update proxmox-dashboard.tsx 2025-10-05 20:58:15 +02:00
MacRimi 2347e10458 Update AppImage 2025-10-05 20:45:54 +02:00
MacRimi 85051f1340 Update virtual-machines.tsx 2025-10-05 20:30:47 +02:00
MacRimi 42c6e70ebe Update virtual-machines.tsx 2025-10-05 20:24:30 +02:00
MacRimi b9fe83e7a8 Update virtual-machines.tsx 2025-10-05 20:12:33 +02:00
MacRimi 841108623f Update virtual-machines.tsx 2025-10-05 17:27:10 +02:00
MacRimi 8ce221e41b Update virtual-machines.tsx 2025-10-05 17:18:26 +02:00
MacRimi f8c41ab39f Update virtual-machines.tsx 2025-10-05 17:10:28 +02:00
MacRimi 79e7fd175e Update virtual-machines.tsx 2025-10-05 17:01:50 +02:00
MacRimi fbcf755591 Update virtual-machines.tsx 2025-10-05 16:28:12 +02:00
MacRimi 6168a47e24 Update network-metrics.tsx 2025-10-05 16:15:45 +02:00
MacRimi d788114be3 Update AppImage 2025-10-05 16:05:54 +02:00
MacRimi 497814f80c Update AppImage 2025-10-05 15:54:24 +02:00
MacRimi 7297edf16f Update AppImage 2025-10-05 15:44:19 +02:00
MacRimi 714407eb46 Update virtual-machines.tsx 2025-10-05 15:38:29 +02:00
MacRimi dd3523ddd7 Update virtual-machines.tsx 2025-10-05 15:18:50 +02:00
MacRimi 7739de5db9 Update AppImage 2025-10-05 15:00:42 +02:00
MacRimi 99c08026ee Update network-metrics.tsx 2025-10-05 14:33:47 +02:00
MacRimi 19f7ea70f0 Update AppImage 2025-10-05 14:16:21 +02:00
MacRimi 49050c042d Update AppImage 2025-10-05 13:50:29 +02:00
MacRimi 18ccff5759 Update AppImage 2025-10-05 13:15:44 +02:00
MacRimi 9f6f646e77 Update virtual-machines.tsx 2025-10-05 12:56:06 +02:00
MacRimi b8c0d8ef79 Update AppImage 2025-10-05 12:48:34 +02:00
MacRimi 2ccd41bfb9 Update AppImage 2025-10-05 12:32:09 +02:00
MacRimi fa64b51d4a Update AppImage 2025-10-05 12:03:47 +02:00
MacRimi f5ac194008 Update AppImage 2025-10-05 11:48:32 +02:00
MacRimi 816cf0141b Update AppImage 2025-10-04 20:23:42 +02:00
ProxMenuxBot 7baabc6d2c Update helpers_cache.json 2025-10-04 18:16:43 +00:00
MacRimi 37d1c7338b Update flask_server.py 2025-10-04 20:14:57 +02:00
MacRimi 404ea9d838 Update AppImage 2025-10-04 20:06:47 +02:00
MacRimi 98c5c5827c Update AppImage 2025-10-04 19:58:12 +02:00
MacRimi 79525284b1 Update network-metrics.tsx 2025-10-04 19:53:12 +02:00
MacRimi c14ea7afdf Update AppImage 2025-10-04 19:45:37 +02:00
MacRimi c437753d64 Update package.json 2025-10-04 19:28:13 +02:00
MacRimi 441cc35e5a Update AppImage 2025-10-04 19:25:28 +02:00
MacRimi dc03144773 Update AppImage 2025-10-04 19:05:39 +02:00
MacRimi 992921b24c Update storage-overview.tsx 2025-10-04 18:53:31 +02:00
MacRimi 28f38dca46 Update storage-overview.tsx 2025-10-04 18:46:12 +02:00
MacRimi 53155ccef0 Update AppImage 2025-10-04 18:36:15 +02:00
MacRimi ba6f0a1aab Update AppImage 2025-10-04 18:23:45 +02:00
MacRimi 2d89d06bcb Update AppImage 2025-10-04 17:48:10 +02:00
MacRimi 54ff50ce68 Update AppImage 2025-10-04 17:34:07 +02:00
MacRimi 22aa8cdd6c Update flask_server.py 2025-10-04 17:03:39 +02:00
MacRimi 06b0195d74 Update customizable_post_install.sh 2025-10-04 16:31:38 +02:00
MacRimi a99b4ded7f Update uninstall-tools.sh 2025-10-04 16:22:17 +02:00
MacRimi 2405a0e778 Update uninstall-tools.sh 2025-10-04 16:20:30 +02:00
MacRimi 84544b1e84 Update auto_post_install.sh 2025-10-04 16:11:45 +02:00
MacRimi 95fce39502 Update auto_post_install.sh 2025-10-04 16:04:09 +02:00
MacRimi 99c5b26241 Update uninstall-tools.sh 2025-10-04 10:26:06 +02:00
ProxMenuxBot 6e07e49c84 Update helpers_cache.json 2025-10-03 18:18:02 +00:00
ProxMenuxBot 0bcfea9d20 Update helpers_cache.json 2025-10-03 12:25:37 +00:00
MacRimi 2658331fd2 Update AppImage 2025-10-02 23:41:31 +02:00
MacRimi 2ab49cc545 Update AppImage 2025-10-02 23:20:59 +02:00
MacRimi a39fe5ff3b Update flask_server.py 2025-10-02 23:02:17 +02:00
MacRimi 01578b4e34 Update flask_server.py 2025-10-02 22:38:06 +02:00
MacRimi 95718c889d Update AppImage 2025-10-02 22:29:24 +02:00
MacRimi 6279cc9ec1 Update AppImage 2025-10-02 19:51:53 +02:00
MacRimi f7fb9034ef Update system-overview.tsx 2025-10-02 19:34:53 +02:00
MacRimi 15f3af2020 Update AppImage 2025-10-02 18:28:36 +02:00
MacRimi 97288ed6ce Update system-overview.tsx 2025-10-02 18:11:00 +02:00
MacRimi 5e168c2561 Update system-overview.tsx 2025-10-02 17:49:40 +02:00
MacRimi 358b3f96ae Update globals.css 2025-10-02 17:41:23 +02:00
MacRimi c0d9c3808a Update globals.css 2025-10-02 17:28:23 +02:00
MacRimi 7404bb8e64 Update globals.css 2025-10-02 17:21:04 +02:00
MacRimi 93eccd7dcf Update globals.css 2025-10-02 17:17:21 +02:00
MacRimi 05d9d41860 Update AppImage 2025-10-02 17:10:27 +02:00
MacRimi c47c41548f Update globals.css 2025-10-02 16:57:12 +02:00
ProxMenuxBot 013d1980a3 Update helpers_cache.json 2025-10-01 18:18:31 +00:00
MacRimi df9f4a23b4 Update AppImage 2025-10-01 18:14:58 +02:00
MacRimi c41da47a48 Update AppImage 2025-10-01 18:08:31 +02:00
MacRimi e7214ad8df Update globals.css 2025-10-01 18:04:38 +02:00
MacRimi d6671de842 Update AppImage 2025-10-01 18:01:54 +02:00
MacRimi aad218db5d Update globals.css 2025-10-01 17:44:00 +02:00
MacRimi 724ba1e271 Update AppImge 2025-10-01 17:39:43 +02:00
MacRimi 97d554f638 update AppImage 2025-10-01 17:27:05 +02:00
MacRimi c5a7655d26 Update AppImage 2025-10-01 17:10:37 +02:00
MacRimi 403e896e3e Update proxmox-dashboard.tsx 2025-10-01 17:03:56 +02:00
MacRimi 1a15f43cad Update proxmox-dashboard.tsx 2025-10-01 16:53:37 +02:00
MacRimi 399b460c53 Update globals.css 2025-09-30 00:11:24 +02:00
MacRimi acc0362180 Update AppImage 2025-09-30 00:09:11 +02:00
MacRimi 00db93e03f Update AppImage 2025-09-29 23:56:33 +02:00
MacRimi d1997794c8 Update globals.css 2025-09-29 23:40:45 +02:00
MacRimi aa1ebe69f2 Update globals.css 2025-09-29 23:11:40 +02:00
MacRimi 4e7f5f56f1 Update AppImage 2025-09-29 22:59:10 +02:00
MacRimi 28cb7359ce Update system-overview.tsx 2025-09-29 22:38:25 +02:00
ProxMenuxBot 91c272d21c Update helpers_cache.json 2025-09-29 18:19:17 +00:00
MacRimi 3c00125e83 Update flask_server.py 2025-09-29 19:19:35 +02:00
MacRimi f359848a2f Update AppRun 2025-09-29 19:12:56 +02:00
MacRimi 989769e5e8 Update AppRun 2025-09-29 19:07:35 +02:00
MacRimi 0f2f1b6211 Update system-overview.tsx 2025-09-29 19:02:59 +02:00
MacRimi ffe8f4acc6 Update AppImage 2025-09-29 18:58:53 +02:00
MacRimi edb09777de Update package.json 2025-09-29 18:47:18 +02:00
MacRimi 5262c7863e Update AppImage 2025-09-29 18:43:14 +02:00
MacRimi 54256826fe Update AppImage 2025-09-29 18:37:32 +02:00
MacRimi 3d3c224b3a Update flask_server.py 2025-09-29 18:31:09 +02:00
MacRimi 049eccb872 Update AppImage 2025-09-29 18:27:09 +02:00
MacRimi 269828c79e Update AppImage 2025-09-29 18:16:01 +02:00
MacRimi b4e25ae66d Update AppImage 2025-09-29 18:07:30 +02:00
MacRimi b20dd74d23 Update AppImage 2025-09-29 17:57:00 +02:00
MacRimi bc3e2ec358 Update AppImage 2025-09-29 17:46:37 +02:00
MacRimi 6133a6d6d8 Update build_appimage.sh 2025-09-29 17:32:24 +02:00
MacRimi 46a16c04e6 Update AppImage 2025-09-29 17:21:59 +02:00
MacRimi 8469b3b26f Update AppImage 2025-09-29 17:05:42 +02:00
ProxMenuxBot 2ed04f57fe Update helpers_cache.json 2025-09-29 12:26:48 +00:00
MacRimi b19bac679a Update flask_server.py 2025-09-29 00:00:01 +02:00
MacRimi 3c33d5982c Update AppImage 2025-09-28 23:51:06 +02:00
MacRimi 5b934eeb87 Update AppImage 2025-09-28 23:25:58 +02:00
MacRimi 795d96f8d5 Update AppImage 2025-09-28 23:09:31 +02:00
MacRimi a8e7119b4a Update AppImage 2025-09-28 23:05:59 +02:00
MacRimi 38569ff7fc Update AppImage 2025-09-28 22:57:15 +02:00
MacRimi e404557d62 Update AppImage 2025-09-28 22:53:42 +02:00
MacRimi 96cbc75a5e Update build_appimage.sh 2025-09-28 21:26:25 +02:00
MacRimi c989af6cf0 Update build_appimage.sh 2025-09-28 21:18:52 +02:00
MacRimi 4eac9d03ea Update build_appimage.sh 2025-09-28 21:15:18 +02:00
MacRimi 6292009b0b Update AppImage 2025-09-28 21:08:58 +02:00
MacRimi 3272be967d Update build_appimage.sh 2025-09-28 21:01:48 +02:00
MacRimi 1c015da440 Update build_appimage.sh 2025-09-28 20:57:23 +02:00
MacRimi 0d047cc956 Update build_appimage.sh 2025-09-28 20:46:55 +02:00
MacRimi e682070b85 update AppImage 2025-09-28 20:28:35 +02:00
MacRimi 9f08694d9b Update Appimage 2025-09-28 20:25:55 +02:00
MacRimi 70f0db73e5 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-28 20:20:46 +02:00
MacRimi 9dc8f44379 Create tsconfig.json 2025-09-28 20:20:31 +02:00
MacRimiandGitHub 59f7ccd723 Update build-appimage.yml 2025-09-28 20:15:56 +02:00
MacRimi 0710e95a6d Update package.json 2025-09-28 20:15:33 +02:00
MacRimiandGitHub 4d1b5e3919 Update build-appimage.yml 2025-09-28 20:12:21 +02:00
MacRimiandGitHub 0cc2cb92dd Update build-appimage.yml 2025-09-28 20:10:08 +02:00
MacRimiandGitHub dba4d168f7 Update build-appimage.yml 2025-09-28 20:08:02 +02:00
MacRimiandGitHub d87ac7843c Update build-appimage.yml 2025-09-28 20:07:12 +02:00
MacRimiandGitHub 040535b004 Update build-appimage.yml 2025-09-28 20:04:39 +02:00
MacRimiandGitHub c8acd2c0b1 Update build-appimage.yml 2025-09-28 19:56:03 +02:00
MacRimiandGitHub d67fecea6e Update build-appimage.yml 2025-09-28 19:46:03 +02:00
MacRimiandGitHub 61f80f9ee6 Update build-appimage.yml 2025-09-28 19:44:51 +02:00
MacRimiandGitHub 9da8f9a5d1 Update build-appimage.yml 2025-09-28 19:43:05 +02:00
MacRimiandGitHub f381468d5a Create build-appimage.yml 2025-09-28 19:41:13 +02:00
MacRimi 6ae97266e4 Create AppImage 2025-09-28 19:40:23 +02:00
MacRimi 66060f345c Create jd2_2.sh 2025-09-27 20:21:15 +02:00
MacRimi c61f568170 Update nfs_lxc_server.sh 2025-09-27 18:50:50 +02:00
MacRimi dcd108bda3 Update update-pve.sh 2025-09-27 18:28:56 +02:00
MacRimi 9d89f98987 Update customizable_post_install.sh 2025-09-27 18:26:23 +02:00
MacRimi ca7b959fce Update auto_post_install.sh 2025-09-27 18:25:25 +02:00
MacRimi 4a30793595 Update uninstall-tools.sh 2025-09-27 18:24:02 +02:00
MacRimi 35e2d53f0f update remove subscription banner PVE 9 2025-09-27 18:16:12 +02:00
MacRimi 503efa4572 Create remove-banner-pve9_2.sh 2025-09-27 17:42:18 +02:00
ProxMenuxBot b0c33d9dff Update helpers_cache.json 2025-09-27 00:56:57 +00:00
MacRimi 012b156b46 Update install_coral_pve9.sh 2025-09-26 00:17:32 +02:00
MacRimi 25d0d3bf59 Create install_coral_pve9.sh 2025-09-25 23:45:50 +02:00
ProxMenuxBot 0f1babc82b Update helpers_cache.json 2025-09-25 18:20:06 +00:00
ProxMenuxBot e2b93ea785 Update helpers_cache.json 2025-09-24 18:19:23 +00:00
ProxMenuxBot b1cedfa81e Update helpers_cache.json 2025-09-24 12:27:12 +00:00
ProxMenuxBot 701ee36f6a Update helpers_cache.json 2025-09-23 12:25:52 +00:00
ProxMenuxBot 4e5db86434 Update helpers_cache.json 2025-09-21 12:23:25 +00:00
ProxMenuxBot f45e9e657c Update helpers_cache.json 2025-09-19 18:18:42 +00:00
ProxMenuxBot 4936fcdb1e Update helpers_cache.json 2025-09-18 18:18:59 +00:00
ProxMenuxBot 374e05c422 Update helpers_cache.json 2025-09-18 12:25:39 +00:00
ProxMenuxBot 9c00798373 Update helpers_cache.json 2025-09-17 18:18:09 +00:00
ProxMenuxBot db82fce925 Update helpers_cache.json 2025-09-15 12:26:37 +00:00
ProxMenuxBot acaa28e476 Update helpers_cache.json 2025-09-13 00:54:55 +00:00
ProxMenuxBot f297ce5809 Update helpers_cache.json 2025-09-12 12:24:43 +00:00
ProxMenuxBot 3dc3fc5f67 Update helpers_cache.json 2025-09-12 00:57:45 +00:00
ProxMenuxBot 4884fc4418 Update helpers_cache.json 2025-09-11 18:15:27 +00:00
MacRimiandGitHub adc17842ec Update README.md 2025-09-11 18:18:42 +02:00
MacRimiandGitHub daa48b0b7c Update README.md 2025-09-11 18:17:16 +02:00
MacRimi 17c0362df3 Update cache.json 2025-09-10 20:29:19 +02:00
MacRimi 29b9a63fc9 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-10 20:28:00 +02:00
MacRimi 2a9fae160e Update cache.json 2025-09-10 20:27:58 +02:00
ProxMenuxBot 0c49a1e3bd Update helpers_cache.json 2025-09-10 18:18:53 +00:00
MacRimi e896c41be1 Update main_menu.sh 2025-09-10 19:14:42 +02:00
MacRimi 187250fa24 update text ProxMenux 2025-09-10 19:06:04 +02:00
MacRimi 9035b18584 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-10 18:58:12 +02:00
MacRimi 4534d78978 update text ProxMenux 2025-09-10 18:57:49 +02:00
MacRimiandGitHub f4ab0e982c Update README.md 2025-09-10 18:53:41 +02:00
MacRimi 3e7c6629a6 update text ProxMenux 2025-09-10 18:47:55 +02:00
MacRimi 3ea17331fe Update install_proxmenux.sh 2025-09-10 18:24:14 +02:00
MacRimi 1057fcc271 Update install_proxmenux.sh 2025-09-10 18:19:13 +02:00
MacRimi 5a31c36097 update menu share 2025-09-10 18:05:06 +02:00
MacRimi 1677a69bba Update version.txt 2025-09-10 17:52:17 +02:00
MacRimi 315c49165d Update CHANGELOG.md 2025-09-10 17:50:53 +02:00
MacRimi aae70e7ec0 Update CHANGELOG.md 2025-09-10 17:47:37 +02:00
MacRimi 5cb9e13ca7 Update CHANGELOG.md 2025-09-10 17:37:51 +02:00
MacRimi 0187010f94 Create main-menu.png 2025-09-10 16:28:47 +02:00
ProxMenuxBot 2c2ed21e59 Update helpers_cache.json 2025-09-10 12:24:52 +00:00
MacRimi f8b2ccec40 Update commands_share.sh 2025-09-10 13:48:17 +02:00
MacRimi e858dc582d Update commands_share.sh 2025-09-10 13:47:13 +02:00
MacRimi dd737f4b46 Update commands_share.sh 2025-09-10 13:46:02 +02:00
MacRimi f0bc238b6d Update commands_share.sh 2025-09-10 13:35:16 +02:00
MacRimi af55424850 Update lxc-mount-manager_minimal.sh 2025-09-10 13:16:54 +02:00
MacRimi 902534baff update menu shared 2025-09-10 12:57:17 +02:00
MacRimi 6daa630040 Update nfs_host.sh 2025-09-10 12:53:09 +02:00
MacRimi 0b2b86673b Update lxc-mount-manager_minimal.sh 2025-09-10 12:31:49 +02:00
MacRimi 6aa5b58208 Update share_menu.sh 2025-09-10 12:17:03 +02:00
MacRimi 4430201cd2 Update lxc-mount-manager_minimal.sh 2025-09-10 12:16:25 +02:00
MacRimi 7c7963a83e Create lxc-mount-manager_minimal.sh 2025-09-10 11:10:02 +02:00
ProxMenuxBot e2202cd2d8 Update helpers_cache.json 2025-09-09 18:17:13 +00:00
MacRimi a931be83bc Update share-common.func 2025-09-09 19:20:15 +02:00
MacRimi 7350bea345 Update auto_post_install.sh 2025-09-09 19:16:13 +02:00
MacRimi 9b1e39dbb4 Update update-pve.sh 2025-09-09 19:14:27 +02:00
MacRimi 15cd118845 Update auto_post_install.sh 2025-09-09 19:06:33 +02:00
MacRimi d58dff047c Update auto_post_install.sh 2025-09-08 19:24:49 +02:00
MacRimi a2f83c896c Update common-functions.sh 2025-09-08 19:14:41 +02:00
MacRimi 6ef77c731c Update guia.md 2025-09-08 17:44:17 +02:00
MacRimi 29b0f61958 Update guia.md 2025-09-08 17:43:39 +02:00
MacRimi e944b2ecdd Update guia.md 2025-09-08 17:24:23 +02:00
MacRimi 41819c46a3 Update guia.md 2025-09-08 17:21:58 +02:00
MacRimi 13f391a6f0 Update guia.md 2025-09-08 17:14:52 +02:00
MacRimi 85a3d44f2c Update guia.md 2025-09-08 17:05:26 +02:00
MacRimi 0792392058 Update guia.md 2025-09-08 16:42:46 +02:00
MacRimi ff5083ada0 Update guia.md 2025-09-08 16:20:10 +02:00
MacRimi 62841677bc Update guia.md 2025-09-08 16:06:50 +02:00
MacRimi 1761cf53a2 Update guia.md 2025-09-08 15:27:32 +02:00
MacRimi a771efc5fa Update guia.md 2025-09-08 15:08:02 +02:00
MacRimi ed049da76a Update guia.md 2025-09-08 15:03:10 +02:00
MacRimi 5d1d357a2e Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-08 15:01:02 +02:00
MacRimi 30d0706a1c Create guia.md 2025-09-08 15:01:00 +02:00
ProxMenuxBot e9667e1266 Update helpers_cache.json 2025-09-08 12:27:18 +00:00
MacRimi 73109483e7 Update share-common.func 2025-09-08 11:29:02 +02:00
MacRimi a9c1acf204 Update lxc-mount-manager.sh 2025-09-08 11:15:52 +02:00
MacRimi 81c4f5814c Update samba_host.sh 2025-09-08 10:40:59 +02:00
MacRimi c595f6d781 Update nfs_host.sh 2025-09-08 10:36:57 +02:00
MacRimiandGitHub 24bb6b1d3d Update lxc-mount-manager.sh 2025-09-07 19:10:23 +02:00
MacRimiandGitHub 49eeb6020d Update install_proxmenux.sh 2025-09-07 17:24:29 +02:00
MacRimiandGitHub 7c272bd2a2 Update install_proxmenux.sh 2025-09-07 17:22:47 +02:00
MacRimi cfbd865937 Update share-common.func 2025-09-07 09:29:43 +02:00
MacRimi fe472f33ef Update lxc-mount-manager.sh 2025-09-07 09:28:09 +02:00
MacRimi 8d6b3d650f update menu share 2025-09-07 09:19:53 +02:00
MacRimi 3b0d5b5eb7 Update nfs_lxc_server.sh 2025-09-07 09:16:22 +02:00
MacRimi 875e8a99bd Update samba_client.sh 2025-09-07 09:06:47 +02:00
MacRimi 6c19d81844 Update samba_client.sh 2025-09-07 09:05:23 +02:00
MacRimi ba535a931f Update nfs_client.sh 2025-09-07 09:04:13 +02:00
MacRimi 45dca5218d Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-07 09:00:39 +02:00
MacRimi da3cb9971b Update share-common.func 2025-09-07 09:00:37 +02:00
ProxMenuxBot b39270dc1e Update helpers_cache.json 2025-09-07 01:03:30 +00:00
MacRimi ae8a7d0de9 Update commands_share.sh 2025-09-06 23:59:23 +02:00
MacRimi 2d501415bf Update commands_share.sh 2025-09-06 23:50:57 +02:00
MacRimi da639ccaac Update commands_share.sh 2025-09-06 23:35:46 +02:00
MacRimi a352770e2d Update share_menu.sh 2025-09-06 23:20:59 +02:00
MacRimi e3e1899466 update menu share 2025-09-06 23:18:11 +02:00
MacRimi e67288e623 Update lxc-mount-manager.sh 2025-09-06 22:55:28 +02:00
MacRimi 4019e49b07 Update share-common.func 2025-09-06 22:51:57 +02:00
MacRimi cd8711f3bc Update lxc-mount-manager.sh 2025-09-06 22:50:30 +02:00
MacRimi 0d119379de Update lxc-mount-manager.sh 2025-09-06 22:46:52 +02:00
MacRimi aa2b6ff112 Update lxc-mount-manager.sh 2025-09-06 22:44:09 +02:00
MacRimi 3482f7dc98 Update share-common.func 2025-09-06 22:42:17 +02:00
MacRimi 16c321f114 Update lxc-mount-manager.sh 2025-09-06 22:33:19 +02:00
MacRimi a81e7f3c44 Update lxc-mount-manager.sh 2025-09-06 22:25:50 +02:00
MacRimi d7cc001521 Update lxc-mount-manager.sh 2025-09-06 22:20:34 +02:00
MacRimi eb11962231 Update share-common.func 2025-09-06 22:19:52 +02:00
MacRimi 9f73b8f159 Update lxc-mount-manager.sh 2025-09-06 22:17:08 +02:00
MacRimi 873a4abe24 Update share-common.func 2025-09-06 22:16:40 +02:00
MacRimi 56bc584f5e Update lxc-mount-manager.sh 2025-09-06 22:10:01 +02:00
MacRimi 2a9f2f3c2e Update lxc-mount-manager.sh 2025-09-06 22:09:11 +02:00
MacRimi ee719cdd39 Update share-common.func 2025-09-06 22:06:02 +02:00
MacRimi a571b57b30 Update share-common.func 2025-09-06 21:48:39 +02:00
MacRimi 5ee7a23bea Update share-common.func 2025-09-06 21:47:20 +02:00
MacRimi fe159ea195 Update share-common.func 2025-09-06 21:42:30 +02:00
MacRimi 8fcdf6176b Update share-common.func 2025-09-06 21:31:03 +02:00
MacRimi 715166bbca Update share-common.func 2025-09-06 21:22:40 +02:00
MacRimi 1d58072c70 Update share-common.func 2025-09-06 21:21:39 +02:00
MacRimi d667cde699 Update lxc-mount-manager.sh 2025-09-06 21:19:55 +02:00
MacRimi 4cd8889c38 Update share-common.func 2025-09-06 21:14:54 +02:00
MacRimi 93896f6fb7 Update share-common.func 2025-09-06 21:07:12 +02:00
MacRimi 3b3f0387bb Update share-common.func 2025-09-06 21:05:32 +02:00
MacRimi 2875c9af95 Update lxc-mount-manager.sh 2025-09-06 20:59:03 +02:00
MacRimi 93ef1bfccc update share menu 2025-09-06 20:57:04 +02:00
MacRimi a886af1d87 Update samba_client.sh 2025-09-06 20:48:37 +02:00
MacRimi d731ff3ae6 Update samba_host.sh 2025-09-06 20:40:45 +02:00
MacRimi d44864637d Update nfs_host_auto.sh 2025-09-06 20:37:23 +02:00
MacRimi 674ee34ec6 Update nfs_host_auto.sh 2025-09-06 20:30:04 +02:00
MacRimi a93eeda243 Update share-common.func 2025-09-06 19:56:24 +02:00
MacRimi 80fd92e2a1 Update share-common.func 2025-09-06 19:55:01 +02:00
MacRimi d4ff2da473 Update share-common.func 2025-09-06 19:16:15 +02:00
MacRimi 9b7b271580 update menu shared 2025-09-06 19:13:52 +02:00
MacRimi e1b340966a Update share-common.func 2025-09-06 18:56:10 +02:00
MacRimi 7ec4c331af Update nfs_client.sh 2025-09-06 18:39:35 +02:00
MacRimi 3102d596ee Update nfs_lxc_server.sh 2025-09-06 18:37:28 +02:00
MacRimi af56dc546e Update nfs_host_auto.sh 2025-09-06 18:26:13 +02:00
MacRimi 15d47499fa Update nfs_host_auto.sh 2025-09-06 18:22:02 +02:00
MacRimi 53a34d0470 update nfs menu 2025-09-06 18:20:20 +02:00
MacRimi 3ee675cefe Update nfs_lxc_server.sh 2025-09-06 18:08:48 +02:00
MacRimi d98c7bdc03 Update share_menu.sh 2025-09-06 16:48:07 +02:00
MacRimi bb4f1ebed6 Update share_menu.sh 2025-09-06 16:44:58 +02:00
MacRimi c8f73ea23b Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-06 16:41:55 +02:00
MacRimi 8292b12787 Create nfs_lxc_server.sh 2025-09-06 16:41:54 +02:00
ProxMenuxBot 0f518e3c35 Update helpers_cache.json 2025-09-06 12:22:12 +00:00
MacRimi 1c2f67d43d Update lxc-mount-manager.sh 2025-09-06 11:55:23 +02:00
MacRimi a5560a3123 Update share-common.func 2025-09-06 11:50:48 +02:00
MacRimi 1332096360 Update share-common.func 2025-09-06 11:39:17 +02:00
MacRimi 80381a6375 Update share-common.func 2025-09-06 11:32:07 +02:00
MacRimi acf92bd005 Update share-common.func 2025-09-06 11:30:23 +02:00
MacRimi da4f8a3a19 Update share-common.func 2025-09-06 11:11:19 +02:00
MacRimi 3a332192e3 Update share-common.func 2025-09-06 11:08:23 +02:00
MacRimi 1fdb1d87cc Update share-common.func 2025-09-06 11:01:35 +02:00
MacRimi b99aa55d7a Update share-common.func 2025-09-06 10:46:22 +02:00
MacRimi de20da2dad Update lxc-mount-manager.sh 2025-09-06 10:10:37 +02:00
MacRimi 9444f0a68b Update nfs_host_auto.sh 2025-09-06 08:51:41 +02:00
MacRimi 48fd223a28 Update samba.sh 2025-09-04 20:38:11 +02:00
MacRimi 0845efe419 Update samba_client.sh 2025-09-04 20:37:18 +02:00
MacRimi 57b7ba91bc Update share_menu.sh 2025-09-04 20:34:43 +02:00
MacRimi 97af8a4892 Update share_menu.sh 2025-09-04 20:31:33 +02:00
MacRimi d6f237e289 Update share_menu.sh 2025-09-04 20:30:36 +02:00
MacRimi aba7109b35 Update samba_host.sh 2025-09-04 20:28:16 +02:00
MacRimi d3ec71052e Update samba_client.sh 2025-09-04 20:26:51 +02:00
MacRimi 1be63f396b Update nfs_client.sh 2025-09-04 20:26:26 +02:00
MacRimi 9308742146 Update samba_client.sh 2025-09-04 20:23:00 +02:00
MacRimi b32241082d Update share_menu.sh 2025-09-04 20:12:24 +02:00
MacRimi 1f8504d685 update shared 2025-09-04 20:04:08 +02:00
MacRimi 97c5c48150 Update nfs_host.sh 2025-09-03 23:13:21 +02:00
MacRimi afe84dc46a Update nfs_client.sh 2025-09-03 23:10:01 +02:00
MacRimi ffafd42f03 Update nfs.sh 2025-09-03 23:06:47 +02:00
MacRimi 7dca715c91 Update nfs.sh 2025-09-03 23:04:46 +02:00
MacRimi 7695e1d8dd Update nfs.sh 2025-09-03 22:55:45 +02:00
MacRimi 84b86d1db7 Update nfs.sh 2025-09-03 22:14:29 +02:00
MacRimi bae3ef6460 Update nfs.sh 2025-09-03 22:06:34 +02:00
MacRimi 97c6ec8875 Update share-common.func 2025-09-03 16:47:03 +02:00
MacRimiandGitHub d33128dc26 Update share_menu.sh 2025-09-03 12:27:59 +02:00
MacRimiandGitHub 10bdecabb6 Update share_menu.sh 2025-09-03 12:25:35 +02:00
MacRimiandGitHub de88f530c8 Update share_menu.sh 2025-09-03 12:23:54 +02:00
MacRimiandGitHub fb511b7596 Update share_menu.sh 2025-09-03 12:22:49 +02:00
MacRimiandGitHub 322665ce91 Update share_menu.sh 2025-09-03 12:21:21 +02:00
MacRimiandGitHub baeca1fcfb Update share-common.func 2025-09-03 11:35:38 +02:00
MacRimiandGitHub 095b98c36a Update share-common.func 2025-09-03 11:28:37 +02:00
MacRimiandGitHub 29bb7e7608 Update share-common.func 2025-09-03 11:16:34 +02:00
MacRimi e3d137efba Update share-common.func 2025-09-02 22:56:46 +02:00
MacRimi 207e915393 Update share-common.func 2025-09-02 22:45:33 +02:00
MacRimi 614e629a2b Update share-common.func 2025-09-02 22:44:42 +02:00
MacRimi f35de5c749 Update share-common.func 2025-09-02 21:34:13 +02:00
MacRimi c1623bd4df Update share-common.func 2025-09-02 21:23:57 +02:00
ProxMenuxBot 8690da5017 Update helpers_cache.json 2025-09-02 18:17:01 +00:00
MacRimi 696adcdc24 Update share-common.func 2025-09-02 18:48:57 +02:00
MacRimi 2756bd06c1 Update share-common.func 2025-09-02 18:48:20 +02:00
MacRimi 4893f6ea00 Update lxc-mount-manager.sh 2025-09-02 18:45:53 +02:00
MacRimi 35a7348197 Update lxc-mount-manager.sh 2025-09-02 18:45:04 +02:00
MacRimi cdd6333d0a Update share_menu.sh 2025-09-02 18:43:27 +02:00
MacRimi 54399b5b5d Update share-common.func 2025-09-02 18:25:39 +02:00
MacRimi f6b192cc1e Update lxc-mount-manager.sh 2025-09-02 18:22:34 +02:00
MacRimi cd231b90d8 Update share-common.func 2025-09-02 17:21:09 +02:00
ProxMenuxBot 87fe788358 Update helpers_cache.json 2025-09-02 12:26:27 +00:00
MacRimi 3e9bd21ea8 update share menu 2025-09-02 00:02:16 +02:00
MacRimi b6d4029797 Update local-shared-manager.sh 2025-09-01 19:10:46 +02:00
MacRimi ec65e96148 Update share_menu.sh 2025-09-01 19:08:48 +02:00
MacRimi 926f1f971f update share menu 2025-09-01 19:06:52 +02:00
MacRimi 5d69fad73f Update share_menu.sh 2025-09-01 18:45:14 +02:00
MacRimi a796761023 Update share-common.func 2025-09-01 17:39:45 +02:00
MacRimi 5d1338e485 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-09-01 17:21:41 +02:00
MacRimi ce25a167f1 Update auto_post_install.sh 2025-09-01 17:21:39 +02:00
MacRimiandGitHub 1c44969580 Update share_menu.sh 2025-09-01 14:43:46 +02:00
MacRimiandGitHub b6e04e3ede Update share-common.func 2025-09-01 14:30:16 +02:00
ProxMenuxBot 84c26be703 Update helpers_cache.json 2025-09-01 12:26:47 +00:00
MacRimiandGitHub d201160722 Update share-common.func 2025-09-01 14:08:10 +02:00
MacRimiandGitHub e112361b43 Update share-common.func 2025-09-01 13:53:54 +02:00
MacRimiandGitHub 3e69795c9d Update lxc-mount-manager.sh 2025-09-01 13:33:48 +02:00
MacRimiandGitHub b11baf2e5d Update auto_post_install.sh 2025-09-01 12:42:46 +02:00
MacRimiandGitHub 233770b553 Update auto_post_install.sh 2025-09-01 12:41:26 +02:00
MacRimiandGitHub 187db73798 Update zimaos.sh 2025-09-01 12:20:45 +02:00
MacRimiandGitHub 0e3fc6f682 Update zimaos.sh 2025-09-01 12:15:30 +02:00
MacRimiandGitHub d11e3a4ac4 Update auto_post_install.sh 2025-08-31 23:45:37 +02:00
MacRimiandGitHub d3b4ca3e66 Update customizable_post_install.sh 2025-08-31 21:46:28 +02:00
MacRimi f37fbbfb8b Update menu share 2025-08-30 18:56:49 +02:00
MacRimi 52b7aac424 Update share-common.func 2025-08-30 18:05:02 +02:00
MacRimi d42f3f8f0c Update share-common.func 2025-08-30 18:02:20 +02:00
MacRimi 91b5c7c9bc Update share-common.func 2025-08-30 17:51:45 +02:00
MacRimi 48feebc092 Update share-common.func 2025-08-30 16:59:46 +02:00
MacRimi 14e2d66d96 Update share-common.func 2025-08-30 11:43:02 +02:00
MacRimi 10d844a195 Update share-common.func 2025-08-30 11:36:52 +02:00
MacRimi bbf91ae5d6 Update main_menu.sh 2025-08-30 09:40:42 +02:00
ProxMenuxBot cb82eda49a Update helpers_cache.json 2025-08-30 00:57:37 +00:00
MacRimi bc1dbb1c27 Update help_info_menu.sh 2025-08-30 00:17:51 +02:00
ProxMenuxBot 9496a7f1ce Update helpers_cache.json 2025-08-28 18:19:20 +00:00
ProxMenuxBot 7241fa31b4 Update helpers_cache.json 2025-08-28 12:26:37 +00:00
MacRimi fed7216436 Update share-common.func 2025-08-27 18:15:26 +02:00
MacRimi ffe7d7c4c6 Create group_manager.sh 2025-08-27 10:54:03 +02:00
MacRimi f430ac8d6c Update upgrade_pve8_to_pve9.sh 2025-08-26 19:40:38 +02:00
MacRimi 70dfd7c9a3 Update upgrade_pve8_to_pve9.sh 2025-08-26 19:25:59 +02:00
MacRimi ed3140932b Update upgrade_pve8_to_pve9.sh 2025-08-26 19:24:41 +02:00
MacRimi 3cd2bd6ce8 Update share-common.func 2025-08-26 17:46:48 +02:00
MacRimi 982bf45fc4 Update share-common.func 2025-08-26 17:37:09 +02:00
MacRimi aaba8569fc Update share-common.func 2025-08-26 17:19:11 +02:00
MacRimi 4111e15eb9 Update share-common.func 2025-08-26 17:15:47 +02:00
MacRimi 2012478f26 Update share-common.func 2025-08-26 17:05:17 +02:00
MacRimi 88869d3239 Update share-common.func 2025-08-26 17:02:24 +02:00
ProxMenuxBot f3c2549b18 Update helpers_cache.json 2025-08-26 12:28:24 +00:00
MacRimi 57e3b839d0 Update share-common.func 2025-08-26 13:56:00 +02:00
MacRimi faf3f43413 Create share-common.func 2025-08-26 13:26:22 +02:00
ProxMenuxBot 52e5bb3386 Update helpers_cache.json 2025-08-26 01:02:34 +00:00
ProxMenuxBot 89405f6670 Update helpers_cache.json 2025-08-25 18:20:02 +00:00
ProxMenuxBot 73111c4139 Update helpers_cache.json 2025-08-25 12:27:14 +00:00
ProxMenuxBot 04e9c5db8c Update helpers_cache.json 2025-08-24 12:24:09 +00:00
MacRimiandGitHub 69278902de Update customizable_post_install.sh 2025-08-24 10:55:25 +02:00
MacRimiandGitHub efa95b0858 Update customizable_post_install.sh 2025-08-24 10:29:00 +02:00
MacRimiandGitHub 660128cd5c Update customizable_post_install.sh 2025-08-24 10:28:14 +02:00
MacRimiandGitHub ef1e052e47 Update customizable_post_install.sh 2025-08-24 10:06:20 +02:00
ProxMenuxBot 0b346bc343 Update helpers_cache.json 2025-08-24 06:19:28 +00:00
MacRimi 2272eaf833 Update lxc-unprivileged-to-privileged.sh 2025-08-22 19:08:03 +02:00
MacRimi 4adee98bce new menu lxc 2025-08-22 19:05:36 +02:00
MacRimiandGitHub cbdb2c0705 Rename lxc-manual-guide.sh to lxc-manual-guide.sh 2025-08-22 19:04:10 +02:00
MacRimi 4f438aabbf update manual lxc guide 2025-08-22 19:03:08 +02:00
MacRimi b6ccc06963 Update lxc_menu.sh 2025-08-22 18:58:10 +02:00
MacRimi 5b89a15bfc menu lxc 2025-08-22 18:57:00 +02:00
MacRimi 5596ae551d Update storage_menu.sh 2025-08-22 18:34:12 +02:00
MacRimi 1360df592a Create backup_host4.sh 2025-08-22 18:17:01 +02:00
MacRimi 13684ff83c Update share_menu.sh 2025-08-22 18:10:34 +02:00
MacRimi ae88f7870e Update share_menu.sh 2025-08-22 18:08:20 +02:00
MacRimi 810b6da60c Share menu 2025-08-22 18:05:14 +02:00
ProxMenuxBot 7bdf3e08f9 Update helpers_cache.json 2025-08-21 18:19:22 +00:00
MacRimi fdad2a087f Update zimaos.sh 2025-08-21 19:39:50 +02:00
MacRimi c437a8c426 Update zimaos.sh 2025-08-21 19:36:28 +02:00
MacRimi ef861e6d1d Update zimaos.sh 2025-08-21 19:02:58 +02:00
MacRimi 928a008688 Update zimaos.sh 2025-08-21 18:59:54 +02:00
MacRimi 638a124adb Update zimaos.sh 2025-08-21 18:31:32 +02:00
MacRimi c2a63ae9bb Update zimaos.sh 2025-08-21 18:30:43 +02:00
MacRimi 28cf31e6e7 Update zimaos.sh 2025-08-21 18:27:30 +02:00
MacRimi 3cf416167d Update select_nas_iso.sh 2025-08-21 18:22:57 +02:00
MacRimi ebf03923a0 Update select_nas_iso.sh 2025-08-21 18:14:58 +02:00
MacRimi 82797d2421 Update select_nas_iso.sh 2025-08-21 18:05:45 +02:00
MacRimi 52b6be946c Create zimaos.sh 2025-08-21 18:00:58 +02:00
MacRimi dc46724d7b Update select_linux_iso.sh 2025-08-20 23:02:38 +02:00
MacRimi ed7d43b6a9 Update select_linux_iso.sh 2025-08-20 22:58:14 +02:00
MacRimi 6f3fc51278 Update system_utils.sh 2025-08-20 22:40:42 +02:00
MacRimi a446acc282 Update customizable_post_install.sh 2025-08-20 22:39:28 +02:00
MacRimi d987d639ab Update version.txt 2025-08-20 21:38:45 +02:00
MacRimi e7e180e468 Update CHANGELOG.md 2025-08-20 21:38:20 +02:00
MacRimi 76770f82cd Update system_utils.sh 2025-08-20 21:22:49 +02:00
MacRimi 4079d4fd7c Update customizable_post_install.sh 2025-08-20 21:21:13 +02:00
MacRimi ac48178369 Update lxc-privileged-to-unprivileged.sh 2025-08-20 21:10:05 +02:00
MacRimi c2e9f038ee Update lxc-privileged-to-unprivileged.sh 2025-08-20 21:04:50 +02:00
MacRimi 70220d9829 Update lxc-privileged-to-unprivileged.sh 2025-08-20 21:03:57 +02:00
MacRimi b9a1f378ec Update lxc_menu.sh 2025-08-20 20:59:58 +02:00
MacRimi f6bc090a98 Update lxc_menu.sh 2025-08-20 20:55:43 +02:00
MacRimi be519f3932 Create lxc_menu.sh 2025-08-20 20:46:51 +02:00
MacRimi 0a46f77555 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-20 20:43:56 +02:00
MacRimi 0e6cc0c7e5 Create lxc-privileged-to-unprivileged.sh 2025-08-20 20:43:54 +02:00
ProxMenuxBot 11cd425162 Update helpers_cache.json 2025-08-20 18:19:44 +00:00
MacRimi aa269688d6 Update utilities_menu.sh 2025-08-20 19:51:54 +02:00
MacRimi 4c9e94768e Update upgrade_pve8_to_pve9.sh 2025-08-20 19:48:25 +02:00
MacRimi 581157fa82 Update upgrade_pve8_to_pve9.sh 2025-08-20 19:46:30 +02:00
MacRimi e748e479cc Update utilities_menu.sh 2025-08-20 19:44:16 +02:00
MacRimi 5c9e4eea1e Update upgrade_pve8_to_pve9.sh 2025-08-20 19:41:09 +02:00
MacRimi 0c1189b233 Update pve8to9_check.sh 2025-08-20 19:37:14 +02:00
MacRimi 5ec9b82b4a Update upgrade_pve8_to_pve9.sh 2025-08-20 19:22:22 +02:00
MacRimi c84ec533da Create pve8to9_check.sh 2025-08-20 19:22:12 +02:00
MacRimi fb80c6ad7a Update upgrade_pve8_to_pve9.sh 2025-08-20 19:09:52 +02:00
ProxMenuxBot 2e3bfff6a4 Update helpers_cache.json 2025-08-19 18:18:30 +00:00
ProxMenuxBot e96ce30891 Update helpers_cache.json 2025-08-19 12:26:48 +00:00
MacRimiandGitHub 10de5b2e5f Update customizable_post_install.sh 2025-08-19 14:15:34 +02:00
MacRimiandGitHub 1966081239 Update auto_post_install.sh 2025-08-19 14:13:56 +02:00
MacRimiandGitHub b48d806d53 Update system_utils.sh 2025-08-19 13:42:42 +02:00
MacRimiandGitHub 97784d74e7 Update customizable_post_install.sh 2025-08-19 13:38:59 +02:00
ProxMenuxBot c42e92b07d Update helpers_cache.json 2025-08-19 01:03:35 +00:00
MacRimi 2c52943b54 Update customizable_post_install.sh 2025-08-18 23:36:30 +02:00
MacRimi 4ccb1902cb Update customizable_post_install.sh 2025-08-18 23:30:59 +02:00
MacRimi 349b0572cd Update customizable_post_install.sh 2025-08-18 23:23:35 +02:00
MacRimi 87fae8a9eb Update customizable_post_install.sh 2025-08-18 23:18:26 +02:00
ProxMenuxBot a77a097f47 Update helpers_cache.json 2025-08-18 18:20:28 +00:00
MacRimiandGitHub a84d81143e Update auto_post_install.sh 2025-08-18 15:10:23 +02:00
MacRimiandGitHub d9cee50ef3 Update auto_post_install.sh 2025-08-18 15:08:56 +02:00
MacRimiandGitHub 0fc414e5e9 Update customizable_post_install.sh 2025-08-18 15:08:25 +02:00
ProxMenuxBot e18f20ce4c Update helpers_cache.json 2025-08-18 12:29:04 +00:00
MacRimiandGitHub c12af4060c Update system_utils.sh 2025-08-18 14:20:47 +02:00
MacRimiandGitHub 9992ea0dee Update customizable_post_install.sh 2025-08-18 14:15:19 +02:00
MacRimiandGitHub b8310f1c5d Update main_menu.sh 2025-08-18 09:37:06 +02:00
MacRimi 78f66af702 Update auto_post_install.sh 2025-08-17 15:16:25 +02:00
MacRimi 4d7564094e Update auto_post_install.sh 2025-08-17 15:14:46 +02:00
MacRimi 370f4694d1 Update customizable_post_install.sh 2025-08-17 15:14:20 +02:00
MacRimi fc7c740691 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-17 15:05:42 +02:00
MacRimiandGitHub 8e1f955519 Update auto_post_install.sh 2025-08-17 15:04:27 +02:00
MacRimi aa3d16d981 Update auto_post_install.sh 2025-08-17 15:02:42 +02:00
MacRimiandGitHub 5447f0e4df Update auto_post_install.sh 2025-08-17 15:00:06 +02:00
MacRimi 97294df208 Update customizable_post_install.sh 2025-08-17 14:21:37 +02:00
MacRimi c6f53629da Update auto_post_install.sh 2025-08-17 14:20:58 +02:00
MacRimi fcba907658 Update upgrade_pve8_to_pve9.sh 2025-08-17 13:24:32 +02:00
MacRimiandGitHub f481df7b8d Update configure_igpu_lxc.sh 2025-08-17 13:08:39 +02:00
MacRimiandGitHub 81079a35d9 Update uninstall-tools.sh 2025-08-17 11:23:13 +02:00
MacRimiandGitHub bda344c382 Update uninstall-tools.sh 2025-08-17 11:01:21 +02:00
MacRimiandGitHub c4ebc396af Update customizable_post_install.sh 2025-08-17 10:36:03 +02:00
MacRimiandGitHub 4cdbf1231b Update customizable_post_install.sh 2025-08-16 18:32:19 +02:00
MacRimiandGitHub 92db58a9f6 Update auto_post_install.sh 2025-08-16 18:31:10 +02:00
MacRimiandGitHub 5f07f47308 Update configure_igpu_lxc.sh 2025-08-16 18:12:14 +02:00
MacRimiandGitHub 2132ae79a6 Update configure_igpu_lxc.sh 2025-08-16 17:34:34 +02:00
MacRimiandGitHub bda7834a4f Update disk-passthrough_ct.sh 2025-08-16 17:22:33 +02:00
MacRimiandGitHub 7693f313c4 Update disk-passthrough_ct.sh 2025-08-16 17:13:45 +02:00
MacRimiandGitHub d2200a64e0 Update upgrade_pve8_to_pve9.sh 2025-08-16 17:04:59 +02:00
MacRimiandGitHub a5c46ab837 Update upgrade_pve8_to_pve9.sh 2025-08-16 16:43:44 +02:00
MacRimiandGitHub a389282e23 Update upgrade_pve8_to_pve9.sh 2025-08-16 11:54:11 +02:00
MacRimiandGitHub 1f90b5b739 Update upgrade_pve8_to_pve9.sh 2025-08-16 11:46:09 +02:00
MacRimiandGitHub 2c59500046 Update configure_igpu_lxc.sh 2025-08-16 11:15:41 +02:00
MacRimiandGitHub a59a056e12 Update configure_igpu_lxc.sh 2025-08-16 11:11:43 +02:00
ProxMenuxBot 235364013b Update helpers_cache.json 2025-08-16 01:03:53 +00:00
MacRimi 1049ac6eac Update upgrade_pve8_to_pve9.sh 2025-08-15 12:15:42 +02:00
MacRimi 04dc7af25c Create remove-banner-pve9.sh 2025-08-15 12:06:54 +02:00
MacRimi f62ea3ad04 update remove-banner-pve9.sh 2025-08-15 12:05:24 +02:00
MacRimi 14c75f2cd9 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2025-08-15 11:15:17 +02:00
MacRimi 9ae68b9653 Update customizable_post_install.sh 2025-08-15 10:22:57 +02:00
MacRimi b7ab4c4568 Update customizable_post_install.sh 2025-08-15 10:13:44 +02:00
MacRimi 7d0b3a0c87 Update system_utils.sh 2025-08-15 09:55:22 +02:00
MacRimi 0d38f7f290 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-15 09:54:02 +02:00
MacRimi 12e5ef4231 Update customizable_post_install.sh 2025-08-15 09:54:00 +02:00
ProxMenuxBot f3aa1f7414 Update helpers_cache.json 2025-08-15 01:06:47 +00:00
MacRimi f2eaec6e02 Update upgrade_pve8_to_pve9.sh 2025-08-14 20:08:22 +02:00
MacRimi 0654a3ed55 Update upgrade_pve8_to_pve9.sh 2025-08-14 20:03:47 +02:00
MacRimi 9a27138d96 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2025-08-14 19:46:13 +02:00
MacRimi b3c9f71c02 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2025-08-14 19:41:22 +02:00
MacRimi e9c9b957db Delete upgrade_pve8_topve9.sh 2025-08-14 19:39:36 +02:00
MacRimi 29cdf6fa48 Create upgrade_pve8_to_pve9.sh 2025-08-14 19:39:12 +02:00
MacRimi 8466a8e21e Update upgrade_pve8_topve9.sh 2025-08-14 19:37:15 +02:00
MacRimi 1523b6b8a8 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2025-08-14 19:34:42 +02:00
MacRimi 33205e1008 Create upgrade_pve8_topve9.sh 2025-08-14 19:24:29 +02:00
MacRimi 537af385f8 Update main menu PVE 9 2025-08-14 17:58:59 +02:00
MacRimi 7259b0a850 Update install_proxmenux.sh 2025-08-14 17:52:57 +02:00
MacRimi 11fbfda6bf Update cache.json 2025-08-13 22:46:58 +02:00
MacRimi 4f0353d0fb Create proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2025-08-13 22:46:38 +02:00
MacRimi a605d68d73 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-13 20:29:26 +02:00
MacRimi 237b7fbf1b Update cache.json 2025-08-13 20:29:24 +02:00
ProxMenuxBot 4a7e21f6b4 Update helpers_cache.json 2025-08-13 18:19:42 +00:00
MacRimi b7017573b8 Update update-pve8.sh 2025-08-13 19:45:31 +02:00
MacRimi a98b087c5d Update log2RAM 2025-08-13 15:55:22 +02:00
MacRimiandGitHub 161c840136 Update utils.sh 2025-08-11 08:57:26 +02:00
MacRimi 4dd2abc202 Update utils.sh 2025-08-10 21:56:07 +02:00
MacRimi cc0e9f61a7 Update utils.sh 2025-08-10 21:43:05 +02:00
MacRimi 21a658f1f4 Update utils.sh 2025-08-10 21:23:19 +02:00
MacRimi b99f391c2a Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-10 21:09:25 +02:00
MacRimi 9abe25b91a Update README.md 2025-08-10 21:09:01 +02:00
ProxMenuxBot 2531fc6dac Update helpers_cache.json 2025-08-09 12:25:42 +00:00
ProxMenuxBot e5551cb179 Update helpers_cache.json 2025-08-09 01:05:21 +00:00
MacRimi 4728b7a8b7 Update utils.sh 2025-08-08 18:26:32 +02:00
ProxMenuxBot 2863921e15 Update helpers_cache.json 2025-08-08 12:29:54 +00:00
ProxMenuxBot b93668edfe Update helpers_cache.json 2025-08-08 06:23:01 +00:00
ProxMenuxBot 8ae91b8c31 Update helpers_cache.json 2025-08-07 18:21:56 +00:00
MacRimi 894a23d701 Update common-functions.sh 2025-08-07 20:10:36 +02:00
MacRimi 3598906cbd Update common-functions.sh 2025-08-07 19:37:28 +02:00
MacRimi 75c12e2d4b update common funtions 2025-08-07 19:24:39 +02:00
MacRimi d6e0519a3d Update common-functions.sh 2025-08-07 19:08:44 +02:00
MacRimi 41efc71626 Update update-pve.sh 2025-08-07 19:05:52 +02:00
MacRimi 6e3d97f472 Update common-functions.sh 2025-08-07 19:05:23 +02:00
MacRimi 9d58d02522 Update proxmox_update.sh 2025-08-07 18:11:32 +02:00
MacRimi fe86396b21 Update proxmox_update.sh 2025-08-07 18:08:37 +02:00
MacRimi 97994f7632 Update update-pve.sh 2025-08-07 17:58:24 +02:00
MacRimi 33d63457b3 Update update-pve8.sh 2025-08-07 17:58:00 +02:00
MacRimiandGitHub ed36d9e953 Update proxmox_update.sh 2025-08-07 15:49:22 +02:00
MacRimiandGitHub 9a478d74d2 Update proxmox_update.sh 2025-08-07 15:45:46 +02:00
MacRimiandGitHub 72d72544a4 Update update-pve.sh 2025-08-07 15:31:03 +02:00
MacRimiandGitHub 4bbbe81182 Update update-pve.sh 2025-08-07 15:05:54 +02:00
MacRimiandGitHub a0af0c2492 Update update-pve.sh 2025-08-07 14:47:58 +02:00
MacRimiandGitHub ce7d3e4702 Update update-pve.sh 2025-08-07 14:30:48 +02:00
MacRimiandGitHub 1bb4ca8541 Update common-functions.sh 2025-08-07 14:23:33 +02:00
MacRimiandGitHub ea65445772 Update update-pve.sh 2025-08-07 14:02:02 +02:00
MacRimiandGitHub 972db8fcea Update update-pve8.sh 2025-08-07 13:04:05 +02:00
MacRimiandGitHub a3c12631f0 Update auto_post_install.sh 2025-08-07 11:16:24 +02:00
MacRimi 3cadfd08d8 Update install_proxmenux.sh 2025-08-07 08:04:04 +02:00
MacRimi 104f3de013 Update version.txt 2025-08-06 23:10:08 +02:00
MacRimi 713b41bd52 Update CHANGELOG.md 2025-08-06 23:09:36 +02:00
MacRimi 253093fa2f Update update-pve.sh 2025-08-06 23:08:53 +02:00
MacRimi f36af5af64 Update update-pve.sh 2025-08-06 22:37:13 +02:00
MacRimi 97b6c0e44d Delete common-functions_.sh 2025-08-06 22:14:12 +02:00
MacRimi c4f6dabd4d Update system_utils.sh 2025-08-06 22:02:40 +02:00
MacRimi d1c8aeb25d update pve 8 2025-08-06 22:01:33 +02:00
MacRimi 6e1cb2e0fe Update common-functions.sh 2025-08-06 21:47:53 +02:00
MacRimi da9762f60e Update update-pve.sh 2025-08-06 21:45:09 +02:00
MacRimi 27affdec14 Update common-functions.sh 2025-08-06 21:17:06 +02:00
MacRimi 433a19e46a Update customizable_post_install.sh 2025-08-06 20:10:38 +02:00
MacRimi da9db9d3d1 Update update-pve.sh 2025-08-06 20:09:01 +02:00
MacRimi ed4b0eba2f Update auto_post_install.sh 2025-08-06 20:04:52 +02:00
MacRimi 615aecf80f Update common funtions 2025-08-06 20:04:12 +02:00
MacRimi 4622d1a610 Update update-pve8.sh 2025-08-06 19:31:15 +02:00
MacRimi f1b80d8f57 Update customizable_post_install.sh 2025-08-06 19:22:11 +02:00
MacRimi e76e303383 Update update-pve8.sh 2025-08-06 19:20:03 +02:00
MacRimi 97133c3fcb Update auto_post_install.sh 2025-08-06 19:17:45 +02:00
MacRimi 450610b6e6 Update update-pve8.sh 2025-08-06 19:14:32 +02:00
MacRimi 4dc3fd92cc Update update-pve8.sh 2025-08-06 19:04:32 +02:00
MacRimi f4b5e7c044 Create update-pve8.sh 2025-08-06 18:47:41 +02:00
MacRimi 6c0b2a468d Update common-functions.sh 2025-08-06 18:47:16 +02:00
MacRimi e33f724f1b Create common-functions.sh 2025-08-06 18:17:51 +02:00
MacRimi b0d5562917 Update remove-banner-pve8.sh 2025-08-06 17:45:22 +02:00
MacRimi eecf7a2194 Update remove subscription banner PVE 8.4.9 2025-08-06 17:17:00 +02:00
MacRimi 54fd8a0332 Update remove-banner-pve8.sh 2025-08-06 17:12:16 +02:00
MacRimi b6ca91980b global update 2025-08-06 16:50:16 +02:00
MacRimiandGitHub 6af7e2d749 Update remove-banner-pve8.sh 2025-08-06 15:27:26 +02:00
MacRimiandGitHub 86d334c204 Update remove-banner-pve9.sh 2025-08-06 15:21:31 +02:00
MacRimiandGitHub 585a4fa449 Update remove-banner-pve9.sh 2025-08-06 15:19:56 +02:00
MacRimiandGitHub 7438073e7e Update remove-banner-pve8.sh 2025-08-06 15:19:33 +02:00
MacRimiandGitHub 6e808ae35a Update remove-banner-pve8.sh 2025-08-06 14:32:14 +02:00
MacRimiandGitHub 99ec64e852 Create remove-banner-pve9.sh 2025-08-06 14:31:19 +02:00
MacRimiandGitHub eeac63c0a5 Create remove-banner-pve8.sh 2025-08-06 14:23:22 +02:00
MacRimi 5d5a3c3301 update funtions to pve9 2025-08-05 20:30:29 +02:00
ProxMenuxBot 31e9730236 Update helpers_cache.json 2025-08-05 12:31:00 +00:00
MacRimiandGitHub 69b32a02ff Update utils.sh 2025-08-05 09:46:30 +02:00
ProxMenuxBot a222df8176 Update helpers_cache.json 2025-08-04 12:30:37 +00:00
MacRimiandGitHub 7f4c99be60 Update customizable_post_install.sh 2025-08-04 11:29:43 +02:00
MacRimiandGitHub ccff657a62 Update auto_post_install.sh 2025-08-04 11:29:28 +02:00
MacRimiandGitHub fb258499e1 Update customizable_post_install.sh 2025-08-04 11:22:10 +02:00
MacRimiandGitHub 79c6d6c742 Update customizable_post_install.sh 2025-08-04 11:20:38 +02:00
MacRimiandGitHub 80d9d5480c Update customizable_post_install.sh 2025-08-04 11:19:41 +02:00
MacRimiandGitHub 958a553922 Update auto_post_install.sh 2025-08-04 11:19:27 +02:00
MacRimiandGitHub a44bbc3513 Update customizable_post_install.sh 2025-08-04 11:16:52 +02:00
MacRimiandGitHub d7f2f4a3e7 Update customizable_post_install.sh 2025-08-04 11:15:59 +02:00
MacRimiandGitHub 073566a23e Update auto_post_install.sh 2025-08-04 11:11:14 +02:00
MacRimiandGitHub 590aecfcf1 Update customizable_post_install.sh 2025-08-04 11:11:00 +02:00
MacRimiandGitHub 77ab52310e Update auto_post_install.sh 2025-08-04 11:03:31 +02:00
MacRimiandGitHub 2c2ccddbe4 Update customizable_post_install.sh 2025-08-04 11:02:23 +02:00
MacRimiandGitHub 87062db9d5 Update customizable_post_install.sh 2025-08-04 10:44:58 +02:00
MacRimiandGitHub b74701dbc5 Update system_utils.sh 2025-08-04 08:52:55 +02:00
MacRimiandGitHub a88db8830b Update customizable_post_install.sh 2025-08-04 08:34:40 +02:00
ProxMenuxBot 36cd83c796 Update helpers_cache.json 2025-08-04 01:16:40 +00:00
MacRimiandGitHub a039c93c05 Update customizable_post_install.sh 2025-08-03 23:13:36 +02:00
MacRimiandGitHub 57b4ade3be Update auto_post_install.sh 2025-08-03 23:12:32 +02:00
MacRimiandGitHub 87ce6cfa98 Update auto_post_install.sh 2025-08-03 23:08:06 +02:00
MacRimiandGitHub 6a99c8c81d Update customizable_post_install.sh 2025-08-03 23:06:31 +02:00
ProxMenuxBot 46e8188d5a Update helpers_cache.json 2025-08-02 18:19:25 +00:00
MacRimiandGitHub 3c990df1fe Actualizar main_menu.sh 2025-08-02 12:02:10 +02:00
ProxMenuxBot 8969bc5aa6 Update helpers_cache.json 2025-08-02 01:07:27 +00:00
MacRimiandGitHub 45e8ca8d42 Update customizable_post_install.sh 2025-08-01 08:28:12 +02:00
MacRimi 39930153c9 Update customizable_post_install.sh 2025-08-01 07:25:44 +02:00
MacRimi 5890e46db3 Update auto_post_install.sh 2025-08-01 06:57:21 +02:00
MacRimi c5c06a08ba Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-08-01 06:50:09 +02:00
MacRimi 2a0e677a89 Update proxmox_update.sh 2025-08-01 06:50:07 +02:00
ProxMenuxBot 8f7a968dc9 Update helpers_cache.json 2025-07-31 18:21:21 +00:00
MacRimi 20cfc50448 Update select_linux_iso.sh 2025-07-31 19:39:03 +02:00
MacRimi 4bf019ec7e Update jd2.sh 2025-07-31 19:11:18 +02:00
MacRimi 57fe45484c Update jd2.sh 2025-07-31 18:00:30 +02:00
MacRimi 7744f4ed76 Update jd2.sh 2025-07-31 17:43:14 +02:00
MacRimiandGitHub a43e81e229 Update jd2.sh 2025-07-31 15:14:00 +02:00
MacRimiandGitHub f6ad7e250b Update jd2.sh 2025-07-31 14:45:02 +02:00
MacRimiandGitHub 0f2b0482ec Create jd2.sh 2025-07-31 14:27:12 +02:00
MacRimiandGitHub 21d850d39e Rename jd2.sh to jd2_.sh 2025-07-31 14:26:46 +02:00
MacRimiandGitHub d3f2e42301 Create jd2.sh 2025-07-31 12:50:19 +02:00
MacRimi df68154f10 Update disk-passthrough_ct.sh 2025-07-30 18:43:54 +02:00
MacRimi f8ebf03afd Update system_utils.sh 2025-07-30 18:18:13 +02:00
MacRimi 23f8b97319 Update customizable_post_install.sh 2025-07-30 17:59:12 +02:00
MacRimi d712054353 Update customizable_post_install.sh 2025-07-30 17:55:51 +02:00
MacRimi 58da896b14 Update system_utils.sh 2025-07-30 17:54:15 +02:00
MacRimi 8db57bda6e Update install utilities 2025-07-30 17:34:44 +02:00
MacRimi 53f29ec710 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-07-29 22:22:38 +02:00
MacRimi 43a8fc0e86 Update customizable_post_install.sh 2025-07-29 22:22:36 +02:00
ProxMenuxBot 7f2adb068e Update helpers_cache.json 2025-07-29 18:21:40 +00:00
MacRimi 218ae9f9bf Update uninstall-tools.sh 2025-07-29 20:15:55 +02:00
MacRimi 350c03874d Update customizable_post_install.sh 2025-07-29 20:09:54 +02:00
MacRimi 575c0e5bf9 Update customizable_post_install.sh 2025-07-29 19:58:49 +02:00
ProxMenuxBot 3a890ba2c7 Update helpers_cache.json 2025-07-29 12:30:07 +00:00
ProxMenuxBot 2a345f4869 Update helpers_cache.json 2025-07-28 12:30:05 +00:00
ProxMenuxBot 658ebbd84d Update helpers_cache.json 2025-07-27 12:26:49 +00:00
ProxMenuxBot 0c54ade367 Update helpers_cache.json 2025-07-26 17:08:21 +00:00
ProxMenuxBot f16ba64026 Update helpers_cache.json 2025-07-25 12:28:04 +00:00
ProxMenuxBot d72127aaeb Update helpers_cache.json 2025-07-25 01:09:29 +00:00
ProxMenuxBot 40e0b1291c Update helpers_cache.json 2025-07-24 18:20:25 +00:00
MacRimi 0fae7f0166 Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2025-07-22 22:34:12 +02:00
MacRimi 6f916a4c32 Update customizable_post_install.sh 2025-07-22 22:33:58 +02:00
ProxMenuxBot 41979d5389 Update helpers_cache.json 2025-07-22 18:20:56 +00:00
MacRimi 0d51205bd6 Update customizable_post_install.sh 2025-07-22 19:10:35 +02:00
MacRimi 416dd52a30 Update fastfetch 2025-07-22 19:03:41 +02:00
MacRimi 850e45b9a5 Update uninstall-tools.sh 2025-07-22 18:54:14 +02:00
MacRimi 040d2ca7f6 Update customizable_post_install.sh 2025-07-22 18:39:48 +02:00
ProxMenuxBot e173072622 Update helpers_cache.json 2025-07-22 12:29:23 +00:00
MacRimiandGitHub 991dd80382 Update select_linux_iso.sh 2025-07-22 12:02:38 +02:00
MacRimiandGitHub 5fc5d02134 Update auto_post_install.sh 2025-07-22 09:27:40 +02:00
MacRimiandGitHub 0f51256add Update auto_post_install.sh 2025-07-22 09:22:59 +02:00
ProxMenuxBot a78860dbc4 Update helpers_cache.json 2025-07-21 18:20:56 +00:00
MacRimi f655a3c52d Update auto_post_install.sh 2025-07-20 20:03:12 +02:00
MacRimi b69aebd5be update post-install menu 2025-07-20 19:36:59 +02:00
MacRimiandGitHub 769a7c391f Update customizable_post_install.sh 2025-07-20 14:18:06 +02:00
MacRimi 9a4d55aa36 Update customizable_post_install.sh 2025-07-20 12:34:21 +02:00
MacRimi e776acfbab Update CHANGELOG.md 2025-07-20 12:08:37 +02:00
MacRimiandGitHub b4f58286b4 Update network_menu.sh 2025-07-20 11:50:04 +02:00
MacRimiandGitHub 59779cc931 Update network_menu.sh 2025-07-20 11:46:26 +02:00
MacRimiandGitHub 567bcecc80 Update network_menu.sh 2025-07-20 11:42:25 +02:00
MacRimiandGitHub 0ca87dc29b Update network_menu.sh 2025-07-20 11:38:54 +02:00
MacRimiandGitHub 6e0824e357 Update customizable_post_install.sh 2025-07-20 11:27:08 +02:00
MacRimiandGitHub 9a8a620658 Update uninstall-tools.sh 2025-07-20 11:17:33 +02:00
MacRimiandGitHub eb1db3120d Update auto_post_install.sh 2025-07-20 10:55:04 +02:00
MacRimiandGitHub 98a9225c32 Update customizable_post_install.sh 2025-07-20 02:27:46 +02:00
MacRimiandGitHub 1b8fb766a8 Update auto_post_install.sh 2025-07-20 02:26:26 +02:00
MacRimiandGitHub c28ef3ec3b Update auto_post_install.sh 2025-07-20 02:12:27 +02:00
MacRimiandGitHub fab3f0630c Update customizable_post_install.sh 2025-07-20 02:09:11 +02:00
MacRimiandGitHub d2499ad157 Update customizable_post_install.sh 2025-07-20 01:55:47 +02:00
MacRimiandGitHub 724a37bbf4 Update auto_post_install.sh 2025-07-20 01:51:14 +02:00
MacRimi c5f1c30b1c Update disk-passthrough_ct.sh 2025-07-19 18:28:28 +02:00
MacRimiandGitHub e90363df71 Update customizable_post_install.sh 2025-07-19 17:43:31 +02:00
MacRimiandGitHub 0932008619 Update auto_post_install.sh 2025-07-19 17:40:52 +02:00
MacRimiandGitHub a24e00ad5a Update auto_post_install.sh 2025-07-19 17:25:43 +02:00
MacRimiandGitHub fab938055f Update auto_post_install.sh 2025-07-19 17:19:06 +02:00
MacRimiandGitHub b2026b0dac Update auto_post_install.sh 2025-07-19 17:06:33 +02:00
MacRimiandGitHub c1c742084e Update auto_post_install.sh 2025-07-19 17:02:38 +02:00
MacRimiandGitHub 7140f590cf Update uninstall-tools.sh 2025-07-19 16:52:13 +02:00
MacRimiandGitHub dcc26ad666 Update customizable_post_install.sh 2025-07-19 16:43:14 +02:00
MacRimiandGitHub 70f1ecad49 Update customizable_post_install.sh 2025-07-19 16:38:55 +02:00
ProxMenuxBot 03c7383b54 Update helpers_cache.json 2025-07-17 18:20:17 +00:00
ProxMenuxBot d3734971cc Update helpers_cache.json 2025-07-14 18:20:49 +00:00
ProxMenuxBot cff8358b3e Update helpers_cache.json 2025-07-14 12:28:40 +00:00
ProxMenuxBot 42d691c4ce Update helpers_cache.json 2025-07-13 18:18:07 +00:00
MacRimi 33dcbe8b5a Update install_coral_lxc.sh 2025-07-12 18:34:49 +02:00
MacRimi 980c7a4390 Update uupdump_creator.sh 2025-07-11 21:20:18 +02:00
MacRimi 1b7f881d5a Update uupdump_creator.sh 2025-07-11 20:20:32 +02:00
MacRimi 8635e2cf67 Update uupdump_creator.sh 2025-07-11 20:13:33 +02:00
MacRimi e36bc8bab2 Update uupdump_creator.sh 2025-07-11 20:09:16 +02:00
MacRimi 693da7733f Update uupdump_creator.sh 2025-07-11 19:59:34 +02:00
MacRimi 6b6128d92d Update uupdump_creator.sh 2025-07-11 17:28:02 +02:00
MacRimi 64586a44b4 Update customizable_post_install.sh 2025-07-10 19:25:50 +02:00
MacRimi 2d28ecca32 Update network_menu.sh 2025-07-10 19:16:16 +02:00
MacRimi 92f3edb337 Update network_menu.sh 2025-07-10 19:11:32 +02:00
MacRimi 32f8edecdd Update network_menu.sh 2025-07-10 17:09:59 +02:00
MacRimiandGitHub 14b061cd28 Update cache.json 2025-07-10 12:37:59 +02:00
MacRimiandGitHub aeb90cbdd2 Update network_menu.sh 2025-07-10 12:31:51 +02:00
MacRimi e2a0b627b2 Update cache.json 2025-07-09 22:29:03 +02:00
MacRimi de5eb0d914 Update version.txt 2025-07-09 22:07:26 +02:00
2253 changed files with 755668 additions and 37116 deletions
@@ -0,0 +1,117 @@
title: "[Prompt] "
labels:
- custom-prompt
- community
body:
- type: markdown
attributes:
value: |
## Share Your Custom Prompt
Thank you for sharing your custom prompt with the community!
**Title format suggestion:** Include the provider in the title for easy filtering.
Example: `[Gemini] Clean Spanish - Structured, no emojis`
This helps others find prompts for their specific AI provider.
- type: dropdown
id: provider
attributes:
label: AI Provider
description: Which AI provider did you test this prompt with?
options:
- OpenAI
- Gemini
- Groq
- Ollama
- Anthropic
- OpenRouter
- DeepSeek
- Other
validations:
required: true
- type: input
id: model
attributes:
label: Model
description: The specific model you tested with
placeholder: "e.g., gpt-4o-mini, gemini-2.0-flash, llama3.2:3b"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Describe what your prompt does, main features, and output language
placeholder: |
This prompt generates concise notifications in Spanish.
Features:
- Brief format (2-3 lines)
- Includes severity indicators
- Uses emojis for visual clarity
validations:
required: true
- type: textarea
id: prompt-content
attributes:
label: Prompt Content
description: Paste your complete custom prompt here
render: text
placeholder: |
You are a notification formatter for ProxMenux Monitor.
Your task is to...
RULES:
1. ...
2. ...
OUTPUT FORMAT:
[TITLE]
...
[BODY]
...
validations:
required: true
- type: textarea
id: example-output
attributes:
label: Example Output
description: Show an example of how a notification looks with your prompt
placeholder: |
**Input notification:**
CPU usage high on node pve01
**Output with this prompt:**
pve01: High CPU Usage
CPU at 95% for 5 minutes. Check running processes.
validations:
required: false
- type: textarea
id: additional-notes
attributes:
label: Additional Notes
description: Any tips, variations, or known limitations
placeholder: |
- Works best with models that support system prompts
- May need adjustment for very long notifications
- Tested with Proxmox VE 8.x
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
options:
- label: I have tested this prompt and it works correctly
required: true
- label: I am sharing this prompt for the community to use freely
required: true
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: MacRimi # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: macrimi # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug Report
about: Report a problem in the project
title: "[BUG] Describe the issue"
labels: bug
assignees: 'MacRimi'
---
## Description
Describe the bug clearly and concisely.
## Steps to Reproduce
1. ...
2. ...
3. ...
## Expected Behavior
What should happen?
## Screenshots (Required)
Add images to help illustrate the issue.
## Environment
- Operating system:
- Software version:
- Other relevant details:
## Additional Information
Add any other context about the problem here.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Soporte General
url: https://github.com/MacRimi/ProxMenux/discussions
about: If your request is neither a bug nor a feature, please use Discussions.
+19
View File
@@ -0,0 +1,19 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[FEATURE] Describe your proposal"
labels: enhancement
assignees: 'MacRimi'
---
## Description
Explain the feature you are proposing.
## Motivation
Why is this improvement important? What problem does it solve?
## Alternatives Considered
Are there other solutions you have thought about?
## Additional Information
Add any extra details that help understand your proposal.
+105
View File
@@ -0,0 +1,105 @@
name: OCI validation report
description: Report a real test of an application from the OCI manager catalog.
title: "OCI validation: "
labels: ["oci-validation"]
body:
- type: markdown
attributes:
value: |
One report describes one application and the scenario you tested. Once it is reviewed, the application is recorded as verified: it shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md](https://github.com/MacRimi/ProxMenux/blob/develop/oci/VALIDATION.md) with your GitHub user, the date and a link to this report.
Reports that an application does not work are just as useful: they tell others what to expect and what needs fixing.
- type: input
id: application
attributes:
label: Application
description: Name or ID as it appears in the OCI catalog, for example "Glances" or "glances".
validations:
required: true
- type: input
id: version
attributes:
label: Image version
description: Version or tag shown by the installer.
validations:
required: true
- type: input
id: digest
attributes:
label: Image digest
description: Shown in the App tab of the container in ProxMenux Monitor, after the build date — for example "2026-09-06 · b1f339cf". The full sha256 digest is also accepted.
placeholder: b1f339cf
validations:
required: true
- type: input
id: proxmox
attributes:
label: Proxmox VE version
placeholder: "9.1"
validations:
required: true
- type: dropdown
id: install
attributes:
label: Install mode
options:
- Default configuration
- Advanced configuration
validations:
required: true
- type: dropdown
id: storage
attributes:
label: Data storage
options:
- Volume on Proxmox storage
- Host directory
- Both
- No persistent data
validations:
required: true
- type: dropdown
id: network
attributes:
label: Network
options:
- DHCP
- Static address
validations:
required: true
- type: dropdown
id: gpu
attributes:
label: GPU
options:
- No GPU
- Intel
- AMD
- NVIDIA
validations:
required: true
- type: checkboxes
id: checks
attributes:
label: What was checked
options:
- label: Installs cleanly
- label: The application responds (web interface or service)
- label: Keeps working after a container restart
- label: Survives a Proxmox backup and restore
- label: An image update keeps the data
- type: dropdown
id: result
attributes:
label: Result
options:
- Works
- Works with problems
- Does not work
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Anything worth knowing. If something failed, what you saw and how to reproduce it.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"dependencies": {
"lucide-react": "0.454.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"typescript": "5.9.3"
}
},
"node_modules/lucide-react": {
"version": "0.454.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "proxmenux-offline-node-tests",
"private": true,
"version": "1.0.0",
"description": "Only the dependencies of the qualified tests/*.cjs CI lane",
"dependencies": {
"typescript": "5.9.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"lucide-react": "0.454.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
# GitHub users who can validate an OCI validation report by commenting
# /validated on it. A reviewer does not validate their own report.
MacRimi
Vaso73
f3rs3n
File diff suppressed because it is too large Load Diff
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""
Auto-translate missing keys in AppImage/messages/<locale>/common.json
against the English source (AppImage/messages/en/common.json).
Guardrails:
- Non-empty string translations are PRESERVED, including values equal
to English. --refresh explicitly opts into replacing valid strings.
- Arrays, objects and non-string leaves retain their JSON types. Array
entries are translated individually, never as serialized Python text.
- Preflight all locales; skip locales with existing type mismatches without
provider calls or writes, including with --refresh. Valid locales proceed,
but any skipped locale makes the run exit 1 (partial failure).
The diagnostic identifies the locale
and JSON Pointer requiring manual reconciliation. No automatic migration
of serialized arrays is attempted: parseability or equal length cannot
prove correspondence to current English indices. Recover human text only
after checking its original source/order; do not delete it to force refill.
Null string leaves remain eligible for filling, as in earlier versions.
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
are extracted before translation and restored afterwards, so the
interpolation contract stays intact regardless of what the
translation provider does with the surrounding text.
- `sk` IS translated by default too. Guardrail #1 protects every key
Vaso73 has curated by hand; auto-translation only fills the keys
that are still on the English fallback in sk.
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
successful generation. Exit 1 for skipped locales prevents that step, even
when valid locales made local progress. This does not restore automatic
publication for the default locale set until mismatches are reconciled or
workflow policy is separately changed. Invalid JSON syntax still aborts
preflight globally; per-locale skipping covers parsed catalog type mismatches.
Reuses the same translation providers as build_translation_cache.py so
the CI environment (googletrans pinning, AppImage provider) stays
identical.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
# Reuse providers + cleaner from the CLI translation script.
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
protect_technical_terms,
restore_technical_terms,
translate_appimage,
translate_google_web,
translate_googletrans,
)
# sk IS included in the default. Guardrail #1 (never overwrite a key
# whose target value differs from EN) protects every string Vaso73 has
# already curated by hand — auto-translation only ever touches keys
# that are still on the English fallback in sk. Trade-off accepted:
# users on sk see a decent auto-translation for new keys instead of raw
# English while the human maintainer catches up, and Vaso73 keeps full
# ownership of the wording via follow-up PRs.
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
# codebase uses only the simple form.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
CatalogPath = tuple[str | int, ...]
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
"""Keep typed path segments and empty container markers, without coercion."""
if isinstance(node, dict):
out = {prefix: {}}
children = node.items()
elif isinstance(node, list):
out = {prefix: []}
children = enumerate(node)
else:
return {prefix: node}
for key, value in children:
out.update(flatten(value, prefix + (key,)))
return out
def unflatten(flat: dict[CatalogPath, object]) -> object:
"""Rebuild a tree from typed paths; parents precede children by depth."""
nodes = {}
for path in sorted(flat, key=len):
value = flat[path]
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
if path:
parent = nodes[path[:-1]]
key = path[-1]
if isinstance(parent, list):
while len(parent) <= key:
parent.append(None)
parent[key] = nodes[path]
return nodes[()]
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Swap each ``{xxx}`` for an opaque token that machine translators
tend to leave alone. Order is preserved so restore_placeholders can
walk it linearly."""
placeholders: list[str] = []
def _swap(match: re.Match) -> str:
placeholders.append(match.group(0))
# No underscores: a local model splits on them and returns
# "PMX PH 0", so the placeholder never comes back.
return f"PMXPH{len(placeholders) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), placeholders
def restore_placeholders(text: str, placeholders: list[str]) -> str:
"""Reverse of ``protect_placeholders``. If the provider mangled a
token beyond recognition we leave the mangled form in place — the
fallback assignment (target = existing or EN) upstream catches
the worst case."""
for i, original in enumerate(placeholders):
text = text.replace(f"PMXPH{i:03d}", original)
return text
def translate_argos(text: str, lang: str) -> str:
"""LibreTranslate's engine, running locally.
The remote providers answer a few thousand strings and then start
refusing, and a refusal here hands back the English and stores it as
a translation. Local models have no quota and no silent failure mode,
which is what makes them the right choice for filling a catalogue by
hand before it is committed.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", lang)
def translate_one(
text: str,
lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
"""Dispatch to the correct provider. Reuses the same
implementations as build_translation_cache.py so there is exactly
one place to fix if a provider changes upstream."""
if provider == "googletrans":
raw = translate_googletrans(text, lang, context)
elif provider == "google-web":
raw = translate_google_web(text, lang, context, timeout)
elif provider == "appimage":
raw = translate_appimage(text, lang, context, timeout, appimage_path)
elif provider == "argos":
raw = translate_argos(text, lang)
else:
raise ValueError(f"Unknown provider: {provider}")
return clean_translation(raw) or text
def read_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
def write_json(path: Path, data: dict) -> None:
"""Write with indent=2, no sort_keys — we want to keep the same
top-level ordering the maintainer uses in en/common.json so diffs
stay readable side-by-side."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
encoding="utf-8",
)
tmp.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--source",
type=Path,
default=Path("AppImage/messages/en/common.json"),
help="Path to the English source catalog.",
)
parser.add_argument(
"--messages-dir",
type=Path,
default=Path("AppImage/messages"),
help="Directory that contains per-locale subdirectories.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help=(
"Comma-separated target locales. Includes sk by default; "
"guardrail #1 never overwrites keys whose sk value differs "
"from EN, so Vaso73's curated translations are safe."
),
)
parser.add_argument(
"--provider",
choices=("argos", "appimage", "googletrans", "google-web"),
default="googletrans",
help=(
"Translation provider. The default matches build_translation_cache "
"and is what CI uses; `argos` runs locally with no quota and is the "
"one to reach for when filling a catalogue before committing it."
),
)
parser.add_argument(
"--appimage-path",
type=Path,
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--sleep", type=float, default=0.15)
parser.add_argument(
"--refresh",
action="store_true",
help=(
"Retranslate the keys named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced."
),
)
parser.add_argument(
"--refresh-keys",
type=Path,
help=(
"File with one dotted message key per line. Only these may be "
"retranslated over an existing value, and only with --refresh."
),
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only translate the first N missing keys per locale (test runs).",
)
parser.add_argument(
"--save-every",
type=int,
default=50,
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
source = args.source.resolve()
messages_dir = args.messages_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not source.is_file():
print(f"Source not found: {source}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
en_nested = read_json(source)
en_flat = flatten(en_nested)
print(f"Source: {source}", flush=True)
print(f"EN keys: {len(en_flat)}", flush=True)
print(f"Target locales: {', '.join(languages)}", flush=True)
print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.strip()
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
total_failures: list[tuple[str, CatalogPath, str]] = []
# Preflight every locale before any provider call or write. Serialized
# arrays have no trustworthy index correspondence, even if JSON parses.
# Never guess, evaluate Python repr, or discard human text on --refresh.
targets = {}
skipped = set()
for lang in languages:
locale_path = messages_dir / lang / "common.json"
target_flat = flatten(read_json(locale_path))
targets[lang] = target_flat
for key, en_value in en_flat.items():
if key not in target_flat:
continue
existing = target_flat[key]
if isinstance(en_value, str) and existing is None:
continue # Historical null string leaves are treated as empty.
if type(existing) is not type(en_value):
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
print(
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
f"found {type(existing).__name__}; manual reconciliation required. "
"Locale skipped; its catalog will not be written.", file=sys.stderr,
)
skipped.add(lang)
for lang in languages:
if lang in skipped:
continue
locale_path = messages_dir / lang / "common.json"
target_flat = targets[lang]
# Decide what needs translating. Same rule as
# build_translation_cache.py: only touch keys whose target
# value is empty. Never overwrite an existing value — that
# covers three legitimate cases in one line:
# 1. Human-curated translations (protected trivially).
# 2. Universal tokens the maintainer left equal to EN on
# purpose (Hardware, Terminal, Normal, SMART, OK, CPU %,
# {count}h, product names, etc.). Around 120 keys in
# es/common.json — the old `existing == en_value` rule
# kept resending these to Google every run, and the
# provider sometimes mangled them (`Terminal` →
# `terminal`, `CPU %` → `% de CPU`, ...).
# 3. Auto-fills from prior runs whose output happened to
# match EN — leaving them alone is the intended
# steady-state, not a bug.
# An existing translation is never replaced. --refresh only reaches
# the keys named in --refresh-keys, so an overwrite is always one
# somebody asked for by name rather than a side effect of a run.
# Seed structural nodes and non-text leaves before any checkpoint.
# Only strings are sent to a translation provider.
original_flat = target_flat.copy()
for key, en_value in en_flat.items():
if not isinstance(en_value, str) or not en_value:
target_flat.setdefault(key, en_value)
elif key and isinstance(key[-1], int):
# Null keeps an untranslated array position addressable while
# allowing the runtime English fallback and a later retry.
target_flat.setdefault(key, None)
missing: list[CatalogPath] = []
for key, en_value in en_flat.items():
if not isinstance(en_value, str) or not en_value:
continue
existing = target_flat.get(key, "")
if not existing or ".".join(str(part) for part in key) in refresh_keys:
missing.append(key)
if args.limit > 0:
missing = missing[: args.limit]
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
if not missing:
if target_flat != original_flat:
write_json(locale_path, unflatten(target_flat))
print(f" {lang}: nothing to translate", flush=True)
continue
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
for index, key in enumerate(missing, start=1):
en_value = en_flat[key]
protected, placeholders = protect_placeholders(en_value)
protected, technical_terms = protect_technical_terms(protected)
try:
translated = translate_one(
protected,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
translated = restore_technical_terms(translated, technical_terms)
target_flat[key] = restore_placeholders(translated, placeholders)
print(
f" [{lang} {index}/{len(missing)}] {key}: "
f"{en_value[:60]!r} → {target_flat[key][:60]!r}",
flush=True,
)
except Exception as exc: # noqa: BLE001
# Fall back to whatever we already had (or EN) so the
# runtime fallback still kicks in for this key.
target_flat[key] = target_flat.get(key) or en_value
failures_for_lang.append((lang, key, str(exc)))
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
# Preserve keys already in target_flat + write partial progress.
write_json(locale_path, unflatten(target_flat))
time.sleep(args.sleep)
write_json(locale_path, unflatten(target_flat))
print(f" wrote {locale_path}", flush=True)
total_failures.extend(failures_for_lang)
if total_failures:
print(
f"\nCompleted with {len(total_failures)} translation failures "
f"(partial progress persisted, next run will retry).",
file=sys.stderr,
flush=True,
)
for lang, key, error in total_failures[:20]:
print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True)
if len(total_failures) > 20:
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
# Exit 0 on partial failure so the workflow's Commit + push
# step still runs and the keys that DID translate reach
# develop. The un-translated keys stay empty and the next
# workflow tick (or a manual dispatch) retries them.
# Previously we returned 2, which failed the whole run and
# discarded 36 out of 38 successful translations because
# 2 googletrans timeouts hit sv at the start of the burst.
if not skipped:
return 0
if skipped:
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
print(
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
"Valid locales processed; skipped catalogs unchanged. "
"Manual reconciliation required; exiting 1.",
file=sys.stderr, flush=True,
)
return 1
print("\ni18n messages generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+895
View File
@@ -0,0 +1,895 @@
#!/usr/bin/env python3
"""
Build the ProxMenux translation cache from translate calls in scripts/.
The generated JSON keeps the same shape used by scripts/utils.sh:
{
"Original English text": {
"es": "Translated text",
"fr": "Translated text"
}
}
"""
from __future__ import annotations
import argparse
import ast
import asyncio
import inspect
import json
import os
import subprocess
import re
import sys
import time
from pathlib import Path
from typing import Iterable
from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
# googletrans and the public Google endpoint used by this workflow do not
# support Cloud Translation glossaries. Protect product names, package names
# and command identifiers with opaque tokens before sending text to any
# provider, then restore the exact source spelling afterwards. Keep longer
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
PROTECTED_TECHNICAL_TERMS = (
"google/gasket-driver",
"feranick/gasket-driver",
"libedgetpu1-std",
"Proxmox VE Helper-Scripts",
"Docker Compose",
"gasket-driver",
"gasket-dkms",
"libedgetpu1",
"libedgetpu",
"Google Coral",
"Edge TPU",
"ProxMenux",
"Proxmox",
"AppImage",
"smartctl",
"systemctl",
"pveproxy",
"apt-get",
"Frigate",
# Kernel driver and subsystem names. Left to a translator, `nouveau`
# becomes the adjective it is borrowed from — "el nuevo controlador"
# in Spanish, "Jugendstil" in German — and the reader is told about a
# driver that does not exist instead of the one being blacklisted.
"nvidia-container-toolkit",
"nvidia-smi",
"initramfs",
"nouveau",
"vfio-pci",
"modprobe",
"Docker",
"Coral",
"gasket",
"apex",
"lspci",
"dpkg",
"DKMS",
"QEMU",
"LXC",
"ZFS",
"SSH",
"fork",
# Vendor, API and acceleration names. A label like "NVIDIA (NVDEC/CUDA)"
# is a product name end to end: every provider hands it back as it came,
# and without these entries that correct answer is read as a failure and
# the string is dropped from the catalogue.
"VA-API",
"NVIDIA",
"NVDEC",
"NVENC",
"WebUI",
"Intel",
"CUDA",
"KFD",
"GPU",
"CPU",
"AMD",
# Proxmox's own words for a container and a virtual machine. Read as
# initials they get reordered — "CT" comes back as "TC" in Spanish —
# and the reader is told about something Proxmox does not call that.
"CT",
"VM",
# Open Container Initiative. Read as a word it becomes "BEC" in French,
# which named nothing, across thirty-one strings of the OCI section.
"OCI manager Apps",
"OCI",
"beta",
# Debian release names. "Bookworm" is a bird and a reader of books, and
# came back as "el libro de Debian"; the others are equally ordinary
# words in the languages that borrowed them.
"Bookworm",
"Trixie",
"Bullseye",
"Buster",
"Noble",
"Jammy",
)
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
re.VERBOSE | re.DOTALL,
)
# Same shape the Monitor's generator uses, so a message reads identically
# whichever of the two catalogues it comes from.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
# Literals a reader copies and runs. Translated as words they stop working:
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
# only hold what someone listed; these are recognised by shape.
STRUCTURAL_LITERAL_RE = re.compile(
r"""
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
| (?<![\w-])--[A-Za-z][\w-]*
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
# "■CTID confiar", with the closing bracket translated as a verb.
| <[A-Za-z][\w-]*>
# Shell operators. "&&" became the HTML entity "&apos;" in Spanish, and
# the command it joined stopped being one command; a lone pipe came back
# as the word "Silencio".
| &&|\|\||(?<!\|)\|(?!\|)
# Environment variable names. THELOUNGE_HOME lost its underscore and
# became two words, so the command no longer set anything.
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
# "org.open containers.image.version" — a label name with a space in it.
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
# A command plus a real subcommand. An allowlist, not "any word": with
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
# invocations, and the strings a contributor had already translated by
# hand were discarded as damaged.
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
""",
re.VERBOSE,
)
# Providers that answer the same thing every time for the same input, so a
# second attempt cannot produce a different result. `appimage` shells out to a
# binary that may reach a network service, so it is not on the list.
DETERMINISTIC_PROVIDERS = frozenset({"argos"})
def protect_catalog_titles(directories) -> None:
"""Add every application name in the catalog to the protected glossary.
They are product names, and a translator treats them as words: "HAOS One"
comes back as "HAOS Man". Protecting them costs nothing and the failure it
prevents reaches the reader as an application that does not exist.
"""
global TECHNICAL_TERM_RE
titles: set[str] = set()
for directory in directories:
for path in sorted(Path(directory).rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
title = ((data.get("catalog_ui") or {}).get("title") or {}).get("en_US")
if isinstance(title, str) and title.strip():
titles.add(title.strip())
if not titles:
return
terms = tuple(sorted(set(PROTECTED_TECHNICAL_TERMS) | titles, key=len, reverse=True))
# Same word boundaries as the module-level pattern. Without them a short
# term matches inside longer words, and every application title in the
# catalogue joins the glossary here.
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in terms
),
re.IGNORECASE,
)
print(f"Protected application names: {len(titles)}", flush=True)
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Replace each ``{name}`` with a token before translation.
A provider reads the name inside the braces as a word and translates
it: ``{count}`` comes back as ``{conta}`` in Portuguese and ``{počet}``
in Slovak. The consumer then finds no placeholder to substitute and
falls back to English, so the translation was paid for and never used.
The token carries no underscores because argos splits on them.
"""
found: list[str] = []
def _swap(match: re.Match[str]) -> str:
found.append(match.group(0))
return f"PMXPH{len(found) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), found
def restore_placeholders(text: str, found: list[str]) -> str:
"""Put the original ``{name}`` back where each token landed."""
for index, original in enumerate(found):
text = text.replace(f"PMXPH{index:03d}", original)
return text
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
"""Replace glossary terms with stable tokens before translation.
Structural literals go first. A glossary can only list what someone
thought of, and what reaches the reader as a broken command is never on
that list: an absolute path, a long option, a filename. They are
recognised by shape instead, which also covers the next one added.
"""
protected: list[str] = []
def _swap(match: re.Match[str]) -> str:
protected.append(match.group(0))
return f"PMXTERM{len(protected) - 1:03d}"
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
return TECHNICAL_TERM_RE.sub(_swap, text), protected
def restore_technical_terms(text: str, protected: list[str]) -> str:
"""Restore glossary terms exactly as they appeared in the source."""
for index, original in enumerate(protected):
text = text.replace(f"PMXTERM{index:03d}", original)
return text
# A provider can alter a token instead of carrying it through: argos returned
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
def assert_no_sentinel_residue(text: str) -> None:
"""Fail the translation when a protection token did not survive intact.
Leaving the key out is recoverable: the next run tries again. Storing a
string with a token in it is not, because nothing looks at a value that
already exists.
"""
found = SENTINEL_RESIDUE_RE.search(text)
if found:
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
def iter_script_files(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> Iterable[Path]:
# Walk the main scripts tree.
for path in sorted(scripts_dir.rglob("*")):
if not path.is_file():
continue
if path.name == "utils.sh":
continue
if path.suffix not in {".sh", ".func"}:
continue
yield path
# Yield additional files passed explicitly (e.g. the root-level `menu`
# entry point or install_proxmenux*.sh). These live outside scripts/
# but still contain translate "..." calls we want in the cache.
# No extension filter and no utils.sh skip — the caller decided
# they belong, we just check the file actually exists.
for extra in extra_files:
if extra.is_file():
yield extra
def decode_shell_string(raw: str, quote_char: str) -> str:
if quote_char == "'":
return raw
try:
return ast.literal_eval(f'"{raw}"')
except Exception:
return raw.replace(r"\"", '"').replace(r"\\", "\\")
def extract_translate_texts(
scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> list[str]:
found: dict[str, None] = {}
for path in iter_script_files(scripts_dir, extra_files):
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
for match in TRANSLATE_CALL_RE.finditer(content):
text = decode_shell_string(match.group("text"), match.group("quote"))
text = text.strip()
if text and "$" not in text and "`" not in text:
found.setdefault(text, None)
return sorted(found)
PYTHON_TRANSLATE_CALLS = {"translate", "N_"}
CATALOG_TEXT_KEYS = {"prompt", "enable_prompt", "path_prompt", "size_prompt", "label", "warning"}
CATALOG_TEXT_LISTS = {"stack_completion_notes", "completion_notes"}
def extract_python_texts(directories: Iterable[Path]) -> list[str]:
"""translate("...") and N_("...") calls with a literal argument. The parser
joins implicitly concatenated literals, so wrapped strings are found whole."""
found: dict[str, None] = {}
for directory in directories:
for path in sorted(directory.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and getattr(node.func, "id", None) in PYTHON_TRANSLATE_CALLS
and node.args and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
text = node.args[0].value.strip()
if text:
found.setdefault(text, None)
return sorted(found)
def extract_catalog_texts(directories: Iterable[Path]) -> list[str]:
"""User-visible text stored in the OCI catalog JSON: prompts, labels,
warnings, completion notes, category labels and descriptive usernames."""
found: dict[str, None] = {}
def add(value: object) -> None:
if isinstance(value, str) and value.strip():
found.setdefault(value.strip(), None)
def walk(value: object, key: str = "") -> None:
if isinstance(value, dict):
for child_key, child in value.items():
if child_key in CATALOG_TEXT_KEYS:
add(child)
elif child_key in CATALOG_TEXT_LISTS and isinstance(child, list):
for item in child:
add(item)
elif child_key == "username" and isinstance(child, str) and " " in child:
add(child)
elif child_key == "catalog_ui" and isinstance(child, dict):
# What the application detail screen shows: the tagline,
# and the description only where there is no tagline. The
# catalog stores the source English; the translation lives
# in the language cache with every other string.
tagline = (child.get("tagline") or {}).get("en_US")
add(tagline or (child.get("description") or {}).get("en_US"))
elif child_key == "labels" and key == "" and isinstance(child, dict):
for item in child.values():
add(item)
walk(child, child_key)
elif isinstance(value, list):
for item in value:
walk(item, key)
for directory in directories:
for path in sorted(directory.rglob("*.json")):
try:
walk(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return sorted(found)
def translate_argos(text: str, dest_lang: str) -> str:
"""LibreTranslate's engine, running locally.
A public endpoint answers a few thousand strings and then starts
refusing — and the library wrapper around it returns the English
unchanged rather than raising, which writes the source text into the
catalogue as if it were a translation. Local models have no quota and
no silent failure mode.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc:
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", dest_lang)
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
try:
from googletrans import Translator # type: ignore
except Exception as exc:
raise RuntimeError(
"googletrans is not installed. Install googletrans==4.0.0-rc1 "
"or run with --provider google-web."
) from exc
translator = Translator()
full_text = f"{context} {text}".strip()
result = translator.translate(full_text, dest=dest_lang)
if inspect.isawaitable(result):
result = asyncio.run(result)
return result.text
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
# The public Google endpoint is not prompt-aware: if we prepend context,
# it often translates and returns that context as part of the result.
full_text = text
url = (
"https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}"
)
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
with urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return "".join(part[0] for part in payload[0] if part and part[0])
def translate_appimage(
text: str,
dest_lang: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
if not appimage_path.exists():
prev_path = appimage_path.with_name(appimage_path.name + ".prev")
if prev_path.exists():
appimage_path = prev_path
else:
raise FileNotFoundError(f"AppImage not found: {appimage_path}")
req = {
"text": text,
"dest_lang": dest_lang,
"context": context,
"cache_file": "",
}
env = os.environ.copy()
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
completed = subprocess.run(
[str(appimage_path), "--translate"],
input=json.dumps(req, ensure_ascii=False),
text=True,
capture_output=True,
timeout=timeout,
check=False,
env=env,
)
if completed.returncode != 0:
raise RuntimeError((completed.stderr or completed.stdout).strip())
# AppRun may print a startup line before translate_cli.py emits JSON.
for line in reversed(completed.stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
payload = json.loads(line)
if payload.get("success"):
return str(payload.get("text", text))
raise RuntimeError(str(payload.get("error", "unknown AppImage translation error")))
raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}")
def clean_translation(value: str) -> str:
separator = r"[\s\u00a0]*[::]"
# `Translate` pivot in every locale currently supported. Without
# the target-language variant here, the cleaner can't locate the
# boundary between the context prompt and the real translation,
# and the whole payload leaks through as the translated context.
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
# up as "Technický text používateľského rozhrania…" or
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
# because Preložiť / Översätt / Översätta were missing.
translate_labels = (
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
"|Preložiť|Prelož|Preloz" # sk
"|Översätta|Översätt" # sv
)
context_labels = (
"Context|Contexto|Contexte|Kontext|Contesto"
"|Sammanhang" # sv alternate
)
value = re.sub(
rf"^.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
return value.strip()
# Lowercase-or-digit, then the mark, then an uppercase letter: that is a
# sentence boundary and not `8.0`, `storage.cfg` or `proxmox.com/docs`.
_SENTENCE_RUN = re.compile(
"(?<=[a-z0-9\u00e0-\u00ff\u0107\u010d\u011b\u013e\u0148\u0159\u0161\u0165\u016f\u017a\u017e])"
"([.?!])"
"(?=[A-Z\u00c0-\u00de\u0106\u010c\u011a\u013d\u0147\u0158\u0160\u0164\u016e\u0179\u017d])"
)
def restore_sentence_spacing(text: str) -> str:
"""Every provider drops the blank between sentences it translated apart."""
return _SENTENCE_RUN.sub(r"\1 ", text)
def translate_text(
text: str,
dest_lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
protected_text, placeholders = protect_placeholders(text)
protected_text, protected_terms = protect_technical_terms(protected_text)
if provider == "argos":
translated = translate_argos(protected_text, dest_lang)
elif provider == "googletrans":
translated = translate_googletrans(protected_text, dest_lang, context)
elif provider == "google-web":
translated = translate_google_web(protected_text, dest_lang, context, timeout)
elif provider == "appimage":
translated = translate_appimage(
protected_text, dest_lang, context, timeout, appimage_path
)
else:
raise ValueError(f"Unknown provider: {provider}")
translated = restore_technical_terms(clean_translation(translated), protected_terms)
translated = restore_placeholders(translated, placeholders)
assert_no_sentinel_residue(translated)
return restore_sentence_spacing(translated) if translated else text
def load_language_cache(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {str(text): str(value) for text, value in data.items()}
def is_fully_protected(source: str) -> bool:
"""Whether the string is protected material and punctuation, nothing else.
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
names from end to end, and "apt-get exited" is a command with a word
the command itself supplies. Coming back unchanged is the right answer
for all of them, so the guard below must not read it as a silent
failure and throw the result away. Both kinds of protection count:
the glossary catches the names, the structural pattern the rest.
"""
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
def looks_untranslated(source: str, result: str) -> bool:
"""Whether a provider handed back the text it was given.
A single technical word legitimately survives translation — Docker, GPU,
LXC — but a sentence coming back byte-identical means the provider failed
without saying so. Accepting it writes English into the catalogue, where
it counts as translated and is never looked at again.
"""
if source.strip() != result.strip():
return False
if is_fully_protected(source):
return False
return len([word for word in re.findall(r"[A-Za-z]{2,}", source)]) >= 3
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
tmp_path.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Extract translate calls from scripts/ and build json/cache.json."
)
parser.add_argument("--scripts-dir", default="scripts", type=Path)
parser.add_argument(
"--extra-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Extra directory scanned for translate calls in .sh files. Repeatable.",
)
parser.add_argument(
"--python-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory scanned for translate()/N_() calls in .py files. Repeatable.",
)
parser.add_argument(
"--catalog-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory of OCI catalog JSON files with user-visible text. Repeatable.",
)
parser.add_argument(
"--extra-file",
action="append",
default=[],
type=Path,
metavar="PATH",
help=(
"Extra individual files to scan for translate calls in addition "
"to --scripts-dir. Useful for the root-level `menu` entry point "
"and install_proxmenux*.sh, which sit outside scripts/. "
"Pass multiple times to add more than one file."
),
)
parser.add_argument(
"--output-dir",
default=Path("lang"),
type=Path,
help="Directory where per-language JSON files are written. Default: lang",
)
parser.add_argument(
"--output",
default=None,
type=Path,
help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
)
parser.add_argument(
"--provider",
choices=("argos", "appimage", "googletrans", "google-web"),
default="appimage",
help="Translation provider to use. Default: appimage",
)
parser.add_argument(
"--appimage-path",
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
type=Path,
help="Path to the ProxMenux AppImage when using --provider appimage.",
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", default=30, type=int)
parser.add_argument("--sleep", default=0.15, type=float)
parser.add_argument("--retries", default=4, type=int,
help="Attempts per string before giving up on it.")
parser.add_argument("--retry-wait", default=15, type=float,
help="Seconds before the first retry; it doubles each time.")
parser.add_argument(
"--refresh",
action="store_true",
help="Retranslate the strings named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced.",
)
parser.add_argument(
"--refresh-keys",
type=Path,
help="File with one source string per line. Only these may be "
"retranslated over an existing value, and only with --refresh.",
)
parser.add_argument(
"--extract-only",
action="store_true",
help="Only update the cache keys; missing translations are left empty.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only process the first N extracted strings. Useful for test runs.",
)
parser.add_argument(
"--save-every",
type=int,
default=1,
help="Write the output JSON every N translated items. Default: 1",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
scripts_dir = args.scripts_dir.resolve()
if args.output is not None:
output_dir = args.output.resolve().parent / "lang"
else:
output_dir = args.output_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not scripts_dir.is_dir():
print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
texts = extract_translate_texts(scripts_dir, args.extra_file)
for directory in args.extra_dir:
if directory.is_dir():
texts += extract_translate_texts(directory.resolve())
texts += extract_python_texts(d.resolve() for d in args.python_dir if d.is_dir())
catalog_dirs = [d.resolve() for d in args.catalog_dir if d.is_dir()]
protect_catalog_titles(catalog_dirs)
texts += extract_catalog_texts(catalog_dirs)
texts = sorted(dict.fromkeys(text for text in texts if "$" not in text and "`" not in text))
if args.limit > 0:
texts = texts[: args.limit]
existing_by_lang = {
lang: load_language_cache(output_dir / f"{lang}.json")
for lang in languages
}
# Seeded with what is already translated so a periodic save — or an
# interrupted run — writes a superset of the file it replaces, never a
# truncated one.
next_by_lang: dict[str, dict[str, str]] = {
lang: dict(existing_by_lang.get(lang, {})) for lang in languages
}
print(f"Found {len(texts)} unique translate strings.", flush=True)
print(f"Output directory: {output_dir}", flush=True)
print(f"Languages: {', '.join(languages)}", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.rstrip("\n")
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
failures: list[tuple[str, str, str]] = []
total = len(texts) * len(languages)
done = 0
for lang in languages:
existing = existing_by_lang.get(lang, {})
print(f"Starting language: {lang}", flush=True)
for index, text in enumerate(texts, start=1):
done += 1
# A translation that is already there is never replaced. Most of
# this catalogue is hand-written by the people who speak the
# language, and a machine pass over it trades their wording for
# something worse without anyone seeing the swap. Only a string
# named in --refresh-keys may be overwritten, so every overwrite
# is one somebody asked for by name.
if existing.get(text) and text not in refresh_keys:
next_by_lang[lang][text] = existing[text]
continue
if args.extract_only:
next_by_lang[lang][text] = existing.get(text, "")
continue
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
# A rate limit is a "come back later", not an answer. Retrying with
# a growing wait recovers it; giving up on the first one is what
# left thousands of strings untranslated.
value, last_error = None, None
for attempt in range(1, args.retries + 1):
unchanged = False
try:
value = translate_text(
text,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
if looks_untranslated(text, value):
value = None
unchanged = True
raise RuntimeError("the provider returned the source text unchanged")
break
except Exception as exc:
last_error = exc
# Unchanged text from a remote provider is how a rate limit
# shows up, so it is worth waiting for. A local engine is
# deterministic: asking again returns the same string, and
# the backoff only buys minutes of sleeping per phrase.
if unchanged and args.provider in DETERMINISTIC_PROVIDERS:
break
if attempt < args.retries:
wait = args.retry_wait * (2 ** (attempt - 1))
print(f" retry {attempt}/{args.retries - 1} in {wait}s: {exc}",
file=sys.stderr, flush=True)
time.sleep(wait)
if value is not None:
next_by_lang[lang][text] = value
print(f" => {value[:100]}", flush=True)
else:
# The key is left out on purpose. Writing the English here
# would count as a translation on the next run and the string
# would never be translated again.
previous = existing.get(text)
if previous:
next_by_lang[lang][text] = previous
failures.append((text, lang, str(last_error)))
print(f" failed: {last_error}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
time.sleep(args.sleep)
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
print(f"Completed language: {lang}", flush=True)
for lang, cache in next_by_lang.items():
write_language_cache(output_dir / f"{lang}.json", cache)
if failures:
print(
f"Completed with {len(failures)} translation failures "
f"(partial progress persisted, next run will retry).",
file=sys.stderr, flush=True,
)
for text, lang, error in failures[:20]:
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
if len(failures) > 20:
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
# Exit 0 on partial failure so the workflow's Commit + push
# step still runs. Otherwise a couple of transient googletrans
# timeouts would fail the whole workflow and discard every
# successful translation from the same batch.
return 0
print("Translation cache generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+899
View File
@@ -0,0 +1,899 @@
#!/usr/bin/env python3
"""Build missing translations for the ProxMenux documentation catalog."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402
clean_translation,
translate_appimage,
translate_argos,
translate_google_web,
translate_googletrans,
)
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
DEFAULT_CONTEXT = (
"Context: ProxMenux technical documentation for Proxmox VE users. "
"Preserve product names, commands, paths, variables and placeholders. Translate:"
)
TECHNICAL_TERMS = (
"Proxmox VE Helper-Scripts",
"Proxmox Backup Server",
"Proxmox Mail Gateway",
"Proxmox VE",
"ProxMenux Monitor",
"ProxMenux Scripts",
"Docker Compose",
"Docker Engine",
"Google Coral",
"Edge TPU",
"Let's Encrypt",
"Cloudflare",
"Pushover",
"Telegram",
"Discord",
"Microsoft Teams",
"GitHub",
"Gotify",
"Apprise",
"Frigate",
"Vaultwarden",
"Portainer",
"ProxMenux",
"AppImage",
"systemctl",
"journalctl",
"smartctl",
"pveproxy",
"apt-get",
"gasket-dkms",
"libedgetpu",
"QEMU",
"LXC",
"ZFS",
"Ceph",
"Docker",
"OpenAI",
"WebSocket",
"OAuth",
"DKMS",
"SSH",
"API",
)
PROTECTED_PATTERNS = (
# Keep rich-text tags visible to Google Translate. It preserves their
# structure while translating the enclosed prose, whereas replacing
# opening/closing tags with adjacent sentinels can make the provider drop
# one side of the pair. The contract is validated after translation.
re.compile(r"`[^`]+`"),
re.compile(r"https?://[^\s<>]+"),
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
)
LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
),
re.IGNORECASE,
)
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
# Every tag as written, attributes and self-closing slash included.
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
# Set from --provider: a local model rewrites bracketed sentinels, a remote
# one drops bare ones beside inline markup. See protect_text.
BARE_SENTINELS = False
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = {
"command",
"code",
"href",
"icon",
"id",
"path",
"route",
"slug",
"src",
"url",
}
SOURCE_STATE_VERSION = 1
@dataclass(frozen=True)
class Leaf:
path: tuple[str | int, ...]
source: str
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
leaves: list[Leaf] = []
if isinstance(node, dict):
for key, value in node.items():
leaves.extend(iter_leaves(value, path + (key,)))
elif isinstance(node, list):
for index, value in enumerate(node):
leaves.extend(iter_leaves(value, path + (index,)))
elif isinstance(node, str):
leaves.append(Leaf(path, node))
return leaves
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
current = node
try:
for part in path:
if isinstance(part, int):
if not isinstance(current, list):
return None
current = current[part]
else:
if not isinstance(current, dict):
return None
current = current[part]
except (IndexError, KeyError, TypeError):
return None
return current
def path_key(path: tuple[str | int, ...]) -> str:
return ".".join(str(part) for part in path)
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
if not source.strip() or not re.search(r"[A-Za-z]", source):
return True
last = str(path[-1]).lower() if path else ""
if last in NON_TRANSLATABLE_KEYS or any(
last.endswith(suffix)
for suffix in ("url", "href", "path", "command", "code", "icon")
):
return True
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
return True
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
return True
return False
def leaf_token(path: tuple[str | int, ...]) -> str:
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
def source_fingerprints(source: Any) -> dict[str, str]:
return {
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
for leaf in iter_leaves(source)
}
def needs_translation(
source: str,
target: Any,
path: tuple[str | int, ...],
forced_tokens: set[str] | None = None,
) -> bool:
if should_copy(source, path):
return False
# An existing translation is never replaced. Only a leaf whose token was
# named on the command line may be retranslated over a real value, so an
# overwrite is always one somebody asked for rather than a side effect.
if forced_tokens and leaf_token(path) in forced_tokens:
return True
return not isinstance(target, str) or not target.strip() or target == source
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
"""Give translatable rich-text tags opaque names during translation.
Google can remove semantic tags such as ``strong`` or ``em`` after
translating their contents. Unknown tag names are retained, so rename
non-literal tags temporarily and restore them before contract validation.
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
provider preserves both their markup and their contents.
"""
names: dict[str, str] = {}
reverse: dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
original = match.group(1)
if original.lower() in {"code", "kbd", "pre"}:
return match.group(0)
key = original.lower()
internal = names.get(key)
if internal is None:
internal = f"pmxrich{len(names):04d}"
names[key] = internal
reverse[internal] = original
slash = "/" if match.group(0).startswith("</") else ""
return f"<{slash}{internal}>"
return TAG_RE.sub(replace, text), reverse
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
for internal, original in mapping.items():
text = re.sub(
rf"<(/?){re.escape(internal)}>",
lambda match: f"<{match.group(1)}{original}>",
text,
flags=re.IGNORECASE,
)
return text
def protect_text(text: str) -> tuple[str, dict[str, str]]:
# Google already keeps both the markup and the contents of literal rich-
# text blocks. Replacing a filename or path inside one of these blocks can
# leave the sentinel as its only child, which the provider may discard.
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
def overlaps_literal(start: int, end: int) -> bool:
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
candidates: list[tuple[int, int]] = []
for pattern in PROTECTED_PATTERNS:
candidates.extend(
(match.start(), match.end())
for match in pattern.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
candidates.extend(
(match.start(), match.end())
for match in TERM_RE.finditer(text)
if not overlaps_literal(match.start(), match.end())
)
if BARE_SENTINELS:
# A remote provider carries inline tags through untouched, so they are
# only validated afterwards. A local model rewrites them — dropping a
# </code>, merging two <em> — and the whole file is then discarded for
# a broken contract. Protecting them costs the translator the tag as
# context and saves the paragraph.
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
# A local model also rewrites what a literal block holds: it drops the
# underscores of an identifier, translates a subcommand and turns a
# pipe into punctuation. The whole block is one opaque token instead.
candidates.extend(literal_ranges)
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int]] = []
cursor = -1
for start, end in candidates:
if start >= cursor:
selected.append((start, end))
cursor = end
mapping: dict[str, str] = {}
chunks: list[str] = []
cursor = 0
for index, (start, end) in enumerate(selected):
# Google Translate can drop underscore-delimited sentinels when they
# sit directly beside inline markup (for example an opening <em>
# token followed by translated prose). Triple brackets remain opaque
# in that position and preserve the complete rich-text contract.
#
# A local model treats the brackets as punctuation instead, and
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
# survives every provider tried, so it is what a local run uses.
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
chunks.append(text[cursor:start])
chunks.append(token)
mapping[token] = text[start:end]
cursor = end
chunks.append(text[cursor:])
return "".join(chunks), mapping
def restore_text(text: str, mapping: dict[str, str]) -> str:
missing = [token for token in mapping if token not in text]
if missing:
raise ValueError(f"translation provider changed protected token {missing[0]}")
for token, original in mapping.items():
text = text.replace(token, original)
return text
def validate_contract(source: str, target: str) -> None:
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
raise ValueError("rich-text tag contract changed")
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
raise ValueError("markup changed")
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
if literal(source) != literal(target):
raise ValueError("literal code changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
def translate(text: str, language: str) -> str:
if args.provider == "argos":
raw = translate_argos(text, language)
elif args.provider == "googletrans":
raw = translate_googletrans(text, language, args.context)
elif args.provider == "google-web":
raw = translate_google_web(text, language, args.context, args.timeout)
else:
raw = translate_appimage(
text,
language,
args.context,
args.timeout,
args.appimage_path,
)
return clean_translation(raw).strip()
return translate
def translate_with_retry(
source: str,
language: str,
translate: Callable[[str, str], str],
retries: int,
delay: float,
) -> str:
rich_text, rich_mapping = protect_rich_tags(source)
protected, mapping = protect_text(rich_text)
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
translated = translate(protected, language)
if not translated:
raise ValueError("translation provider returned an empty value")
translated = restore_text(translated, mapping)
translated = restore_rich_tags(translated, rich_mapping)
validate_contract(source, translated)
return translated
except Exception as exc: # network/provider errors are retried together
last_error = exc
if attempt < retries:
time.sleep(delay * (attempt + 1))
raise RuntimeError(str(last_error)) from last_error
def merge_tree(
source: Any,
target: Any,
translations: dict[tuple[str | int, ...], str],
path: tuple[str | int, ...] = (),
) -> Any:
if isinstance(source, dict):
target_dict = target if isinstance(target, dict) else {}
return {
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
for key, value in source.items()
}
if isinstance(source, list):
target_list = target if isinstance(target, list) else []
return [
merge_tree(
value,
target_list[index] if index < len(target_list) else None,
translations,
path + (index,),
)
for index, value in enumerate(source)
]
if isinstance(source, str):
if path in translations:
return translations[path]
if should_copy(source, path):
return source
if isinstance(target, str) and target.strip():
return target
return source
return source
def source_files(source_root: Path, section: str) -> list[Path]:
scope = (source_root / section).resolve()
root = source_root.resolve()
if scope != root and root not in scope.parents:
raise ValueError("section must stay inside the English messages directory")
if scope.is_file():
if scope.suffix != ".json":
raise ValueError("section file must be JSON")
return [scope]
if not scope.is_dir():
raise ValueError(f"section does not exist: {scope}")
return sorted(
path
for path in scope.rglob("*.json")
if not any(part.startswith(".") for part in path.relative_to(root).parts)
)
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
memory: dict[str, str] = {}
conflicts: set[str] = set()
for source_path in sorted(source_root.rglob("*.json")):
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
continue
target_path = messages_root / language / source_path.relative_to(source_root)
source = read_json(source_path)
target = read_json(target_path)
if target is None:
continue
for leaf in iter_leaves(source):
translated = get_at_path(target, leaf.path)
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
continue
previous = memory.get(leaf.source)
if previous is not None and previous != translated:
conflicts.add(leaf.source)
else:
memory[leaf.source] = translated
for source in conflicts:
memory.pop(source, None)
return memory
def pending_leaves(
source: Any,
target: Any,
forced_tokens: set[str] | None = None,
) -> list[Leaf]:
return [
leaf
for leaf in iter_leaves(source)
if needs_translation(
leaf.source,
get_at_path(target, leaf.path),
leaf.path,
forced_tokens,
)
]
def schema_matches(source: Any, target: Any) -> bool:
if type(source) is not type(target):
return False
if isinstance(source, dict):
return list(source) == list(target) and all(
schema_matches(source[key], target[key]) for key in source
)
if isinstance(source, list):
return len(source) == len(target) and all(
schema_matches(left, right) for left, right in zip(source, target)
)
if isinstance(source, str):
# Localized strings intentionally differ from the English source.
return True
# Booleans, numbers and null values are structural data and must stay in
# sync with the English catalog instead of retaining an obsolete value.
return source == target
def empty_source_state() -> dict[str, Any]:
return {
"version": SOURCE_STATE_VERSION,
"initialized": False,
"source": {},
"pending": {},
}
def load_source_state(path: Path) -> dict[str, Any]:
if not path.exists():
return empty_source_state()
try:
value = read_json(path)
except ValueError:
return empty_source_state()
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
return empty_source_state()
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
return empty_source_state()
value.setdefault("initialized", True)
return value
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
language_state = state.setdefault("pending", {}).setdefault(language, {})
values = language_state.get(relative, [])
if not isinstance(values, list):
return set()
return {str(value) for value in values}
def set_state_pending_tokens(
state: dict[str, Any], language: str, relative: str, tokens: set[str]
) -> None:
language_state = state.setdefault("pending", {}).setdefault(language, {})
if tokens:
language_state[relative] = sorted(tokens)
else:
language_state.pop(relative, None)
if not language_state:
state["pending"].pop(language, None)
def translate_file(
source: Any,
target: Any,
leaves: list[Leaf],
language: str,
memory: dict[str, str],
translate: Callable[[str, str], str],
args: argparse.Namespace,
) -> tuple[Any | None, list[str], int, set[str]]:
resolved: dict[tuple[str | int, ...], str] = {}
failures: list[str] = []
failed_tokens: set[str] = set()
jobs: dict[str, list[tuple[str | int, ...]]] = {}
for leaf in leaves:
if leaf.source in memory:
resolved[leaf.path] = memory[leaf.source]
continue
jobs.setdefault(leaf.source, []).append(leaf.path)
if jobs:
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {
executor.submit(
translate_with_retry,
text,
language,
translate,
args.retries,
args.retry_delay,
): text
for text in jobs
}
for future in as_completed(futures):
text = futures[future]
try:
translated = future.result()
memory[text] = translated
for path in jobs[text]:
resolved[path] = translated
except Exception as exc:
failures.append(f"{text[:90]}: {exc}")
failed_tokens.update(leaf_token(path) for path in jobs[text])
if args.sleep:
time.sleep(args.sleep)
# What did translate is kept. merge_tree leaves a string it has no
# translation for as whatever the locale already held, or as the English
# source, so a file is written with the strings that worked rather than
# discarded whole: a dense paragraph that a provider cannot carry its
# protected tokens through used to take the other hundred and seventy
# strings of its file with it.
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
parser.add_argument(
"--section",
default=".",
help="Relative file or directory below the English messages directory.",
)
parser.add_argument(
"--provider",
choices=("argos", "google-web", "googletrans", "appimage"),
default="googletrans",
help=(
"Translation provider. The default is what CI uses; `argos` runs "
"locally with no quota, which is what a corpus this size needs."
),
)
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
parser.add_argument(
"--source-state",
type=Path,
default=None,
help=(
"Source fingerprint state used to detect changed English strings. "
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
),
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", type=int, default=30)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--retry-delay", type=float, default=2.0)
parser.add_argument("--sleep", type=float, default=0.0)
parser.add_argument(
"--max-files",
type=int,
default=0,
help="Maximum pending files per locale; zero processes all files.",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--check", action="store_true")
parser.add_argument(
"--refresh",
action="store_true",
help="Overwrite existing translations in the selected scope.",
)
parser.add_argument("--strict", action="store_true")
return parser
def main() -> int:
global BARE_SENTINELS
args = build_parser().parse_args()
BARE_SENTINELS = args.provider == "argos"
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
return 2
source_root = args.source_dir.resolve()
messages_root = args.messages_dir.resolve()
source_state_path = (
args.source_state.resolve()
if args.source_state is not None
else messages_root / ".docs-i18n-source-state.json"
)
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
try:
files = source_files(source_root, args.section)
except ValueError as exc:
print(exc, file=sys.stderr)
return 2
if not languages:
print("No target languages selected.", file=sys.stderr)
return 2
if args.refresh:
print("--refresh no longer overwrites existing translations; "
"name the leaves with --tokens instead.")
translate = provider_function(args)
state = load_source_state(source_state_path)
state_was_initialized = bool(state.get("initialized"))
previous_source_state = state.setdefault("source", {})
file_info: dict[
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
] = {}
selected_relatives: set[str] = set()
for source_path in files:
relative = source_path.relative_to(source_root).as_posix()
selected_relatives.add(relative)
source = read_json(source_path)
current_fingerprints = source_fingerprints(source)
translatable_tokens = {
leaf_token(leaf.path)
for leaf in iter_leaves(source)
if not should_copy(leaf.source, leaf.path)
}
previous_fingerprints = previous_source_state.get(relative, {})
if not isinstance(previous_fingerprints, dict):
previous_fingerprints = {}
if state_was_initialized:
changed_tokens = {
token
for token, fingerprint in current_fingerprints.items()
if token in translatable_tokens
# Existing English leaves whose text changed must be sent to
# the provider even when the target still contains the old,
# non-empty translation. New leaves are handled by the usual
# missing-target detection, preserving a translation supplied
# manually in the same commit.
and token in previous_fingerprints
and previous_fingerprints[token] != fingerprint
}
else:
# The first run establishes the baseline without replacing
# existing human/Codex translations. Missing target values are
# still discovered separately for every locale below.
changed_tokens = set()
file_info[relative] = (
source_path,
source,
current_fingerprints,
changed_tokens,
translatable_tokens,
)
# Materialize every locale's pending queue before advancing the shared
# English baseline. If the runner stops halfway through, unprocessed
# locales retain the exact changed leaf tokens for the next run.
for language in languages:
for relative, (
_,
source,
_,
changed_tokens,
translatable_tokens,
) in file_info.items():
target_path = messages_root / language / relative
target = read_json(target_path)
pending_tokens = (
state_pending_tokens(state, language, relative)
& translatable_tokens
)
pending_tokens.update(changed_tokens)
pending_tokens.update(
leaf_token(leaf.path)
for leaf in pending_leaves(source, target)
)
set_state_pending_tokens(state, language, relative, pending_tokens)
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
previous_source_state[relative] = current_fingerprints
state["initialized"] = True
# A complete run also mirrors deletion of an English catalog. Partial
# --section runs deliberately leave unrelated paths untouched.
if args.section in (".", ""):
removed_files = set(previous_source_state) - selected_relatives
for relative in sorted(removed_files):
for language in languages:
target_path = messages_root / language / relative
if target_path.exists() and not (args.check or args.dry_run):
target_path.unlink()
print(f"[{language}] removed obsolete catalog {target_path}")
set_state_pending_tokens(state, language, relative, set())
previous_source_state.pop(relative, None)
if not (args.check or args.dry_run):
write_json(source_state_path, state)
total_failures = 0
total_written = 0
# The provider decides the sentinel format, so a run that cannot be
# attributed to one cannot be diagnosed afterwards: a night of failures
# reading "translation provider changed protected token" left no way to
# tell whether the provider was the one the tokens were shaped for.
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
for language in languages:
memory = collect_memory(source_root, messages_root, language)
# A target value paired with a newly changed English source is the old
# translation, not valid translation memory for the new sentence.
# Remove every queued source text before provider reuse; successful
# translations repopulate memory normally for later files.
queued_source_texts: set[str] = set()
for relative_key, (_, source, _, _, _) in file_info.items():
queued_tokens = state_pending_tokens(state, language, relative_key)
queued_source_texts.update(
leaf.source
for leaf in iter_leaves(source)
if leaf_token(leaf.path) in queued_tokens
)
for source_text in queued_source_texts:
memory.pop(source_text, None)
pending: list[
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
] = []
total_strings = 0
missing_strings = 0
for source_path in files:
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
target_path = messages_root / language / relative
source = file_info[relative_key][1]
target = read_json(target_path)
queued_tokens = state_pending_tokens(state, language, relative_key)
leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
if leaves or schema_changed:
pending.append(
(
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
)
)
print(
f"[{language}] {missing_strings}/{total_strings} strings pending "
f"across {len(pending)} files; reusable translations: {len(memory)}"
)
if args.check or args.dry_run:
continue
if args.max_files:
pending = pending[: args.max_files]
for index, (
source_path,
target_path,
source,
target,
leaves,
queued_tokens,
schema_changed,
) in enumerate(pending, 1):
relative = source_path.relative_to(source_root)
relative_key = relative.as_posix()
suffix = " + schema sync" if schema_changed else ""
print(
f"[{language} {index}/{len(pending)}] {relative} "
f"({len(leaves)} strings{suffix})",
flush=True,
)
built, failures, calls, failed_tokens = translate_file(
source,
target,
leaves,
language,
memory,
translate,
args,
)
if failures:
total_failures += len(failures)
print(
f" {len(failures)} strings left untranslated ({calls} calls)",
file=sys.stderr,
)
for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr)
write_json(target_path, built)
# The strings that failed stay pending, so the next run comes back
# for them and leaves the ones that translated alone.
set_state_pending_tokens(state, language, relative_key, failed_tokens)
if failures:
write_json(source_state_path, state)
write_json(source_state_path, state)
total_written += 1
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
if args.check or args.dry_run:
return 0
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
return 1 if args.strict and total_failures else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+244 -55
View File
@@ -1,76 +1,265 @@
import requests, json
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
from typing import Any
# GitHub API URL to fetch all .json files describing scripts
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
import requests
# Base path to build the full URL for the installable scripts
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
POCKETBASE_BASE = "https://db.community-scripts.org/api/collections"
SCRIPT_COLLECTION_URL = f"{POCKETBASE_BASE}/script_scripts/records"
CATEGORY_COLLECTION_URL = f"{POCKETBASE_BASE}/script_categories/records"
# Output file where the consolidated helper scripts cache will be stored
OUTPUT_FILE = Path("json/helpers_cache.json")
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
res = requests.get(API_URL)
data = res.json()
cache = []
TYPE_TO_PATH_PREFIX = {
"lxc": "ct",
"vm": "vm",
"addon": "tools/addon",
"pve": "tools/pve",
}
# Loop over each file in the JSON directory
for item in data:
url = item.get("download_url")
if not url or not url.endswith(".json"):
continue
def to_mirror_url(raw_url: str) -> str:
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
if not m:
return ""
org, repo, branch, path = m.groups()
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
return ""
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
def fetch_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
r = requests.get(url, params=params, timeout=60)
r.raise_for_status()
data = r.json()
if not isinstance(data, dict):
raise RuntimeError(f"Unexpected response from {url}: expected object")
return data
def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 500) -> list[dict[str, Any]]:
page = 1
items: list[dict[str, Any]] = []
while True:
params: dict[str, Any] = {"page": page, "perPage": per_page}
if expand:
params["expand"] = expand
data = fetch_json(url, params=params)
page_items = data.get("items", [])
if not isinstance(page_items, list):
raise RuntimeError(f"Unexpected items list from {url}")
items.extend(page_items)
total_pages = data.get("totalPages", page)
if not isinstance(total_pages, int) or page >= total_pages:
break
page += 1
return items
def normalize_os_variants(install_methods: list[dict[str, Any]]) -> list[str]:
os_values: list[str] = []
for item in install_methods:
if not isinstance(item, dict):
continue
resources = item.get("resources", {})
if not isinstance(resources, dict):
continue
os_name = resources.get("os")
if isinstance(os_name, str) and os_name.strip():
normalized = os_name.strip().lower()
if normalized not in os_values:
os_values.append(normalized)
return os_values
def split_notes(notes_raw: list[dict[str, Any]]) -> tuple[list[str], list[str]]:
"""Split PocketBase notes into (info_notes, warnings).
Each entry has shape ``{"text": str, "type": "warning"|...}``. Anything
flagged ``type == "warning"`` lands in the warnings list so the bash
menu can render those in red with a dedicated WARNINGS header. Other
notes go to the regular notes list.
"""
info: list[str] = []
warns: list[str] = []
for note in notes_raw or []:
if not isinstance(note, dict):
continue
text = note.get("text")
if not isinstance(text, str) or not text.strip():
continue
text = text.strip()
ntype = (note.get("type") or "").strip().lower()
if ntype == "warning":
warns.append(text)
else:
info.append(text)
return info, warns
def build_script_path(type_name: str, slug: str) -> str:
type_name = (type_name or "").strip().lower()
slug = (slug or "").strip()
if type_name == "turnkey":
return "turnkey/turnkey.sh"
prefix = TYPE_TO_PATH_PREFIX.get(type_name)
if not prefix or not slug:
return ""
return f"{prefix}/{slug}.sh"
def main() -> int:
try:
raw = requests.get(url).json()
scripts = fetch_all_records(SCRIPT_COLLECTION_URL, expand="type,categories")
categories = fetch_all_records(CATEGORY_COLLECTION_URL)
except Exception as e:
print(f"ERROR: Unable to fetch PocketBase data: {e}", file=sys.stderr)
return 1
category_map: dict[str, dict[str, Any]] = {}
for category in categories:
category_id = category.get("id")
if isinstance(category_id, str) and category_id:
category_map[category_id] = category
cache: list[dict[str, Any]] = []
print(f"Fetched {len(scripts)} scripts and {len(category_map)} categories")
for idx, raw in enumerate(scripts, start=1):
if not isinstance(raw, dict):
continue
except:
continue
# Extract fields required to identify a valid helper script
name = raw.get("name", "")
slug = raw.get("slug")
type_ = raw.get("type", "")
script = raw.get("install_methods", [{}])[0].get("script", "")
if not slug or not script:
continue # Skip if it's not a valid script
slug = raw.get("slug")
name = raw.get("name", "")
desc = raw.get("description", "")
desc = raw.get("description", "")
categories = raw.get("categories", [])
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
full_script_url = f"{SCRIPT_BASE}/{script}"
if not isinstance(slug, str) or not slug.strip():
continue
expand = raw.get("expand", {}) if isinstance(raw.get("expand"), dict) else {}
type_expanded = expand.get("type", {}) if isinstance(expand.get("type"), dict) else {}
type_name = type_expanded.get("type", "") if isinstance(type_expanded.get("type"), str) else ""
credentials = raw.get("default_credentials", {})
cred_username = credentials.get("username")
cred_password = credentials.get("password")
add_credentials = (
(cred_username is not None and str(cred_username).strip() != "") or
(cred_password is not None and str(cred_password).strip() != "")
)
script_path = build_script_path(type_name, slug)
if not script_path:
print(f"[{idx:03d}] WARNING: Unable to build script path for slug={slug} type={type_name!r}", file=sys.stderr)
continue
entry = {
"name": name,
"slug": slug,
"desc": desc,
"script": script,
"script_url": full_script_url,
"categories": categories,
"notes": notes,
"type": type_
}
if add_credentials:
entry["default_credentials"] = {
"username": cred_username,
"password": cred_password
full_script_url = f"{SCRIPT_BASE}/{script_path}"
script_url_mirror = to_mirror_url(full_script_url)
# Sprint 11.7: PocketBase exposes these as `install_methods` and
# `notes`, not `install_methods_json` / `notes_json`. The legacy field
# names silently returned [] for every entry, which is why the cache
# had empty notes and missing OS variants for every script.
install_methods = raw.get("install_methods", [])
if not isinstance(install_methods, list):
install_methods = []
notes_raw = raw.get("notes", [])
if not isinstance(notes_raw, list):
notes_raw = []
notes, warnings = split_notes(notes_raw)
category_ids = raw.get("categories", [])
if not isinstance(category_ids, list):
category_ids = []
expanded_categories = expand.get("categories", []) if isinstance(expand.get("categories"), list) else []
category_names: list[str] = []
for cat in expanded_categories:
if isinstance(cat, dict):
cat_name = cat.get("name")
if isinstance(cat_name, str) and cat_name.strip():
category_names.append(cat_name.strip())
if not category_names:
for cat_id in category_ids:
cat = category_map.get(cat_id, {})
cat_name = cat.get("name")
if isinstance(cat_name, str) and cat_name.strip():
category_names.append(cat_name.strip())
# Shared fields across all install method entries
default_user = raw.get("default_user")
default_passwd = raw.get("default_passwd")
default_credentials: dict[str, str] | None = None
if (isinstance(default_user, str) and default_user.strip()) or (isinstance(default_passwd, str) and default_passwd.strip()):
default_credentials = {
"username": default_user if isinstance(default_user, str) else "",
"password": default_passwd if isinstance(default_passwd, str) else "",
}
base_entry: dict[str, Any] = {
"name": name,
"slug": slug,
"desc": desc,
"script": script_path,
"script_url": full_script_url,
"script_url_mirror": script_url_mirror,
"type": type_name,
"type_id": raw.get("type", ""),
"categories": category_ids,
"category_names": category_names,
"notes": notes,
"warnings": warnings,
"port": raw.get("port", 0),
"website": raw.get("website", ""),
"documentation": raw.get("documentation", ""),
"logo": raw.get("logo", ""),
"updateable": bool(raw.get("updateable", False)),
"privileged": bool(raw.get("privileged", False)),
"has_arm": bool(raw.get("has_arm", False)),
"is_dev": bool(raw.get("is_dev", False)),
"execute_in": raw.get("execute_in", []),
"config_path": raw.get("config_path", ""),
}
if default_credentials:
base_entry["default_credentials"] = default_credentials
cache.append(entry)
# Emit one entry per install method so the menu shell can offer an
# explicit OS choice. When there is only one method (or none), a
# single entry is emitted with os="" (script decides at runtime).
os_variants = normalize_os_variants(install_methods)
if len(os_variants) > 1:
for os_name in os_variants:
entry = {**base_entry, "os": os_name}
cache.append(entry)
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name}")
else:
os_name = os_variants[0] if os_variants else ""
entry = {**base_entry, "os": os_name}
cache.append(entry)
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name or 'n/a'}")
# Write the JSON cache to disk
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2)
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
total_notes = sum(len(e.get("notes", [])) for e in cache)
total_warns = sum(len(e.get("warnings", [])) for e in cache)
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
print(f" Guardados: {len(cache)} entries, {total_notes} notes, {total_warns} warnings")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,76 @@
import requests, json
from pathlib import Path
# GitHub API URL to fetch all .json files describing scripts
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
# Base path to build the full URL for the installable scripts
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
# Output file where the consolidated helper scripts cache will be stored
OUTPUT_FILE = Path("json/helpers_cache.json")
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
res = requests.get(API_URL)
data = res.json()
cache = []
# Loop over each file in the JSON directory
for item in data:
url = item.get("download_url")
if not url or not url.endswith(".json"):
continue
try:
raw = requests.get(url).json()
if not isinstance(raw, dict):
continue
except:
continue
# Extract fields required to identify a valid helper script
name = raw.get("name", "")
slug = raw.get("slug")
type_ = raw.get("type", "")
script = raw.get("install_methods", [{}])[0].get("script", "")
if not slug or not script:
continue # Skip if it's not a valid script
desc = raw.get("description", "")
categories = raw.get("categories", [])
notes = [note.get("text", "") for note in raw.get("notes", []) if isinstance(note, dict)]
full_script_url = f"{SCRIPT_BASE}/{script}"
credentials = raw.get("default_credentials", {})
cred_username = credentials.get("username")
cred_password = credentials.get("password")
add_credentials = (
(cred_username is not None and str(cred_username).strip() != "") or
(cred_password is not None and str(cred_password).strip() != "")
)
entry = {
"name": name,
"slug": slug,
"desc": desc,
"script": script,
"script_url": full_script_url,
"categories": categories,
"notes": notes,
"type": type_
}
if add_credentials:
entry["default_credentials"] = {
"username": cred_username,
"password": cred_password
}
cache.append(entry)
# Write the JSON cache to disk
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2)
print(f"✅ helpers_cache.json created at {OUTPUT_FILE} with {len(cache)} valid scripts.")
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
import requests
# ---------- Config ----------
# API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE-Frontend-Archive/contents/public/json"
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
# ----------------------------
def to_mirror_url(raw_url: str) -> str:
"""
Convierte una URL raw de GitHub al raw del mirror.
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
"""
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
if not m:
return ""
org, repo, branch, path = m.groups()
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
return ""
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
def guess_os_from_script_path(script_path: str) -> str | None:
"""
Heurística suave cuando el JSON no publica resources.os:
- tools/pve/* -> proxmox
- ct/alpine-* -> alpine
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
- ct/* -> debian (por defecto para CTs)
"""
if not script_path:
return None
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
return "proxmox"
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
return "alpine"
if script_path.startswith("tools/addon/"):
return "generic"
if script_path.startswith("ct/"):
return "debian"
return None
def fetch_directory_json(api_url: str) -> list[dict]:
r = requests.get(api_url, timeout=30)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
raise RuntimeError("The GitHub API did not return a list.")
return data
def main() -> int:
try:
directory = fetch_directory_json(API_URL)
except Exception as e:
print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
return 1
cache: list[dict] = []
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
total_items = len(directory)
processed = 0
kept = 0
for item in directory:
url = item.get("download_url")
name_in_dir = item.get("name", "")
if not url or not url.endswith(".json"):
continue
try:
raw = requests.get(url, timeout=30).json()
if not isinstance(raw, dict):
continue
except Exception:
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
continue
processed += 1
name = raw.get("name", "")
slug = raw.get("slug")
type_ = raw.get("type", "")
desc = raw.get("description", "")
categories = raw.get("categories", [])
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
# Credenciales (si existen, se copian tal cual)
credentials = raw.get("default_credentials", {})
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
add_credentials = any([
cred_username not in (None, ""),
cred_password not in (None, "")
])
install_methods = raw.get("install_methods", [])
if not isinstance(install_methods, list) or not install_methods:
# Sin install_methods válidos -> continuamos
continue
for im in install_methods:
if not isinstance(im, dict):
continue
script = im.get("script", "")
if not script:
continue
# OS desde resources u heurística
resources = im.get("resources", {}) if isinstance(im, dict) else {}
os_name = resources.get("os") if isinstance(resources, dict) else None
if not os_name:
os_name = guess_os_from_script_path(script)
if isinstance(os_name, str):
os_name = os_name.strip().lower()
full_script_url = f"{SCRIPT_BASE}/{script}"
script_url_mirror = to_mirror_url(full_script_url)
key = (slug or "", script)
if key in seen:
continue
seen.add(key)
entry = {
"name": name,
"slug": slug,
"desc": desc,
"script": script,
"script_url": full_script_url,
"script_url_mirror": script_url_mirror, # nuevo
"os": os_name, # nuevo
"categories": categories,
"notes": notes,
"type": type_,
}
if add_credentials:
entry["default_credentials"] = {
"username": cred_username,
"password": cred_password,
}
cache.append(entry)
kept += 1
# Progreso ligero
print(f"[{kept:03d}] {slug or name:<24} → {script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
# Orden estable para commits reproducibles
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
print(f" Total JSON en índice: {total_items}")
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""OCI validation record: renders oci/VALIDATION.md from verification.json,
checks the record, checks who a pull request credits, and records a reviewed
validation report."""
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
VERIFICATION = ROOT / "oci/catalog/verification.json"
INDEX = ROOT / "oci/catalog/index.json"
OUTPUT = ROOT / "oci/VALIDATION.md"
REVIEWERS = ROOT / ".github/oci-validation-reviewers"
REPO = "https://github.com/MacRimi/ProxMenux"
BOARD = "https://github.com/users/MacRimi/projects/1"
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
DISCUSSION = f"{REPO}/discussions/360"
MAINTAINERS = {"macrimi"}
LAB = "laboratory-validated"
USER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")
REPORT_RE = re.compile(r"^https://github\.com/MacRimi/ProxMenux/"
r"(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$")
ENTRY_KEYS = {"status", "community_tested"}
COMMUNITY_KEYS = {"by", "date", "report", "digest", "scenario"}
DIGEST_RE = re.compile(r"^[a-f0-9]{8,64}$")
# Form fields that describe the tested scenario, in the order they are read.
SCENARIO_FIELDS = ("Install mode", "Data storage", "Network", "GPU")
NO_RESPONSE = "_No response_"
def load(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def dump(data):
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def applications(index):
return {item["id"]: item for item in index.get("applications", [])}
def lab_validated(entry, item):
# Same rule as the catalog: an application that cannot be installed is not verified.
return entry.get("status") == LAB and bool(item.get("automatic_install_candidate"))
def community(entry):
tested = entry.get("community_tested")
return tested if isinstance(tested, dict) and tested.get("by") else None
def cell(text):
return str(text).replace("|", "\\|").strip()
def render(verification, index):
apps = applications(index)
entries = {app: entry for app, entry in verification.get("applications", {}).items()
if app in apps and isinstance(entry, dict)}
lab = sorted((app for app, entry in entries.items() if lab_validated(entry, apps[app])),
key=lambda app: str(apps[app].get("title") or app).casefold())
tested = sorted((app for app, entry in entries.items() if community(entry)),
key=lambda app: str(apps[app].get("title") or app).casefold())
visible = sum(1 for item in apps.values() if not item.get("hidden"))
verified = len(set(lab) | set(tested))
lines = [
"# OCI application validation",
"",
"<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->",
"",
f"{verified} of {visible} applications in the OCI catalog have been tested for real: "
f"{len(lab)} in the ProxMenux lab and {len(tested)} by the community. "
"The OCI installer shows them as verified, with a ✓ in the lists.",
"",
"Each test describes the scenario that was run and names the image it ran on. It does not cover every possible "
"configuration of the application, and a newer image has not been tested until someone reports it.",
"",
"## Taking part",
"",
"Any application that is not listed here is open for testing.",
"",
f"1. Check the [ProxMenux Roadmap]({BOARD}) to see which applications someone is already testing. "
"With access to the board, create a card for the application and move it to In progress while you test it.",
f"2. Test the application with the OCI manager and fill in the [OCI validation report]({FORM}). "
"The report records the image and the exact scenario: install mode, storage, network and GPU.",
"3. A reviewer reads the report and comments `/validated` on it. The application is then added to this list "
"with your GitHub user and shown as verified in the OCI installer, and the report is closed.",
"",
"Reviewers are listed in [.github/oci-validation-reviewers](../.github/oci-validation-reviewers), and a "
"reviewer does not validate their own report. A validation can also be added with a pull request that adds "
"the entry to `oci/catalog/verification.json` and regenerates this file with "
"`python3 .github/scripts/oci_validation.py render`; CI checks that the entry credits the author of the pull request.",
"",
"An application that cannot be validated for a reason outside the tester's hands — hardware nobody has, something "
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
"and what would make it worth trying again.",
"",
f"Questions and ideas about OCI testing go in [discussion #360]({DISCUSSION}).",
"",
"## Tested by the community",
"",
]
if tested:
lines += ["| Application | Tested by | Date | Image | Scenario | Report |", "|---|---|---|---|---|---|"]
for app in tested:
entry = community(entries[app])
report = f"[Report]({entry['report']})" if entry.get("report") else "—"
image = f"`{entry['digest'][:12]}`" if entry.get("digest") else "—"
lines.append(f"| {cell(apps[app].get('title') or app)} | [@{entry['by']}](https://github.com/{entry['by']}) "
f"| {entry.get('date') or '—'} | {image} | {cell(entry.get('scenario') or '—')} | {report} |")
else:
lines.append("No application has been tested by the community yet.")
lines += ["", "## Tested in the ProxMenux lab", ""]
if lab:
lines += ["| Application | Category |", "|---|---|"]
for app in lab:
lines.append(f"| {cell(apps[app].get('title') or app)} | {cell(apps[app].get('category_label') or '—')} |")
else:
lines.append("No application has been tested in the ProxMenux lab yet.")
return "\n".join(lines) + "\n"
def problems(verification, index, rendered=None, today=None):
today = today or datetime.date.today()
apps = applications(index)
found = []
entries = verification.get("applications") if isinstance(verification, dict) else None
if not isinstance(entries, dict):
return ["verification.json must hold an \"applications\" object"]
for app, entry in entries.items():
where = f"verification.json: {app}"
if app not in apps:
found.append(f"{where}: not an application of the OCI catalog")
if not isinstance(entry, dict) or not entry:
found.append(f"{where}: the entry must be a non-empty object")
continue
if set(entry) - ENTRY_KEYS:
found.append(f"{where}: unknown keys {sorted(set(entry) - ENTRY_KEYS)}")
if "status" in entry and entry["status"] != LAB:
found.append(f"{where}: status can only be \"{LAB}\"")
if "community_tested" not in entry:
continue
tested = entry["community_tested"]
if not isinstance(tested, dict):
found.append(f"{where}: community_tested must be an object")
continue
if set(tested) - COMMUNITY_KEYS:
found.append(f"{where}: unknown community_tested keys {sorted(set(tested) - COMMUNITY_KEYS)}")
if not USER_RE.fullmatch(str(tested.get("by", ""))):
found.append(f"{where}: \"by\" must be a GitHub user name")
try:
date = datetime.date.fromisoformat(str(tested.get("date", "")))
if date > today + datetime.timedelta(days=1):
found.append(f"{where}: the date is in the future")
except ValueError:
found.append(f"{where}: \"date\" must be YYYY-MM-DD")
if "report" in tested and not REPORT_RE.fullmatch(str(tested["report"])):
found.append(f"{where}: \"report\" must link to an issue or discussion of MacRimi/ProxMenux")
if "digest" in tested and not DIGEST_RE.fullmatch(str(tested["digest"])):
found.append(f"{where}: \"digest\" must be 8 to 64 lowercase hex characters of the image digest")
if "scenario" in tested and (not isinstance(tested["scenario"], str) or len(tested["scenario"]) > 300):
found.append(f"{where}: \"scenario\" must be text of at most 300 characters")
if rendered is not None and rendered != render(verification, index):
found.append("oci/VALIDATION.md is out of date: run python3 .github/scripts/oci_validation.py render")
return found
def author_problems(base, head, author):
"""A pull request only adds or changes community tests credited to its author."""
if author.casefold() in MAINTAINERS:
return []
found = []
before = base.get("applications", {}) if isinstance(base, dict) else {}
after = head.get("applications", {})
for app in sorted(set(before) | set(after)):
old = before.get(app) if isinstance(before.get(app), dict) else {}
new = after.get(app) if isinstance(after.get(app), dict) else {}
if old.get("status") != new.get("status"):
found.append(f"{app}: the ProxMenux lab status is changed by the maintainer only")
if old.get("community_tested") == new.get("community_tested"):
continue
credited = community(new)
if not credited:
found.append(f"{app}: a community test is removed by the maintainer only")
elif str(credited["by"]).casefold() != author.casefold():
found.append(f"{app}: credits @{credited['by']}, but the pull request is from @{author}")
return found
def parse_form(body):
"""Issue form answers, keyed by the field label."""
fields, label, lines = {}, None, []
for line in (body or "").replace("\r\n", "\n").split("\n"):
if line.startswith("### "):
if label is not None:
fields[label] = "\n".join(lines).strip()
label, lines = line[4:].strip(), []
elif label is not None:
lines.append(line)
if label is not None:
fields[label] = "\n".join(lines).strip()
return {key: ("" if value == NO_RESPONSE else value) for key, value in fields.items()}
def resolve(name, index):
wanted = " ".join(name.split()).casefold()
matches = [item["id"] for item in index.get("applications", [])
if wanted in (str(item["id"]).casefold(), " ".join(str(item.get("title") or "").split()).casefold())]
return matches[0] if len(matches) == 1 else None
def image_digest(text):
"""The digest as the Monitor shows it (b1f339cf) or in full (sha256:b1f3...)."""
value = str(text or "").strip().lower()
value = value.split("·")[-1].strip()
value = value[7:] if value.startswith("sha256:") else value
return value if DIGEST_RE.fullmatch(value) else None
def scenario(fields):
parts = [fields.get(name, "") for name in SCENARIO_FIELDS]
if fields.get("Proxmox VE version"):
parts.append(f"Proxmox VE {fields['Proxmox VE version']}")
if fields.get("Image version"):
parts.append(f"image {fields['Image version']}")
text = " · ".join(part.strip() for part in parts if part and part.strip())
if fields.get("Result") == "Works with problems":
text = f"Works with problems: {text}"
return text[:300]
def reviewers(text):
"""User names of the reviewer list, one per line; # starts a comment."""
names = {line.split("#", 1)[0].strip() for line in text.splitlines()}
return {name.casefold() for name in names if USER_RE.fullmatch(name)}
def record(event, verification, index, today, allowed=frozenset()):
"""Returns (verification, app id, message); raises ValueError with the reason to refuse.
A label can only be added by someone with write access to the repository.
A /validated comment is accepted from the reviewer list, never from the
report's own author unless it is the maintainer."""
issue = event.get("issue") or {}
labels = {label.get("name") for label in issue.get("labels", [])}
if "oci-validation" not in labels:
raise ValueError("This issue is not an OCI validation report.")
author = str((issue.get("user") or {}).get("login", ""))
report = str(issue.get("html_url", ""))
if not USER_RE.fullmatch(author) or not REPORT_RE.fullmatch(report):
raise ValueError("The report author or link could not be read.")
reviewer = str(((event.get("comment") or {}).get("user") or {}).get("login", ""))
if event.get("comment") is not None:
if reviewer.casefold() not in allowed:
raise ValueError(f"@{reviewer} is not in the list of OCI validation reviewers "
"(.github/oci-validation-reviewers), so the report is not recorded.")
if reviewer.casefold() == author.casefold() and reviewer.casefold() not in MAINTAINERS:
raise ValueError("A report is validated by a reviewer other than its author.")
fields = parse_form(issue.get("body"))
name = fields.get("Application", "")
app = resolve(name, index)
if app is None:
raise ValueError(f"\"{name}\" does not match one application of the OCI catalog. "
"Edit the Application field with its name or ID as shown in the catalog, then add the label again.")
if fields.get("Result") == "Does not work":
raise ValueError("The report says the application does not work, so it is not recorded as verified.")
digest = image_digest(fields.get("Image digest"))
if digest is None:
raise ValueError("The Image digest field does not hold an image digest. Edit it with the digest shown in the "
"App tab of the Monitor, for example b1f339cf, then add the label again.")
data = json.loads(json.dumps(verification))
entries = data.setdefault("applications", {})
entry = entries.setdefault(app, {})
previous = community(entry)
entry["community_tested"] = {"by": author, "date": today.isoformat(), "report": report,
"digest": digest, "scenario": scenario(fields)}
data["applications"] = dict(sorted(entries.items()))
title = applications(index)[app].get("title") or app
validated = f" Validated by @{reviewer}." if reviewer else ""
message = (f"Thank you, @{author}! {title} is now recorded as verified.{validated} "
f"It shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md]({REPO}/blob/develop/oci/VALIDATION.md).")
if previous and previous.get("by") != author:
message += f" This report replaces the previous one from @{previous['by']}."
return data, app, message
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("render", help="Write oci/VALIDATION.md")
commands.add_parser("check", help="Check verification.json and that oci/VALIDATION.md is current")
authors = commands.add_parser("authors", help="Check who a pull request credits")
authors.add_argument("--base", required=True, help="verification.json of the base branch; may be missing")
authors.add_argument("--author", required=True)
rec = commands.add_parser("record", help="Record a reviewed validation report")
rec.add_argument("--event", required=True)
rec.add_argument("--message", required=True, help="File that receives the reply for the issue")
args = parser.parse_args(argv)
verification, index = load(VERIFICATION), load(INDEX)
if args.command == "render":
OUTPUT.write_text(render(verification, index), encoding="utf-8")
return 0
if args.command == "check":
rendered = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
found = problems(verification, index, rendered)
elif args.command == "authors":
base = load(args.base) if Path(args.base).exists() and Path(args.base).stat().st_size else {}
found = author_problems(base, verification, args.author)
else:
try:
allowed = reviewers(REVIEWERS.read_text(encoding="utf-8")) if REVIEWERS.exists() else frozenset()
data, app, message = record(load(args.event), verification, index, datetime.date.today(), allowed)
except ValueError as error:
Path(args.message).write_text(str(error) + "\n", encoding="utf-8")
return 2
VERIFICATION.write_text(dump(data), encoding="utf-8")
OUTPUT.write_text(render(data, index), encoding="utf-8")
Path(args.message).write_text(message + "\n", encoding="utf-8")
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"app={app}\n")
return 0
for problem in found:
print(f"::error::{problem}")
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main())
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run only the independently qualified, host-independent tests/ fixtures.
No filesystem discovery widens this manifest. Run from the repository root;
Python and Node have separate CI jobs so either failure blocks the check.
"""
import argparse
import os
from pathlib import Path
import subprocess
import sys
PYTHON_FILES = (
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
"tests/test_audit_policy.py",
"tests/test_audit_report.py",
"tests/test_audit_catalog.py",
"tests/storage/test_nvme_status_message.py",
"tests/test_fastfetch_config_generation.py",
"tests/test_notification_active_payloads.py",
)
NODE_FILES = (
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
"tests/test_audit_policy.cjs",
"tests/test_audit_presentation.cjs",
"tests/test_audit_safety_wording.cjs",
"tests/test_audit_summary.cjs",
"tests/test_backup_archives_empty.cjs",
"tests/test_backup_destination_messages.cjs",
"tests/test_borg_ssh_guidance.cjs",
"tests/test_storage_messages.cjs",
"tests/test_health_text.cjs",
)
# unittest's CLI accepts a missing pattern as a successful zero-test run.
# Load the exact file in a fresh interpreter and reject zero tests and skips.
PYTHON_RUN = """import sys, unittest
folder, filename = sys.argv[1:]
suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename)
count = suite.countTestCases()
if count == 0:
raise SystemExit('No tests discovered: ' + folder + '/' + filename)
print('DISCOVERED=' + str(count), flush=True)
result = unittest.TextTestRunner(verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1)
"""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lane", required=True, choices=("python", "node"))
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
files = PYTHON_FILES if args.lane == "python" else NODE_FILES
if not files or Path.cwd().resolve() != root:
parser.error("nonempty manifest and repository-root working directory required")
for path in files:
if not (root / path).is_file():
print(f"Missing qualified test: {path}", file=sys.stderr)
return 1
if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir():
print("Install the locked offline-node dependencies first", file=sys.stderr)
return 1
for path in files:
print(f"RUN {path}", flush=True)
if args.lane == "python":
folder, filename = str(Path(path).parent), Path(path).name
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
else:
command = ["node", path]
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
if result.returncode != 0:
print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr)
return 1
print(f"PASS {path}", flush=True)
print(f"PASS {args.lane}: {len(files)} qualified files", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,558 @@
{
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
"fallback_indices": [
9,
10,
19,
20,
21,
22,
31,
32,
33,
34,
43,
44,
45,
58,
59
],
"locales": {
"es": [
"Listar todos los nodos Proxmox",
"Listar VM en un nodo",
"Listar contenedores LXC en un nodo",
"Listar almacenamiento en un nodo",
"Listar interfaces de red",
"Listar todas las máquinas virtuales QEMU/KVM",
"Iniciar una máquina virtual",
"Detener una máquina virtual",
"Apagar una máquina virtual de forma segura",
"Mostrar estado de VM",
"Mostrar configuración de VM",
"Crear una instantánea de VM",
"Listar todos los contenedores LXC",
"Iniciar un contenedor LXC",
"Detener un contenedor LXC",
"Abrir una consola de contenedor LXC",
"Mostrar configuración del contenedor",
"Mostrar estado de almacenamiento",
"Enumerar el contenido del almacenamiento",
"Probar el rendimiento del sistema Proxmox",
"Mostrar la versión de Proxmox VE",
"Verificar el estado del clúster",
"Mostrar el estado del clúster",
"Enumerar los nodos del clúster",
"Mostrar el estado del grupo ZFS",
"Enumerar todos los grupos ZFS",
"Enumerar todo Conjuntos de datos ZFS",
"Enumerar todos los archivos con detalles",
"Cambiar directorio",
"Crear un directorio",
"Eliminar un directorio de forma recursiva",
"Copiar archivos o directorios",
"Mover o cambiar el nombre de archivos",
"Mostrar el contenido del archivo",
"Buscar un patrón en un archivo",
"Buscar archivos por nombre",
"Cambiar permisos de archivos",
"Cambiar el propietario del archivo",
"Extraer un archivo tar.gz",
"Crear un tar.gz archivo",
"Mostrar uso del disco",
"Mostrar tamaños de directorio",
"Mostrar uso de memoria",
"Mostrar procesos en ejecución",
"Buscar un proceso en ejecución",
"Forzar la eliminación de un proceso",
"Verificar el estado del servicio",
"Iniciar un servicio",
"Detener un servicio",
"Reiniciar un servicio",
"Actualizar paquetes de Debian/Ubuntu",
"Instalar un paquete de Debian/Ubuntu",
"Eliminar un paquete",
"Listar contenedores en ejecución",
"Listar Docker imágenes",
"Abrir un shell en un contenedor",
"Mostrar direcciones IP",
"Probar conectividad de red",
"Obtener encabezados HTTP",
"Descargar un archivo",
"Conectarse a través de SSH",
"Copiar un archivo a través de SSH",
"Seguir un archivo de registro en tiempo real",
"Mostrar historial de comandos",
"Borrar la pantalla del terminal"
],
"de": [
"Alle Proxmox-Knoten auflisten",
"VMs auf einem Knoten auflisten",
"LXC-Container auf einem Knoten auflisten",
"Speicher auf einem Knoten auflisten",
"Netzwerkschnittstellen auflisten",
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
"Eine virtuelle Maschine starten",
"Eine virtuelle Maschine stoppen",
"Eine virtuelle Maschine sicher herunterfahren",
"VM-Status anzeigen",
"VM-Konfiguration anzeigen",
"Einen VM-Snapshot erstellen",
"Alle LXC-Container auflisten",
"Einen LXC-Container starten",
"Einen LXC-Container stoppen",
"Eine LXC-Containerkonsole öffnen",
"Containerkonfiguration anzeigen",
"Speicherstatus anzeigen",
"Speicherinhalte auflisten",
"Proxmox-Systemleistung testen",
"Proxmox VE-Version anzeigen",
"Clusterstatus prüfen",
"Clusterstatus anzeigen",
"Clusterknoten auflisten",
"ZFS-Poolstatus anzeigen",
"Alle ZFS-Pools auflisten",
"Alle ZFS-Datensätze auflisten",
"Alle Dateien mit Details auflisten",
"Verzeichnis ändern",
"Verzeichnis erstellen",
"Verzeichnis rekursiv entfernen",
"Dateien oder Verzeichnisse kopieren",
"Dateien verschieben oder umbenennen",
"Dateiinhalte anzeigen",
"Nach einem Muster in einer Datei suchen",
"Dateien nach Namen suchen",
"Dateiberechtigungen ändern",
"Dateieigentümer ändern",
"Tar.gz-Archiv extrahieren",
"Tar.gz-Archiv erstellen",
"Festplattennutzung anzeigen",
"Verzeichnisgrößen anzeigen",
"Speichernutzung anzeigen",
"Laufende Prozesse anzeigen",
"Laufenden Prozess suchen",
"Prozess beenden",
"Dienststatus prüfen",
"Dienst starten",
"Dienst stoppen",
"Dienst neu starten",
"Debian/Ubuntu-Pakete aktualisieren",
"Debian/Ubuntu-Paket installieren",
"Paket entfernen",
"Laufende Container auflisten",
"Docker-Images auflisten",
"Eine Shell in einem Container öffnen",
"IP-Adressen anzeigen",
"Netzwerkkonnektivität testen",
"HTTP-Header abrufen",
"Eine Datei herunterladen",
"Über SSH verbinden",
"Eine Datei über SSH kopieren",
"Eine Protokolldatei in Echtzeit verfolgen",
"Befehlsverlauf anzeigen",
"Terminalbildschirm löschen"
],
"fr": [
"Liste de tous les nœuds Proxmox",
"Liste des machines virtuelles sur un nœud",
"Liste des conteneurs LXC sur un nœud",
"Liste du stockage sur un nœud",
"Liste des interfaces réseau",
"Liste de toutes les machines virtuelles QEMU/KVM",
"Démarrer une machine virtuelle",
"Arrêter une machine virtuelle",
"Arrêter une machine virtuelle en toute sécurité",
"Afficher l'état de la VM",
"Afficher la VM configuration",
"Créer un instantané de VM",
"Liste de tous les conteneurs LXC",
"Démarrer un conteneur LXC",
"Arrêter un conteneur LXC",
"Ouvrir une console de conteneur LXC",
"Afficher la configuration du conteneur",
"Afficher l'état du stockage",
"Liste du contenu du stockage",
"Tester les performances du système Proxmox",
"Afficher la version Proxmox VE",
"Vérifier l'état du cluster",
"Afficher l'état du cluster",
"Liste des nœuds du cluster",
"Afficher l'état du pool ZFS",
"Liste de tous les pools ZFS",
"Liste de tous les ensembles de données ZFS",
"Liste de tous les fichiers avec détails",
"Changer de répertoire",
"Créer un répertoire",
"Supprimer un répertoire de manière récursive",
"Copier des fichiers ou des répertoires",
"Déplacer ou renommer des fichiers",
"Afficher le contenu du fichier",
"Rechercher un modèle dans un fichier",
"Rechercher des fichiers par nom",
"Modifier les autorisations du fichier",
"Modifier le propriétaire du fichier",
"Extraire une archive tar.gz",
"Créer une archive tar.gz",
"Afficher l'utilisation du disque",
"Afficher la taille des répertoires",
"Afficher l'utilisation de la mémoire",
"Afficher les processus en cours d'exécution",
"Trouver un processus en cours d'exécution",
"Forcer l'arrêt d'un processus",
"Vérifier le service\". statut",
"Démarrer un service",
"Arrêter un service",
"Redémarrer un service",
"Mettre à jour les packages Debian/Ubuntu",
"Installer un package Debian/Ubuntu",
"Supprimer un package",
"Liste des conteneurs en cours d'exécution",
"Liste des images Docker",
"Ouvrir un shell dans un conteneur",
"Afficher les adresses IP",
"Tester la connectivité réseau",
"Obtenir les en-têtes HTTP",
"Télécharger un fichier",
"Connecter via SSH",
"Copier un fichier via SSH",
"Suivre un fichier journal en temps réel",
"Afficher l'historique des commandes",
"Effacer l'écran du terminal"
],
"it": [
"Elenca tutti i nodi Proxmox",
"Elenca VM su un nodo",
"Elenca container LXC su un nodo",
"Elenca storage su un nodo",
"Elenca interfacce di rete",
"Elenca tutte le VM QEMU/KVM",
"Avvia una VM",
"Arresta una VM",
"Spegni una VM in modo ordinato",
"Mostra stato VM",
"Mostra configurazione VM",
"Crea snapshot VM",
"Elenca tutti i container LXC",
"Avvia un container LXC",
"Arresta un container LXC",
"Apri una shell in un container LXC",
"Mostra configurazione container",
"Mostra stato storage",
"Elenca contenuto storage",
"Verifica prestazioni del sistema Proxmox",
"Mostra versione Proxmox VE",
"Mostra stato servizio pve-cluster",
"Mostra stato cluster",
"Elenca nodi cluster",
"Mostra stato pool ZFS",
"Elenca tutti i pool ZFS",
"Elenca filesystem e volumi ZFS",
"Elenca tutti i file con dettagli",
"Cambia directory",
"Crea una directory",
"Rimuovi una directory ricorsivamente",
"Copia file",
"Sposta o rinomina file",
"Visualizza contenuto file",
"Cerca un pattern in un file",
"Trova file per nome",
"Modifica permessi file",
"Cambia proprietario file",
"Estrai un archivio tar.gz",
"Crea un archivio tar.gz",
"Mostra utilizzo disco",
"Mostra dimensioni directory",
"Mostra utilizzo memoria",
"Mostra processi in esecuzione",
"Trova un processo in esecuzione",
"Termina forzatamente un processo",
"Controlla stato servizio",
"Avvia un servizio",
"Arresta un servizio",
"Riavvia un servizio",
"Aggiorna pacchetti Debian/Ubuntu",
"Installa un pacchetto Debian/Ubuntu",
"Rimuovi un pacchetto",
"Elenca i container in esecuzione",
"Elenca immagini Docker",
"Apri una shell in un container",
"Mostra indirizzi IP",
"Verifica connettività di rete",
"Ottieni intestazioni HTTP",
"Scarica un file",
"Connettiti tramite SSH",
"Copia un file tramite SSH",
"Segui un file di log in tempo reale",
"Mostra cronologia comandi",
"Pulisci lo schermo del terminale"
],
"pt": [
"Listar todos os nós Proxmox",
"Listar VMs em um nó",
"Listar contêineres LXC em um nó",
"Listar armazenamento em um nó",
"Listar interfaces de rede",
"Listar todas as máquinas virtuais QEMU/KVM",
"Iniciar uma máquina virtual",
"Parar uma máquina virtual",
"Desligar uma máquina virtual com segurança",
"Mostrar status da VM",
"Mostrar configuração da VM",
"Criar um instantâneo de VM",
"Listar todos os LXC containers",
"Iniciar um contêiner LXC",
"Parar um contêiner LXC",
"Abrir um console de contêiner LXC",
"Mostrar configuração do contêiner",
"Mostrar status de armazenamento",
"Listar conteúdo de armazenamento",
"Testar desempenho do sistema Proxmox",
"Mostrar a versão Proxmox VE",
"Verificar status do cluster",
"Mostrar status do cluster",
"Listar nós do cluster",
"Mostrar status do pool ZFS",
"Listar todos os pools ZFS",
"Listar todos os conjuntos de dados ZFS",
"Listar todos os arquivos com detalhes",
"Alterar diretório",
"Criar um diretório",
"Remover um diretório recursivamente",
"Copiar arquivos ou diretórios",
"Mover ou renomear arquivos",
"Exibir conteúdo do arquivo",
"Pesquisar um padrão em um arquivo",
"Encontrar arquivos por nome",
"Alterar permissões de arquivo",
"Alterar o proprietário do arquivo",
"Extrair um arquivo tar.gz",
"Criar um arquivo tar.gz",
"Mostrar uso de disco",
"Mostrar tamanhos de diretório",
"Mostrar uso de memória",
"Mostrar processos em execução",
"Encontrar um processo em execução",
"Forçar a eliminação de um processo",
"Verificar status do serviço",
"Iniciar um serviço",
"Parar um serviço",
"Reiniciar um serviço",
"Atualizar pacotes Debian/Ubuntu",
"Instalar um pacote Debian/Ubuntu",
"Remover um pacote",
"Listar contêineres em execução",
"Listar imagens do Docker",
"Abrir um shell em um contêiner",
"Mostrar endereços IP",
"Testar conectividade de rede",
"Obter cabeçalhos HTTP",
"Baixar um arquivo",
"Conectar através de SSH",
"Copiar um arquivo através de SSH",
"Seguir um arquivo de log em tempo real",
"Mostrar histórico de comandos",
"Limpar a tela do terminal"
],
"sv": [
"Lista alla Proxmox-noder",
"Lista virtuella datorer på en nod",
"Lista LXC-behållare på en nod",
"Lista lagringsutrymme på en nod",
"Lista nätverksgränssnitt",
"Lista alla virtuella QEMU/KVM-maskiner",
"Starta en virtuell maskin",
"Stoppa en virtuell maskin",
"Stäng av en virtuell maskin på ett säkert sätt",
"Show VM status",
"Show VM configuration",
"Skapa en VM Snapshot",
"Lista alla LXC-behållare",
"Starta en LXC-behållare",
"Stoppa en LXC-behållare",
"Öppna en LXC-containerkonsol",
"Visa behållarkonfiguration",
"Visa lagringsstatus",
"Lista lagringsinnehåll",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Check cluster status",
"Show cluster status",
"Lista klusternoder",
"Visa ZFS-poolstatus",
"Lista alla ZFS-pooler",
"Lista alla ZFS-datauppsättningar",
"Lista alla filer med detaljer",
"Ändra katalog",
"Skapa en katalog",
"Ta bort en katalog rekursivt",
"Copy files or directories",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Hitta filer efter namn",
"Ändra filbehörigheter",
"Ändra filägare",
"Extrahera ett tar.gz-arkiv",
"Skapa ett tar.gz-arkiv",
"Visa diskanvändning",
"Visa katalogstorlekar",
"Visa minnesanvändning",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Kontrollera tjänsten status",
"Starta en tjänst",
"Stoppa en tjänst",
"Starta om en tjänst",
"Uppdatera Debian/Ubuntu-paket",
"Installera ett Debian/Ubuntu-paket",
"Ta bort ett paket",
"Lista behållare som körs",
"Lista Docker-avbildningar",
"Öppna ett skal i en behållare",
"Visa IP-adresser",
"Testa en nätverksanslutning",
"Get HTTP headers",
"Download a file",
"Anslut via SSH",
"Kopiera en fil via SSH",
"Följ en loggfil i realtid",
"Visa kommandohistorik",
"Rensa terminalskärmen"
]
},
"english": [
"List all Proxmox nodes",
"List VMs on a node",
"List LXC containers on a node",
"List storage on a node",
"List network interfaces",
"List all QEMU/KVM virtual machines",
"Start a virtual machine",
"Stop a virtual machine",
"Shut down a virtual machine safely",
"Show VM status",
"Show VM configuration",
"Create a VM Snapshot",
"List all LXC containers",
"Start an LXC container",
"Stop an LXC container",
"Open a shell in an LXC container",
"Show container configuration",
"Show storage status",
"List storage contents",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Show pve-cluster service status",
"Show cluster status",
"List cluster nodes",
"Show ZFS pool status",
"List all ZFS pools",
"List ZFS filesystems and volumes",
"List all files with details",
"Change directory",
"Create a directory",
"Remove a directory recursively",
"Copy files",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Find files by name",
"Change file permissions",
"Change the file owner",
"Extract a tar.gz archive",
"Create a tar.gz archive",
"Show disk usage",
"Show directory sizes",
"Show memory usage",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Check service status",
"Start a service",
"Stop a service",
"Restart a service",
"Update Debian/Ubuntu packages",
"Install a Debian/Ubuntu package",
"Remove a package",
"List running containers",
"List Docker images",
"Open a shell in a container",
"Show IP addresses",
"Test network connectivity",
"Get HTTP headers",
"Download a file",
"Connect through SSH",
"Copy a file through SSH",
"Follow a log file in real time",
"Show command history",
"Clear the terminal screen"
],
"commands": [
"pvesh get /nodes",
"pvesh get /nodes/{node}/qemu",
"pvesh get /nodes/{node}/lxc",
"pvesh get /nodes/{node}/storage",
"pvesh get /nodes/{node}/network",
"qm list",
"qm start <vmid>",
"qm stop <vmid>",
"qm shutdown <vmid>",
"qm status <vmid>",
"qm config <vmid>",
"qm snapshot <vmid> <snapname>",
"pct list",
"pct start <vmid>",
"pct stop <vmid>",
"pct enter <vmid>",
"pct config <vmid>",
"pvesm status",
"pvesm list <storage>",
"pveperf",
"pveversion",
"systemctl status pve-cluster",
"pvecm status",
"pvecm nodes",
"zpool status",
"zpool list",
"zfs list",
"ls -la",
"cd /path/to/dir",
"mkdir dirname",
"rm -rf dirname",
"cp source dest",
"mv source dest",
"cat filename",
"grep 'pattern' file",
"find . -name 'file'",
"chmod 755 file",
"chown user:group file",
"tar -xzf file.tar.gz",
"tar -czf archive.tar.gz dir/",
"df -h",
"du -sh *",
"free -h",
"top",
"ps aux | grep process",
"kill -9 PID",
"systemctl status service",
"systemctl start service",
"systemctl stop service",
"systemctl restart service",
"apt update && apt upgrade",
"apt install package",
"apt remove package",
"docker ps",
"docker images",
"docker exec -it container bash",
"ip addr show",
"ping host",
"curl -I url",
"wget url",
"ssh user@host",
"scp file user@host:/path",
"tail -f /var/log/syslog",
"history",
"clear"
]
}
@@ -0,0 +1,76 @@
// Execute the actual Security handlers against inert UI state and transport.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
const end = source.indexOf('\n const loadFail2banDetails =', begin);
assert(begin !== -1 && end > begin);
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
const uninstall = eraseTypes(source.slice(begin, end));
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
assert(anchor !== -1);
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
const clickEnd = source.indexOf('\n className=', anchor);
assert(clickBegin !== -1 && clickEnd > anchor);
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
async function exercise(lang, action, reply, priorSuccess = '') {
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
const ctx = {
st, Error, confirm: () => true,
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
loadSecurityTools: () => {state.loads++},
fetchApi: (endpoint, options) => {
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
state.calls.push(options.method);
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
},
};
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
await fn({stopPropagation() {}});
await new Promise(resolve => setImmediate(resolve));
return state;
}
(async () => {
for (const lang of Object.keys(catalogs)) {
for (const action of ['delete', 'uninstall']) {
const success = await exercise(lang, action, {success:true, message:'inert'});
assert.equal(success.report, null, `${lang} ${action} clean report`);
assert(success.success, `${lang} ${action} clean success`);
assert.equal(success.error, '', `${lang} ${action} clean no error`);
assert.equal(success.loads, 1);
for (const [kind, reply] of [
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
['malformed_success', {success:'false',message:'inert malformed response'}],
['first_failure', {success:false,partial:false,message:'inert denied'}],
['partial_failure', {success:false,partial:true,message:'inert denied'}],
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
['nonjson_failure', new Error('Invalid JSON response')],
['network_failure', new Error('network failure')],
]) {
const state = await exercise(lang, action, reply);
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
assert(state.error, `${lang} ${action} ${kind}: error visible`);
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
: 'lynisRemovalUnconfirmed';
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
if (action === 'delete' && kind === 'no_files') {
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
}
}
}
}
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
})().catch(err => { console.error(err); process.exitCode = 1 });
@@ -0,0 +1,57 @@
// Execute the actual printable-report generator, without mounting the app.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
const end = source.indexOf('\n const loadSslStatus =', begin);
assert(begin > 0 && end > begin, 'print consumer not found');
const body = source.slice(begin, end)
.replace('(report: LynisReport)', '(report)')
.replace('(raw: unknown): string', '(raw)');
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
const translate = (key, params = {}) => {
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
};
const context = {
st: translate,
window: { location: { origin: 'https://offline.invalid' } },
document: { documentElement: { lang } },
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
getActionableCount: (total, expected) => Math.max(0, total - expected),
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
};
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
const report = {
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
hardening_index: 80, tests_performed: 1,
warnings: [
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
],
suggestions: [
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
],
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
};
const html = render(report);
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
assert(html.includes(id), `missing actual finding ${id}`);
}
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
const description = translate(key);
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
assert(html.includes(description), `missing rendered description ${key}`);
}
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
@@ -0,0 +1,88 @@
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
import datetime
import sqlite3
import tempfile
import threading
import time
import types
import typing
from html.parser import HTMLParser
from pathlib import Path
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
def visible(markup):
class Text(HTMLParser):
def __init__(self): super().__init__(); self.parts = []; self.tags = []
def handle_data(self, data): self.parts.append(data)
def handle_starttag(self, tag, attrs): self.tags.append(tag)
parser = Text(); parser.feed(markup)
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
def restore_event(warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
'components':'2', 'duration':'2m', 'warnings':warnings}),
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
'jsonify':lambda value:value}
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
assert status == 200 and len(events) == 1
return events[0]
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
captured = []
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
def sink(title, body, severity, data):
markup = channel._format_html(title, body, severity, data)
text, tags = visible(markup)
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
return {'success':True}
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
_channels={'email':types.SimpleNamespace(send=sink)},
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
is_event_enabled=lambda event:True)
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
resolve_notification_hostname=lambda host, config:host or 'node-a',
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
db = Path(scratch)/'pending.sqlite'
rows = []
if quiet:
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
conn.commit(); conn.close()
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
for earlier in quiet_before:
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
if manual:
assert not quiet
result = manager.send_notification(event_type, severity, '', '', dict(data))
assert result['success']
else:
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
manager._dispatch_event(event)
if quiet:
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
assert len(rows)==1+len(quiet_before) and not captured
manager._flush_quiet_for_channel('email', manager._channels['email'])
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
assert remaining==0
assert len(captured)==1
if quiet: captured[0]['buffered']=rows
return captured[0]
@@ -0,0 +1,51 @@
"""Assertion-free inert actual consumers; no operational host imports."""
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
# Display-name resolution is an infrastructure boundary, never load manager.
templates._get_hostname = lambda: 'node-a'
from notification_channels import EmailChannel
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
def extract(path, name, owner, ns):
tree = ast.parse(path.read_text())
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def receive(message, severity='info', title='Backup', kind='vzdump'):
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = methods['_classify_pve']
_map_severity = staticmethod(methods['_map_severity'])
_backup_outcome = staticmethod(methods['_backup_outcome'])
process_webhook = methods['process_webhook']
def __init__(self): self._queue = Queue()
target = Receiver()
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
assert result['accepted'] and len(target._queue.items) == 1
return target._queue.items[0]
def email(event_type, data, severity='INFO', language='en'):
result = templates.render_template(event_type, data, language)
channel = object.__new__(EmailChannel)
channel.subject_prefix = '[ProxMenux]'
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
return result, channel._format_html(result['title'], result['body'], severity, context)
@@ -0,0 +1,58 @@
"""A host backup and a finished restore tell the Monitor on its own port,
over HTTPS once the Monitor has a certificate."""
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LIBRARY = ROOT / 'scripts/backup_restore/lib_host_backup_common.sh'
POSTBOOT = ROOT / 'scripts/backup_restore/apply_cluster_postboot.sh'
SETTING = '/etc/proxmenux/ssl_config.json'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class BackupNotifyHttps(TestCase):
def address(self, setting):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
if setting is not None:
path.write_text(setting)
body = function(LIBRARY.read_text(), 'hb_notify_lifecycle').replace(SETTING, str(path))
# The function silences curl, so the stub writes the address it was given.
called = Path(folder) / 'called'
script = (f'curl() {{ printf "%s" "${{@: -1}}" > {called}; }}\n' + body
+ 'HB_NOTIFY_JOB_ID=job1 hb_notify_lifecycle complete\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return called.read_text()
def test_plain_http_while_the_monitor_has_no_certificate(self):
expected = 'http://127.0.0.1:8008/api/notifications/webhook'
self.assertEqual(self.address(None), expected)
self.assertEqual(self.address('{"enabled": false}'), expected)
def test_https_once_the_monitor_has_a_certificate(self):
self.assertEqual(self.address('{"enabled": true, "source": "proxmox"}'),
'https://127.0.0.1:8008/api/notifications/webhook')
def test_the_finished_restore_follows_the_same_setting(self):
source = POSTBOOT.read_text()
self.assertIn('"${NOTIFY_SCHEME}://127.0.0.1:8008/api/internal/restore-event"', source)
self.assertNotIn('"http://127.0.0.1:8008/api/internal/restore-event"', source)
check = source[source.index('NOTIFY_SCHEME="http"'):source.index('NOTIFY_HTTP=$(curl')]
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
for setting, expected in (('{"enabled": true}', 'https'), ('{"enabled":false}', 'http'), (None, 'http')):
path.unlink(missing_ok=True)
if setting is not None:
path.write_text(setting)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c',
check.replace(SETTING, str(path)) + '\necho "$NOTIFY_SCHEME"'],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.stdout.strip(), expected, setting)
@@ -0,0 +1,211 @@
"""Offline regression tests for the Monitor catalog generator."""
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
from contextlib import redirect_stdout, redirect_stderr
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class I18nMessagesTests(unittest.TestCase):
def run_generator(self, source, target, *options, provider=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
en = root / "en" / "common.json"
it = root / "it" / "common.json"
en.parent.mkdir()
it.parent.mkdir()
en.write_text(json.dumps(source), encoding="utf-8")
it.write_text(json.dumps(target), encoding="utf-8")
before = it.read_bytes()
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
"--languages", "it", "--sleep", "0", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
) as translate, redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
def test_generator_fills_array_leaves_preserving_existing_values(self):
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
"extra": {"0": "Keep"}})
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
for options in [(), ("--refresh",)]:
with self.subTest(malformed=malformed, options=options):
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
self.assertEqual(result, 1)
self.assertEqual(actual, target)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
self.assertIn("/terminal/commandDescriptions", output)
self.assertIn("manual", output)
def test_structure_only_catalog_is_written_without_translation(self):
source = {"items": ["", [], {}, None, False, 0]}
result, actual, calls, _, _ = self.run_generator(source, {})
self.assertEqual(result, 0)
self.assertEqual(actual, source)
self.assertEqual(calls, [])
def test_limit_keeps_array_positions_as_null_fallbacks(self):
result, actual, calls, _, _ = self.run_generator(
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
self.assertEqual(len(calls), 1)
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
source = {"items": ["Changed English", "CPU", "Three"]}
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(actual, target)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
def test_second_run_retries_null_placeholders_preserving_translations(self):
source = {"items": ["One", "Two", "", "Three", "Four"]}
target = {"items": ["Human"], "extra": "Keep"}
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
self.assertEqual(result, 0)
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Two"])
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
def provider(text, *args):
if text == "Two":
raise RuntimeError("offline fixture failure")
return "IT " + text
# --refresh alone never replaces an existing translation.
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--save-every", "1", provider=provider)
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["Uno", "Due", "IT Three"]})
self.assertEqual(len(calls), 1)
# Named keys are retranslated; a provider failure keeps the value.
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as keys:
keys.write("items.0\nitems.1\n")
try:
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--refresh-keys", keys.name, "--save-every", "1", provider=provider)
finally:
Path(keys.name).unlink()
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
self.assertEqual(len(calls), 3)
self.assertIn("1 translation failures", output)
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
self.assertNotIn("{vmid}", calls[0].args[0])
self.assertNotIn("Proxmox", calls[1].args[0])
def test_root_pointer_is_empty_not_empty_key_pointer(self):
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
with self.subTest(pointer=pointer):
result, _, calls, output, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
self.assertIn(pointer + ": expected", output)
def test_wrong_scalar_and_nested_types_are_rejected(self):
for source, target in [({"a": "Text"}, {"a": 42}),
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
({"a": False}, {"a": 0}),
({"a": {}}, {"a": []})]:
with self.subTest(source=source):
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
def test_real_english_array_round_trip_and_generation(self):
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
descriptions = catalog["terminal"]["commandDescriptions"]
self.assertIsInstance(descriptions, list)
self.assertTrue(descriptions)
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
for options in [(), ("--refresh",)]:
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalogs = {"en": {"items": ["One", "Two"]},
"sk": {"items": ["Human"]},
"it": {"items": "Uno"}, "de": {"items": {}}}
for lang, data in catalogs.items():
path = root / lang / "common.json"
path.parent.mkdir()
path.write_text(json.dumps(data))
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
reads = []
real_read = MODULE.read_json
def read(path):
reads.append(path.parent.name)
return real_read(path)
def translate(text, lang, *args):
self.assertEqual(reads, ["en", "sk", "it", "de"])
self.assertEqual(lang, "sk")
return "SK " + text
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", "sk,it,de",
"--sleep", "0", "--save-every", "1", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "read_json", side_effect=read), \
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
redirect_stderr(output), redirect_stdout(output):
self.assertEqual(MODULE.main(), 1)
# --refresh without --refresh-keys keeps the existing value.
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
{"items": ["Human", "SK Two"]})
self.assertEqual(provider.call_count, 1)
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
for lang in ["en", "it", "de"]:
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
self.assertNotIn("generated successfully", output.getvalue())
def test_round_trip_preserves_json_types_and_literal_keys(self):
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,220 @@
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class WebDocsI18nTests(unittest.TestCase):
def run_generator(self, root, provider):
source_root = root / "messages" / "en"
messages_root = root / "messages"
state_path = messages_root / ".docs-i18n-source-state.json"
argv = [
str(SCRIPT),
"--source-dir",
str(source_root),
"--messages-dir",
str(messages_root),
"--languages",
"de",
"--source-state",
str(state_path),
"--workers",
"1",
]
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "provider_function", return_value=provider
):
return MODULE.main()
def test_protected_contract_round_trip(self):
source = (
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
"then open <link>Settings</link> at {host}."
)
protected, mapping = MODULE.protect_text(source)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertNotIn("Proxmox VE", protected)
self.assertIn("<code>", protected)
self.assertIn("</code>", protected)
self.assertIn("<link>", protected)
self.assertIn("</link>", protected)
self.assertNotIn("<code>", mapping.values())
self.assertNotIn("</code>", mapping.values())
self.assertNotIn("systemctl", mapping.values())
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_human_translation_is_not_pending(self):
source = {"title": "Updates", "url": "https://example.com"}
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
target = {"title": "Vorherige Installationsanleitung"}
token = MODULE.leaf_token(("title",))
leaves = MODULE.pending_leaves(
source,
target,
forced_tokens={token},
)
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
def test_source_fingerprints_change_per_leaf(self):
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
self.assertNotEqual(
before[MODULE.leaf_token(("title",))],
after[MODULE.leaf_token(("title",))],
)
self.assertEqual(
before[MODULE.leaf_token(("body",))],
after[MODULE.leaf_token(("body",))],
)
def test_schema_mismatch_detects_removed_keys(self):
self.assertFalse(
MODULE.schema_matches(
{"title": "Title"},
{"title": "Título", "removed": "Old"},
)
)
def test_schema_mismatch_detects_changed_structural_values(self):
self.assertFalse(
MODULE.schema_matches(
{"enabled": True, "retries": 2},
{"enabled": False, "retries": 2},
)
)
def test_schema_allows_localized_string_values(self):
self.assertTrue(
MODULE.schema_matches(
{"title": "Updates"},
{"title": "Aktualisierungen"},
)
)
def test_translatable_rich_tags_use_opaque_names(self):
source = (
"Keep <strong>this</strong>, <em>that</em> and "
"<code>systemctl restart pveproxy</code>."
)
protected, mapping = MODULE.protect_rich_tags(source)
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
def test_source_schema_drives_output(self):
source = {"title": "Title", "items": ["One", "Two"]}
target = {
"title": "Título",
"items": ["Uno", "Dos", "Obsoleto"],
"removed": "Old",
}
translated = {
("items", 0): "Uno",
("items", 1): "Dos",
}
self.assertEqual(
MODULE.merge_tree(source, target, translated),
{"title": "Título", "items": ["Uno", "Dos"]},
)
def test_translation_memory_ignores_conflicts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_root = root / "messages" / "en"
target_root = root / "messages" / "es"
source_root.mkdir(parents=True)
target_root.mkdir(parents=True)
(source_root / "one.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "one.json").write_text(
json.dumps({"label": "Ajustes"}), encoding="utf-8"
)
(source_root / "two.json").write_text(
json.dumps({"label": "Settings"}), encoding="utf-8"
)
(target_root / "two.json").write_text(
json.dumps({"label": "Configuración"}), encoding="utf-8"
)
memory = MODULE.collect_memory(source_root, root / "messages", "es")
self.assertNotIn("Settings", memory)
def test_source_state_retranslates_an_existing_changed_leaf(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
0,
)
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
calls = []
def provider(text, language):
calls.append((text, language))
return "Geändert"
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(calls, [("Changed", "de")])
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source_path = root / "messages" / "en" / "page.json"
target_path = root / "messages" / "de" / "page.json"
source_path.parent.mkdir(parents=True)
target_path.parent.mkdir(parents=True)
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
source_path.write_text(
json.dumps({"title": "Original", "new": "New text"}),
encoding="utf-8",
)
target_path.write_text(
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
encoding="utf-8",
)
self.assertEqual(
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
0,
)
self.assertEqual(
read_json_file(target_path),
{"title": "Ursprünglich", "new": "Neuer Text"},
)
def read_json_file(path):
return json.loads(path.read_text(encoding="utf-8"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,267 @@
"""Frozen conservative recovery: update the fixture only after explicit review.
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
The consumer test executes only extracted pure lookup functions, not the UI.
"""
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stdout, redirect_stderr
from unittest import mock
from test_build_i18n_messages import MODULE, SCRIPT
ROOT = SCRIPT.parents[2]
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
def require_node():
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
node = os.environ.get("NODE_BINARY") or shutil.which("node")
if not node:
raise AssertionError(diagnostic)
try:
result = subprocess.run([node, "--version"], capture_output=True, text=True)
except OSError as exc:
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
return node
def catalog(lang):
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
class NodeRuntimeTests(unittest.TestCase):
def test_missing_node_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_invalid_override_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_older_node_reports_detected_version(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
["node", "--version"], 0, "v20.19.0\n", "")):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
require_node()
class CommandDescriptionsTests(unittest.TestCase):
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
self.assertEqual(commands, FIXTURE["commands"])
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
for lang, expected in FIXTURE["locales"].items():
with self.subTest(locale=lang):
actual = catalog(lang)["terminal"]["commandDescriptions"]
self.assertIsInstance(actual, list)
self.assertEqual(len(actual), len(commands))
self.assertEqual(actual, expected)
self.assertTrue(all(isinstance(x, str) and x for x in actual))
# Explicit English recovery values are frozen locale text, not live fallback.
for index in FIXTURE["fallback_indices"]:
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
FIXTURE["locales"]["sv"][index])
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for lang in ["en", *languages.split(",")]:
path = root / lang / "common.json"
path.parent.mkdir()
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
# This steady-state fixture assumes generation already finished.
# Seed only intentional message additions; keep the
# recovered command arrays and the repo catalogs exact. Runtime
# missing-key fallback is covered by the JSX seam tests and by
# test_storage_messages.cjs and test_health_text.cjs.
if lang != "en":
temporary = json.loads(path.read_text())
for section, key in (
("archives", "emptyMessage"),
("destinations", "backupsKeptMessage"),
("destinations", "localAttachHelpMessage"),
):
temporary["backup"][section].setdefault(
key, catalog("en")["backup"][section][key])
temporary["storage"].setdefault(
"savedSmartData", catalog("en")["storage"]["savedSmartData"])
report = temporary["storage"]["smartReport"]
source_report = catalog("en")["storage"]["smartReport"]
for key in ("passedAssessment", "noReallocatedSectorsReported",
"passedMeaningTitle", "passedMeaning"):
report.setdefault(key, source_report[key])
for key in ("passedTitle", "passedText"):
report["recommendations"].setdefault(
key, source_report["recommendations"][key])
# New health-popup messages deliberately use runtime English
# fallback until each locale is translated. Model generator
# steady state only in this disposable copy, as for thermal keys.
for key in (
'certificateExpired',
'certificateExpiresDays',
'certificateInconclusive',
'cpuCheckFailed',
'cpuHighSustained',
'cpuSustained',
'fail2banCheckUnavailable',
'fail2banInactive',
'failedLoginsWithinThreshold',
'kernelPackagesMore',
'kernelPveUpdates',
'loginCheckUnavailable',
'memoryCheckFailed',
'memoryPressure',
'noFailedLogins',
'noSwapConfigured',
'noTemperatureSensor',
'nonRunningKernelUpdates',
'pendingPackageUpdates',
'pendingSecurityUpdates',
'pendingSecurityUpdatesDays',
'pendingSecurityUpdatesUnpatched',
'pveVersionAvailable',
'ramHighSustained',
'ramSustained',
'runningKernelUpdate',
'securityCheckUnavailable',
'sensorHighSamplesSpan',
'swapRamPressure',
'systemNotUpdatedMonths',
'systemNotUpdatedYear',
'temperatureElevated',
'unknownDetail',
'updatesAptError',
'updatesAptTimeout',
'updatesCheckUnavailable',
'uptimeKernelAdvice',
'uptimeUnavailable',
):
temporary["healthStatus"]["details"].setdefault(
key, catalog("en")["healthStatus"]["details"][key])
# New thermal messages also await automatic translation.
# Seed only these intentional additions in the temporary copy.
for key in (
"temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
"temperatureCriticalCount", "temperatureUnavailable",
"temperatureIncomplete", "temperatureUnavailableCount",
):
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
# Every shipped locale carries the temperature alert fallback
# keys; the seed only guards a catalog that lacks them. The
# runtime fallback is tested separately.
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
fallback = temporary["runtime"]["notifications"]["fallback"]
for key in ("temperatureAlertTitle", "temperatureAlertBody",
"recordedReason", "recordedDetails"):
fallback.setdefault(key, source_fallback[key])
# Slovak remains the exact upstream catalog; model the
# pending outcome-key generator additions in disposable
# copies rather than modifying its curated values.
if lang == 'sk':
local = temporary['runtime']['notifications']
source = catalog('en')['runtime']['notifications']
for key, value in source['backup'].items():
local.setdefault('backup', {}).setdefault(key, value)
local['channels']['email']['status'].setdefault(
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
local['channels']['email']['status'].setdefault(
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
path.write_text(json.dumps(temporary, ensure_ascii=False))
# Model steady state after the bot fills these intentional new
# messages; keep repository locales and all other leaves intact.
if lang != "en":
messages = json.loads(path.read_text())
english = catalog("en")
for section, key in (("encryption", "pbsHelp"),
("keyfileActions", "pveKeyDescription")):
messages["backup"][section].setdefault(key, english["backup"][section][key])
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
messages["backup"]["destinations"].setdefault(
key, english["backup"]["destinations"][key])
messages["backup"]["actions"].setdefault(
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
self.assertEqual(result, 0, output.getvalue())
provider.assert_not_called()
writer.assert_not_called()
self.assertEqual(before, {p: p.read_bytes() for p in before})
for lang in languages.split(","):
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
node = require_node()
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
script = ('import assert from "node:assert/strict";\n'
'type MessageTree = Record<string, unknown>;\n'
'type TranslationParams = Record<string, string | number>;\n' + functions +
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
fixture.english.forEach((text, i) => {
assert.equal(t("en", `terminal.commandDescriptions.${i}`), text);
});
for (const [lang, texts] of Object.entries(fixture.locales)) {
texts.forEach((text, i) => {
const key = `terminal.commandDescriptions.${i}`;
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
assert.equal(t(lang, key), text);
});
}
for (const i of fixture.fallback_indices) {
const key = `terminal.commandDescriptions.${i}`;
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
}
assert.equal(t("sv", "missing.key"), "missing.key");
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
''')
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "consumer.ts"
path.write_text(script)
result = subprocess.run([node, "--experimental-strip-types", str(path)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("390 indexed consumer lookups", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,172 @@
"""Inert fencing wording contract: execute extracted producers and template renderer."""
import ast
import json
import re
import time
import unittest
from typing import Any
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
EVENTS = ROOT / 'AppImage/scripts/notification_events.py'
TEMPLATES = ROOT / 'AppImage/scripts/notification_templates.py'
MESSAGES = ROOT / 'AppImage/messages'
FIELDS = ('title', 'body', 'label')
def extract_method(path, owner, method, namespace):
tree = ast.parse(path.read_text(encoding='utf-8'))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == method)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), namespace)
return namespace[method]
def notification_renderer(catalogs):
tree = ast.parse(TEMPLATES.read_text(encoding='utf-8'))
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'render_template')
def lookup(obj, dotted):
for part in dotted.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
namespace = {'TEMPLATES': templates, 'Dict': dict, 'Any': object,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: catalogs[lang]['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': lambda key, lang, **kw: key,
'time': time, 're': re}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(TEMPLATES), 'exec'), namespace)
return templates, namespace['render_template']
class FencingWordingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalogs = {p.parent.name: json.loads(p.read_text(encoding='utf-8'))
for p in MESSAGES.glob('*/common.json')}
cls.templates, render = notification_renderer(cls.catalogs)
cls.render = staticmethod(render)
def test_webhook_fencing_renders_without_split_brain_or_quorum_diagnosis(self):
namespace: dict[str, Any] = {'re': re}
classify = extract_method(EVENTS, 'ProxmoxHookWatcher', '_classify_pve', namespace)
severity = extract_method(EVENTS, 'ProxmoxHookWatcher', '_map_severity', namespace)
namespace['capture_journal_context'] = lambda **kw: ''
class Event:
def __init__(self, **kw):
self.__dict__.update(kw)
self.event_id = 'inert-id'
class Queue:
def __init__(self): self.items = []
def put(self, item): self.items.append(item)
namespace['NotificationEvent'] = Event
webhook = extract_method(EVENTS, 'ProxmoxHookWatcher', 'process_webhook', namespace)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity)
def __init__(self): self._queue = Queue()
receiver = Receiver()
for fields in ({'type': 'fencing'}, {'type': 'fencing', 'quorum': 'lost'}):
result = webhook(receiver, {'fields': fields, 'severity': 'warning',
'title': 'Fence requested', 'message': 'Check node state'})
self.assertEqual(result['event_type'], 'split_brain')
event = receiver._queue.items.pop()
self.assertEqual(event.severity, 'WARNING')
self.assertNotIn('quorum', event.data)
for lang, catalog in self.catalogs.items():
rendered = self.render(event.event_type, event.data, lang)
expected = catalog['runtime']['notifications']['templates']['split_brain']
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
# The body names the source event, as received, and adds no diagnosis of its own.
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
self.assertIn('Check node state', rendered['body'], lang)
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
self.assertNotIn('quorum', rendered['body'].lower(), lang)
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
'title': 'Unclassified', 'message': 'Other'})['event_type'], 'system_problem')
self.assertEqual(self.templates['split_brain']['group'], 'cluster')
self.assertIs(self.templates['split_brain']['default_enabled'], True)
def test_journal_family_uses_same_truthful_wording(self):
tree = ast.parse(EVENTS.read_text(encoding='utf-8'))
owner = next(n.name for n in tree.body if isinstance(n, ast.ClassDef)
and any(isinstance(m, ast.FunctionDef) and m.name == '_check_cluster_events' for m in n.body))
namespace: dict[str, Any] = {'re': re}
method = extract_method(EVENTS, owner, '_check_cluster_events', namespace)
class Journal:
_hostname = 'node-a'
def __init__(self): self.events = []
def _emit(self, *args, **kwargs): self.events.append((args, kwargs))
for message in ('fencing required for node', 'cluster partition suspected', 'split-brain reported'):
journal = Journal()
method(journal, message, 'syslog')
args, _ = journal.events[0]
self.assertEqual(args[0], 'split_brain')
for lang, catalog in self.catalogs.items():
rendered = self.render(args[0], args[2], lang)
body = catalog['runtime']['notifications']['templates']['split_brain']['body']
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
journal = Journal()
method(journal, 'quorum lost', 'syslog')
self.assertEqual(journal.events[0][0][0], 'node_disconnect')
def test_english_family_and_settings_label_align_without_changing_key(self):
en = self.catalogs['en']
self.assertEqual(self.templates['split_brain']['label'], 'Cluster event')
self.assertEqual(en['runtime']['notifications']['templates']['split_brain']['label'], 'Cluster event')
self.assertEqual(en['settings']['notifications']['eventTypes']['split_brain'], 'Cluster events')
settings = (ROOT / 'AppImage/components/notification-settings.tsx').read_text(encoding='utf-8')
self.assertIn('settings.notifications.eventTypes.${evt.type}', settings)
self.assertIn('evt.title : translated', settings)
for field in FIELDS:
self.assertEqual(en['runtime']['notifications']['templates']['split_brain'][field],
self.templates['split_brain'][field])
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
expected = {
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
}
self.assertEqual(set(self.catalogs), {'en', *expected})
for lang, (title, body, label, settings) in expected.items():
with self.subTest(lang=lang):
leaves = self.catalogs[lang]['runtime']['notifications']['templates']['split_brain']
self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
(title, body, label, settings))
result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
self.assertEqual(result['title'], title.format(hostname='node-a'))
self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
self.assertNotIn('quorum', result['body'].lower())
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
import copy
catalogs = copy.deepcopy(self.catalogs)
for field in FIELDS:
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
'A cluster event was reported:\nfencing node node-b')
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
for lang, catalog in self.catalogs.items():
if lang == 'en': continue
leaves = catalog['runtime']['notifications']['templates']['split_brain']
self.assertTrue(all(leaves[field] for field in FIELDS), lang)
self.assertTrue(catalog['settings']['notifications']['eventTypes']['split_brain'], lang)
self.assertNotEqual(leaves['body'], self.catalogs['en']['runtime']['notifications']['templates']['split_brain']['body'], lang)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,99 @@
"""The menus that install drivers inside a container leave out the ones
created from an OCI image: those are changed from OCI manager Apps."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
MENUS = ('scripts/gpu_tpu/add_gpu_lxc.sh', 'scripts/gpu_tpu/install_coral_lxc.sh')
ORDINARY = "arch: amd64\ncores: 2\nhostname: debian\nostype: debian\nrootfs: local-lvm:vm-111-disk-0,size=8G\n"
OCI = ORDINARY + "entrypoint: /init\nlxc.signal.halt: SIGTERM\n"
def function(path, name):
source = (ROOT / path).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class GpuMenusHideOci(TestCase):
def is_oci(self, config):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / '111.conf').write_text(config)
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
return subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 111'], capture_output=True).returncode == 0
def test_a_container_from_an_oci_image_is_told_apart_by_its_configuration(self):
self.assertTrue(self.is_oci(OCI))
self.assertTrue(self.is_oci(ORDINARY + "cmd: nginx -g 'daemon off;'\n"))
self.assertFalse(self.is_oci(ORDINARY))
# A setting that only mentions the word is not an entrypoint.
self.assertFalse(self.is_oci(ORDINARY + "description: entrypoint: none\n"))
def test_a_container_without_configuration_is_not_one(self):
with tempfile.TemporaryDirectory() as folder:
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
self.assertNotEqual(subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 999'],
capture_output=True).returncode, 0)
def test_both_menus_skip_them_and_say_so(self):
for menu in MENUS:
body = function(menu, 'select_container')
self.assertLess(body.index('pmx_lxc_is_oci "$ctid"'), body.index('menu_items+=('), menu)
self.assertIn('Containers created from an OCI image are not listed:', body, menu)
self.assertEqual(body.count('${oci_note}'), 2, menu)
SWITCHES = (('scripts/gpu_tpu/switch_gpu_mode.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/switch_gpu_mode_direct.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/add_gpu_vm.sh', 'cleanup_lxc_configs', None))
class GpuToVmKeepsOciConfiguration(TestCase):
"""A GPU that moves to a VM stops the containers that use it. One created
from an OCI image keeps its configuration, which OCI manager Apps records."""
def test_an_oci_container_is_never_edited(self):
for script, apply, _ in SWITCHES:
body = function(script, apply)
guard = body.index('pmx_lxc_is_oci "$ctid"')
self.assertLess(guard, body.index('action="keep_gpu_disable_onboot"'), script)
removal = body.index('"$action" == "remove_gpu_keep_onboot"')
self.assertLess(guard, removal, script)
# The only edit of a configuration hangs from the per-container action.
self.assertEqual(len(re.findall(r'_from_lxc_conf "\$', body)), 1, script)
edit = body.index('_from_lxc_conf "$')
condition = body.rindex('if [[', 0, edit)
self.assertIn('"$action" == "remove_gpu_keep_onboot"', body[condition:edit], script)
def test_the_list_names_them_and_nothing_is_asked_when_all_are_oci(self):
for script, _, prompt in SWITCHES:
source = (ROOT / script).read_text() if prompt is None else function(script, prompt)
self.assertIn('", OCI"', source, script)
self.assertIn('OCI containers keep their configuration', source, script)
self.assertIn('"$oci_count" -eq ${#LXC_AFFECTED_CTIDS[@]}', source, script)
class DiskAndSharePickersHideOci(TestCase):
"""Adding a disk or a mount, and the NFS and Samba set-ups, change the
container they are given. A container from an OCI image is not offered."""
def test_every_picker_filters_before_it_lists(self):
pickers = ((function('scripts/global/share-common.func', 'select_lxc_container'), 'options+=('),
(function('scripts/global/share-common.func', 'select_privileged_lxc'), 'ct_list+='),
(function('scripts/share/lxc-mount-manager_minimal.sh', 'select_lxc_container'), 'options+=('))
for body, listing in pickers:
self.assertLess(body.index('pmx_lxc_is_oci "$id"'), body.index(listing))
self.assertIn('Containers created from an OCI image are not listed', body)
disk = (ROOT / 'scripts/storage/disk-passthrough_ct.sh').read_text()
picker = disk[disk.index('CT_LIST=""'):disk.index('if [ -z "$CTID" ]')]
self.assertLess(picker.index('pmx_lxc_is_oci "$ct_id"'), picker.index('CT_LIST+='))
self.assertIn('${OCI_NOTE}" $UI_MENU_H $UI_MENU_W $CT_LIST_H', picker)
def test_a_disk_in_use_is_still_looked_for_in_every_container(self):
for script in ('scripts/storage/disk-passthrough_ct.sh', 'scripts/storage/disk-passthrough.sh'):
source = (ROOT / script).read_text()
check = source[source.index('disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"'):]
scan = check[:check.index("done < <(pct list | awk 'NR>1 {print $1, $3}')")]
self.assertNotIn('pmx_lxc_is_oci', scan, script)
@@ -0,0 +1,108 @@
"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
the Proxmox host, inside a container, or wherever the user chooses when it
can be both. Until this, every addon ran on the host."""
from pathlib import Path
import subprocess
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
# Only the functions under test are loaded, not the menu or its catalog.
PRELUDE = textwrap.dedent(f"""
set -uo pipefail
eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
translate() {{ printf '%s' "$1"; }}
clear() {{ :; }}
dialog() {{ :; }}
pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
MENU_HEIGHT=22; MENU_ROWS=14
""")
def bash(script):
return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
class TargetChoiceTests(unittest.TestCase):
def choice(self, kind, places):
return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
def test_an_addon_for_the_host_runs_on_the_host(self):
self.assertEqual(self.choice("addon", "pve"), "host")
def test_an_addon_for_containers_runs_in_one(self):
self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
self.assertEqual(self.choice("addon", "lxc"), "lxc")
def test_an_addon_for_both_asks_where(self):
self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
self.assertEqual(self.choice("addon", ""), "ask")
def test_what_is_not_an_addon_runs_on_the_host(self):
# A script that creates a container or a VM belongs on the host,
# whatever the catalog lists for the application it installs.
self.assertEqual(self.choice("lxc", "lxc"), "host")
self.assertEqual(self.choice("vm", "vm"), "host")
self.assertEqual(self.choice("pve", "pve"), "host")
class ContainerListTests(unittest.TestCase):
def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
result = bash(r"""
pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
helper_lxc_rows
""")
self.assertEqual(result.stdout,
"109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
class RunTests(unittest.TestCase):
def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
result = bash(r"""
curl() { printf 'printf on-host'; }
pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
""")
self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
def test_a_failed_or_empty_download_runs_nothing(self):
result = bash(r"""
bash() { echo RAN; }
pct() { echo RAN; }
HELPER_LXC_TARGET=109
curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
echo done
""")
self.assertEqual(result.stdout, "done\n")
def test_cancelling_the_choice_leaves_no_target(self):
result = bash(r"""
dialog() { return 1; }
select_helper_target Test ask && echo UNEXPECTED
printf '[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "[]\n")
def test_the_host_needs_no_question(self):
result = bash(r"""
dialog() { echo ASKED; return 1; }
select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "ok[]\n")
class PlainTextTests(unittest.TestCase):
def test_html_becomes_text_and_commands_are_kept(self):
result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
input="<p>Run <code>ls -la &amp;&amp; pwd</code></p><ul><li>one</li><li>two</li></ul>")
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
"""A Proxmox host without a subscription is refused by the enterprise
repositories on every apt refresh. The installers tell that apart from a
refresh that really failed."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
INSTALLERS = ('install_proxmenux.sh', 'install_proxmenux_beta.sh')
ENTERPRISE = """Hit:1 http://deb.debian.org/debian trixie InRelease
Err:2 https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Err:4 https://enterprise.proxmox.com/debian/pve trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Reading package lists...
E: Failed to fetch https://enterprise.proxmox.com/debian/ceph-squid/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease' is not signed.
E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/pve trixie InRelease' is not signed.
"""
NETWORK = """Err:1 http://deb.debian.org/debian trixie InRelease
Temporary failure resolving 'deb.debian.org'
E: Failed to fetch http://deb.debian.org/debian/dists/trixie/InRelease Temporary failure resolving 'deb.debian.org'
"""
LOCKED = "E: Could not get lock /var/lib/apt/lists/lock. It is held by process 4321 (apt-get)\n"
# apt reports a repository it cannot reach as a warning, not as an error.
UNREACHABLE = """Err:6 http://repo.invalid/debian trixie InRelease
Could not resolve 'repo.invalid'
W: Failed to fetch http://repo.invalid/debian/dists/trixie/InRelease Could not resolve 'repo.invalid'
W: Some index files failed to download. They have been ignored, or old ones used instead.
"""
def function(installer, name):
source = (ROOT / installer).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class InstallerAptRefresh(TestCase):
def refused(self, installer, log):
with tempfile.NamedTemporaryFile('w', suffix='.log') as handle:
handle.write(log)
handle.flush()
script = function(installer, 'apt_only_enterprise_refused') + \
f'APT_ERROR_LOG={handle.name if log is not None else ""}\napt_only_enterprise_refused\n'
return subprocess.run(['bash', '-c', script], capture_output=True).returncode == 0
def test_a_refresh_refused_only_by_the_enterprise_repositories_is_good(self):
for installer in INSTALLERS:
self.assertTrue(self.refused(installer, ENTERPRISE), installer)
def test_any_other_failure_is_still_a_failure(self):
for installer in INSTALLERS:
self.assertFalse(self.refused(installer, NETWORK), installer)
self.assertFalse(self.refused(installer, LOCKED), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + NETWORK), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + UNREACHABLE), installer)
self.assertFalse(self.refused(installer, 'Reading package lists...\n'), installer)
def test_the_refresh_step_asks_before_it_warns(self):
for installer in INSTALLERS:
source = (ROOT / installer).read_text()
step = source[source.index('if run_apt update -y; then'):]
step = step[:step.index('\n fi\n')]
self.assertLess(step.index('elif apt_only_enterprise_refused; then'), step.index('apt cache refresh failed'))
self.assertIn('discard_apt_error', step[step.index('elif'):step.index('else\n')])
@@ -0,0 +1,78 @@
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
import ast
import os
import subprocess
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
def extracted(path, name, namespace):
tree = ast.parse((ROOT / path).read_text())
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
fn.decorator_list = []
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
return namespace[name]
class LynisFeedbackProducer(unittest.TestCase):
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertFalse(result[0])
self.assertEqual(result[3], 'no_files')
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['outcome'], 'no_files')
def test_report_outcomes(self):
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 1, 'missing'):
with self.subTest(fail_at=fail_at):
removed = []
def remove(p):
if fail_at in (0, 1) and p == paths[fail_at]:
raise PermissionError('inert denial')
removed.append(p)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
ns = {'security_manager': object(), 'jsonify': lambda data: data}
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
body, status = result if isinstance(result, tuple) else (result, 200)
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
self.assertEqual(body['success'], fail_at is None)
if fail_at == 'missing':
self.assertEqual(body['outcome'], 'no_files')
elif fail_at in (0, 1):
self.assertEqual(status, 500)
self.assertEqual(body['partial'], fail_at == 1)
self.assertIn('inert denial', body['message'])
def test_uninstall_outcomes_and_route(self):
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 2):
with self.subTest(fail_at=fail_at):
removed = []
def remove(path):
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
fake_shutil = SimpleNamespace(rmtree=remove)
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
self.assertEqual(result[0], fail_at is None)
self.assertEqual(result[2], fail_at == 2)
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['partial'], fail_at == 2)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Inert source-contract checks; never import operational modules or touch host paths."""
import ast
import json
import os
import subprocess
import unittest
from collections import defaultdict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
APP = ROOT / 'AppImage'
def extract_function(path, name, *, owner=None):
tree = ast.parse(path.read_text())
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
namespace = {}
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
def leaf(pointer):
data = json.loads((APP / 'messages/en/common.json').read_text())
for segment in pointer.split('.'):
data = data[segment]
return data
class LynisThresholdContracts(unittest.TestCase):
def test_delete_confirmation_names_all_three_actual_targets(self):
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
original_import = __import__
def fake_import(name, *args, **kwargs):
if name == 'os':
return fake_os
return original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertEqual(result['success'], True)
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
text = leaf('securityPage.confirm.deleteAuditReport').lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('this audit report?', text)
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
def remove(path):
if path.endswith('lynis.log'):
raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result, status = namespace['lynis_report_delete']()
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
self.assertEqual(status, 500)
self.assertFalse(result['success'])
self.assertIn('inert denial', result['message'])
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertFalse(result['success'])
self.assertIn('No report files', result['message'])
def test_uninstall_notice_describes_existing_cleanup_targets(self):
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
class FakeShutil:
@staticmethod
def rmtree(path):
removed.append(path)
original_import = __import__
def fake_import(name, *args, **kwargs):
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
namespace = {'os': fake_os}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
result = namespace['uninstall_lynis']()
self.assertTrue(result[0])
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
text = leaf(key).lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('monitor-created', text)
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
# The method is bound to an inert class, not the production constructor.
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
memory = SimpleNamespace(percent=50, available=10, total=100)
swap = SimpleNamespace(percent=90, used=90, total=100)
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
'time': SimpleNamespace(time=lambda: 1000)}
exec(compile(module, '<inert memory check>', 'exec'), namespace)
first = namespace['_check_memory_comprehensive'](self_obj)
second = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(first['status'], 'OK')
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
self_obj.state_history.clear()
memory.percent = 91
swap.percent = 0
for _ in range(8):
last = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(last['status'], 'CRITICAL')
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
self.assertIn('swap usage', hint)
self.assertIn('repeated', hint)
self.assertIn('ram', hint)
self.assertNotIn('swap file', hint)
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
self.assertNotIn('only when both', hint)
def test_print_generator_and_descriptions(self):
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f'print HTML ({lang}):', result.stdout)
if __name__ == '__main__':
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
"""The search of the main menu finds an option of ProxMenux, an OCI application
or a Helper-Script by its name or by related words, and opens it where it
lives: the scripts it launches are the ones the menus launch."""
import importlib.util
import json
from pathlib import Path
import re
import runpy
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / "scripts"
def load():
spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# The repository in the place of the installed copy.
module.BASE, module.SCRIPTS = ROOT, SCRIPTS
module.OCI_INDEX = ROOT / "oci/catalog/index.json"
module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
module._translations = {}
return module
class IndexTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
def test_every_entry_opens_a_script_that_exists(self):
missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
self.assertEqual(missing, [])
def test_every_title_is_a_text_the_menus_already_translate(self):
names = {"ZimaOS", "Synology DSM"}
unknown = [item["title"] for item in self.index
if item["title"] not in self.spanish and item["title"] not in names]
self.assertEqual(unknown, [])
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
def test_every_script_a_menu_launches_can_be_found(self):
indexed = {item["script"] for item in self.index}
launched = set()
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
if menu.name in ("main_menu.sh", "lxc_menu.sh"):
continue
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
launched -= {"menus/main_menu.sh"}
# Opened from their own menu, which is what the index points at.
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
self.assertEqual(sorted(launched - indexed), [])
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
self.assertIn('search "$(translate "Search options and applications")"', menu)
self.assertIn('search_menu.py" --handoff "$handoff"', menu)
self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
self.assertIn('exec bash "${target[@]}"', menu)
def test_an_option_inside_a_menu_names_one_that_menu_has(self):
for item in self.index:
argument = item.get("argument")
if not argument:
continue
script = (SCRIPTS / item["script"]).read_text()
self.assertIn("PRESELECTED_OPTION", script, item["script"])
if not argument.isdigit():
self.assertIn(argument, script, f"{item['script']}: {argument}")
class CatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.search = load()
cls.items = cls.search.catalog(refresh=False)
def found(self, query):
return self.search.search(self.items, query)
def test_the_three_sources_are_in_the_catalog(self):
self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
sources = [item["source"] for item in self.found("samba")]
self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
self.assertEqual(sources[0], "ProxMenux")
def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
def test_accents_and_case_do_not_matter(self):
self.assertEqual([item["title"] for item in self.found("NVIDIA")],
[item["title"] for item in self.found("nvidia")])
def test_related_words_find_an_option(self):
self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
def test_a_match_by_category_comes_after_the_names(self):
matches = self.found("vpn")
self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
by_category = False
for item in matches:
words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
in_name = any(word.startswith("vpn") for word in words)
self.assertFalse(in_name and by_category)
by_category = by_category or not in_name
def test_an_application_is_not_found_by_what_its_description_mentions(self):
titles = [item["title"] for item in self.found("plex")]
self.assertNotIn("Jellyfin", titles)
self.assertTrue(all("plex" in title.casefold() for title in titles))
def test_nothing_matches_nothing(self):
self.assertEqual(self.found("zzqqxx"), [])
self.assertEqual(self.found(" "), [])
def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
def test_an_option_inside_a_menu_is_given_to_that_menu(self):
zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
self.assertFalse(zfs["handoff"])
language = next(item for item in self.items if item["action"][-1] == "change_language")
self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
self.assertTrue(language["handoff"])
class OpenedInPlaceTests(unittest.TestCase):
def test_an_option_of_the_post_install_comes_selected(self):
script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
self.assertIn('--default-item "${preselected_item:-1}"', script)
def test_a_utility_comes_selected(self):
script = (SCRIPTS / "utilities/system_utils.sh").read_text()
self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
def test_a_helper_script_is_opened_on_its_own(self):
script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
self.assertIn('run_script_by_slug "$1"', script)
def test_an_oci_application_opens_its_page(self):
cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
self.assertIn('subparsers.add_parser("open"', cli)
class TranslationTests(unittest.TestCase):
"""What the search and the main menu say reaches the translation workflow,
read with the folders the workflow itself passes."""
@classmethod
def setUpClass(cls):
workflow = (ROOT / ".github/workflows/build-translation-cache.yml").read_text()
extractor = runpy.run_path(str(ROOT / ".github/scripts/build_translation_cache.py"))
folders = [ROOT / folder for folder in re.findall(r"--python-dir (\S+)", workflow)]
cls.found = set(extractor["extract_python_texts"](folders))
cls.found |= set(extractor["extract_translate_texts"](SCRIPTS))
cls.triggers = workflow
def test_every_text_of_the_search_is_found(self):
source = (SCRIPTS / "menus/search_menu.py").read_text()
said = set(re.findall(r"translate\('([^']+)'\)", source))
self.assertIn("No results", said)
self.assertEqual(sorted(said - self.found), [])
def test_the_title_of_the_main_menu_is_found(self):
self.assertIn("Main menu", self.found)
def test_a_change_in_the_search_runs_the_workflow(self):
self.assertIn("'scripts/**/*.py'", self.triggers)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,122 @@
"""Backup-only maintainer contract at actual render/dispatch/email seams.
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
and manual delivery with an email capture sink. No host operations or sends.
"""
import unittest
from notification_fixture import templates, LANGUAGES
from notification_final_fixture import deliver
class BackupSplitTests(unittest.TestCase):
def test_recovery_default_is_upstream_resolved_without_proof(self):
for language in LANGUAGES:
data = {'hostname': 'alias', 'category': 'temperature',
'reason': 'Temperature high (recovered)', 'duration': '2m',
'original_severity': 'WARNING'}
rendered = templates.render_template('error_resolved', data, language)
expected = templates.runtime_message('templates.error_resolved.title', language,
hostname='alias', category='temperature', entity_suffix='')
self.assertEqual(rendered['title'], expected)
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
self.assertTrue(rich.startswith('✅ '))
result = deliver('error_resolved', data, 'OK', language)
self.assertIn('background:#f0fdf4;', result['html'])
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
from notification_fixture import receive
raw_host = 'pve-production.internal.example'
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
'\nTotal running time: 0s\nTotal size: 0 B')
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: unable to activate storage PBS')
event.data['hostname'] = 'display-alias {rack.location}'
for language in LANGUAGES:
for manual in (False, True):
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
self.assertNotIn(raw_host, result['text'])
self.assertNotIn('vzdump backup status', result['text'])
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
self.assertIn('display-alias {rack.location}', result['title'])
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
self.assertEqual(result['data']['pve_message'], raw)
# No global hostname or guest-name substitution: actual diagnostics are
# authoritative, even when they happen to contain the original hostname.
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: cannot connect to ' + raw_host)
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
self.assertNotIn('vzdump backup status', result['body'])
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
# Frozen upstream contract; no Git history required in shipped tests.
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
for language, expected in legacy.items():
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
self.assertIn('alias {rack.location}', result['title'])
self.assertNotIn('()', result['title'])
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
self.assertTrue(new_title)
self.assertEqual(result['title'], new_title)
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
# Absent/blank/non-string translation uses English per-key fallback.
from unittest.mock import patch
english = templates._load_runtime_catalog('en')
for value in (None, '', {}, []):
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
# Both catalogs missing: new outcome text still has explicit EN defaults.
with patch.object(templates, '_load_runtime_catalog', return_value={}):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
def test_restore_reports_its_tasks_without_calling_the_node_ready_and_keeps_the_success_icon(self):
import json
from pathlib import Path
from notification_final_fixture import restore_event
messages = Path(__file__).resolve().parents[3] / 'AppImage/messages'
first = {language: json.loads((messages / language / 'common.json').read_text())
['runtime']['notifications']['templates']['system_restore_completed']['body'].split('\n')[0]
for language in LANGUAGES}
for warning in ('', 'missing module zfs'):
event = restore_event(warning)
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language)
self.assertTrue(result['title'].startswith('✅ '))
self.assertIn(first[language], result['body'])
self.assertIn(first[language], result['text'])
self.assertNotIn('fully ready', result['body'])
self.assertIn('2m', result['text'])
if warning:
self.assertIn(warning, result['text'])
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertIn('✅', quiet['body'])
self.assertIn(first[language], quiet['body'])
self.assertIn('white-space:pre-wrap;', quiet['html'])
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
for outcome, title in expected.items():
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
self.assertEqual(result['title'], 'alias: ' + title)
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
self.assertEqual(failure['title'], 'alias: Backup fallido')
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
self.assertIn('✅', result['body'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,204 @@
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
import html
import unittest
from notification_fixture import templates, receive, email, LANGUAGES
REPORT = """Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
"""
class CorrectionTests(unittest.TestCase):
def test_reversed_finish_is_not_completion_evidence(self):
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
def test_interleaved_complete_logs_keep_both_finished_guests(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('✅ VM web (100)', result['body'])
self.assertIn('✅ CT db (101)', result['body'])
self.assertNotIn('❔', result['body'])
self.assertIn('00:01:01', result['body'])
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
result, markup = email('backup_complete', {'hostname': 'n' * 64,
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
self.assertIn('table-layout:fixed;', markup)
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
self.assertIn('overflow-wrap:break-word;', title_tag)
self.assertIn('word-wrap:break-word;', title_tag)
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated)
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
from notification_fixture import extract, SCRIPTS
import copy
import string
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
ns = {'string': string}
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
probe = unittest.TestCase()
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
parity(probe) # shipped missing keys remain allowed
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
parity(probe) # generation of exactly the pending keys is legal
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
with self.assertRaises(AssertionError): parity(probe)
def test_actual_neutral_style_is_not_success_green(self):
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
result, markup = email(event, data, severity)
self.assertIn('background:#f9fafb;', markup)
self.assertNotIn('background:#f0fdf4;', markup)
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
from notification_fixture import extract, SCRIPTS
from types import SimpleNamespace
for warnings in ('', 'missing module zfs'):
events = []
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
'components': '2', 'duration': '2m', 'warnings': warnings}),
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda value: value}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
self.assertEqual(status, 200)
event = events[0]
for language in LANGUAGES:
result, markup = email(event['event_type'], event['data'], event['severity'], language)
self.assertIn('2m', markup)
for line in result['body'].splitlines():
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
if warnings: self.assertIn(warnings, markup)
def test_raw_display_hostname_is_substituted_exactly_once(self):
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
for language in LANGUAGES:
result, markup = email(event_type, {'hostname': hostname,
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
self.assertIn(hostname, result['title'])
self.assertIn(hostname, html.unescape(markup))
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
for size in ('1..5 GiB', '..5 GiB'):
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(size, result['body'])
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
event = receive(REPORT.replace('ok ', 'err '), kind='')
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.severity, 'INFO')
self.assertEqual(event.data['backup_outcome'], 'failed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('FAILED', markup)
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
def test_confirmed_metadata_only_context_is_retained(self):
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
for language in LANGUAGES:
result, markup = email('backup_complete', data, language=language)
self.assertIn('web {literal} (100)', result['title'])
self.assertIn('1 GiB', result['body'])
self.assertIn('1 GiB', markup)
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
for severity in ('info', 'error'):
event = receive(message + '\n' + diagnostic, severity)
self.assertEqual(event.data.get('vmid'), '100')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn('❌ VM web (100)', result['body'])
self.assertNotIn('✅ VM web (100)', markup)
self.assertIn('✅ CT db (101)', result['body'])
self.assertIn('web (100)', result['title'])
def test_official_week_month_year_durations_stay_confirmed(self):
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
# Fixed-column widths are unchanged for these bounded values.
message = REPORT.replace('1m 1s ', duration.ljust(9))
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(duration, result['body'])
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
for severity in ('info', 'error'):
event = receive(REPORT + '\n' + diagnostic, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(diagnostic, result['body'])
self.assertIn(diagnostic, html.unescape(markup))
# A job-level error must not invent a failed guest.
self.assertIn('✅ VM web (100)', result['body'])
def test_official_completed_report_warning_is_distinct_and_retained(self):
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
event = receive(REPORT + '\n' + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(warning, result['body'])
self.assertIn(warning, html.unescape(markup))
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
failed = REPORT.replace('ok ', 'err ')
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
for message in (failed, REPORT.replace('ok ', 'todo '),
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertNotIn('()', result['title'])
self.assertIn('PBS', result['title'])
self.assertNotIn('web (100)', result['title'])
def test_subject_only_setup_failure_survives_actual_email(self):
reason = 'unable to activate storage PBS'
title = 'vzdump backup status (node-a): backup failed: ' + reason
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
event = receive(message, 'error', title)
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(reason, result['body'])
self.assertIn(reason, html.unescape(markup))
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,136 @@
"""Final review contracts at actual locale/manual/queued/quiet email seams.
All operational dependencies are inert; no manager or route module import.
"""
import ast
import copy
import html
import unittest
from unittest.mock import patch
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
from notification_final_fixture import deliver, restore_event
# Literal native send_notification body, produced by pinned PVE Perl helpers.
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: no log available
'''
class FinalCorrectionsTests(unittest.TestCase):
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
tree = ast.parse(path.read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
block = ast.Module(body=method.body[start:stop], type_ignores=[])
english = templates._load_runtime_catalog('en')
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
else: sk.get('backup', {}).pop('confirmedTitle', None)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
for diagnostic, report in (
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
):
event = receive(diagnostic + '\n' + report, 'error', subject)
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
for language in LANGUAGES:
result = deliver(event.event_type, event.data, event.severity, language)
for line in diagnostic.splitlines():
self.assertEqual(result['body'].count(line), 1)
self.assertEqual(result['text'].count(line), 1)
if 'ok ' in report:
self.assertIn('✅ VM web (100)', result['body'])
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
reason = 'missing module zfs <raw> & {literal}'
event = restore_event(reason)
self.assertEqual(event['severity'], 'WARNING')
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertEqual(result['severity'], 'INFO')
buffered_body = result['buffered'][0][3]
self.assertEqual(result['text'].count(reason), 1)
for line in buffered_body.splitlines():
if line.strip(): self.assertIn(line.strip(), result['text'])
self.assertIn('2m', result['text'])
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
self.assertNotIn('script', result['tags'])
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
token = 'b' * 64
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
for language in LANGUAGES:
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
for result in results:
self.assertIn('table-layout:fixed;', result['html'])
self.assertIn('word-wrap:break-word;', result['html'])
self.assertIn('overflow-wrap:break-word;', result['html'])
self.assertEqual(result['text'].count(token), 1)
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated['html'])
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
('unconfirmed','Task status unavailable: upstream API timed out')):
for language in LANGUAGES:
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
self.assertEqual(result['text'].count(reason), 1)
self.assertNotIn('>COMPLETED</span>', result['html'])
def test_backup_reason_threshold_and_raw_body_deduplication(self):
for event in ('backup_complete', 'backup_fail'):
for length in (79, 80, 81, 120):
prefix = '<raw> & {rack.location} '
reason = prefix + 'b' * (length - len(prefix))
self.assertEqual(len(reason), length)
for raw in ('', reason):
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
for manual in (False, True):
result = deliver(event,data,'INFO','en',manual=manual)
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
self.assertNotIn('raw', result['tags'])
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
event = restore_event('missing module zfs')
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
self.assertEqual(result['text'].count('missing module zfs'), 1)
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
self.assertEqual(result['data']['_count'], 9)
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
'error','vzdump backup status (node-a): backup failed: multiple problems')
result = deliver(event.event_type,event.data,event.severity)
self.assertEqual(result['body'].splitlines().count('web'), 1)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,159 @@
"""Maintainer acceptance at inert actual notification consumers."""
import unittest
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
from notification_final_fixture import deliver
NATIVE_REPORT = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
'''
class MaintainerFollowupTests(unittest.TestCase):
def test_completed_with_warnings_requires_independent_completion(self):
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
event = receive(NATIVE_REPORT + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
for manual in (False,True):
for lang in LANGUAGES:
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
self.assertIn(label,result['title'])
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
self.assertIn(status,result['text'])
self.assertIn('file changed during backup',result['text'])
# Manual sends intentionally skip channel emoji enrichment.
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
self.assertTrue(rich.startswith('💾⚠️'))
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
def test_null_filename_failed_guest_uses_own_start_identity(self):
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
'100 web err 1m 1s 0 B null')
for kind, prefix in (('qemu','VM'),('lxc','CT')):
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
# Unrelated guest appears first and must never supply the failed type.
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'],kind)
result = deliver(event.event_type,event.data,event.severity)
self.assertIn(f'{prefix} web (100)',result['title'])
self.assertIn(f'❌ {prefix} web (100)',result['body'])
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
def test_native_subject_keeps_cause_without_host_envelope(self):
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
event.data['hostname']='configured-alias'
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertNotIn(subject,result['text'])
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
self.assertIn('configured-alias',result['title'])
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
result=deliver(setup.event_type,setup.data,setup.severity)
self.assertEqual(result['text'].count('unable to open storage'),1)
self.assertNotIn(setup.data['pve_title'],result['text'])
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
result=deliver(unique.event_type,unique.data,unique.severity)
self.assertIn('job-end hook denied',result['body'])
self.assertNotIn('vzdump backup status',result['body'])
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
event=receive(raw)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertIn('ERROR: principal archive write failure',result['body'])
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
self.assertIn(notice,result['body'])
self.assertEqual(event.data['pve_message'],raw)
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
result=deliver(long.event_type,long.data,long.severity)
self.assertLess(len(result['body']),1400)
self.assertIn('ERROR: '+ 'b'*100,result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
for raw,outcome,icon in samples:
event=receive(raw)
self.assertEqual(event.data['backup_outcome'],outcome)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
self.assertIn(icon,result['body'])
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
import types,datetime
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
target=types.SimpleNamespace(_notification_language=lambda:'en')
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
body=compose(target,rows,use_icons=True)
for _,_,icon in samples:self.assertIn(icon,body)
plain=compose(target,rows,use_icons=False)
for _,_,icon in samples:self.assertNotIn(icon,plain)
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
from notification_final_fixture import restore_event
event=restore_event('missing module zfs')
for lang in LANGUAGES:
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
body_lines=result['buffered'][0][3].splitlines()
for line in body_lines:
if line.strip():
self.assertIn(' '+line.strip(),result['body'])
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
self.assertIn('white-space:pre-wrap;',result['html'])
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
'vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['text'].count('job-end hook denied'),1)
self.assertIn('100: ERROR: job-end hook denied',result['body'])
def test_job_level_subject_cause_survives_warning_cap(self):
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['body'].count('job-end hook denied'),1)
self.assertNotIn('vzdump backup status',result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
event=receive(NATIVE_REPORT+'\nWARN: changed file')
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
self.assertTrue(result['data'].get('_backup_summary'))
self.assertIn('table-layout:fixed;',result['html'])
self.assertIn('overflow-wrap:break-word;',result['html'])
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
self.assertNotIn('table-layout:fixed;',unrelated['html'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,399 @@
"""Inert producer-to-renderer checks for notification outcome claims."""
import ast
import copy
import json
import re
import time
import sys
import types
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch
from notification_fixture import templates as actual_templates
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
CATALOG = ROOT / 'AppImage/messages/en/common.json'
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}'}}
def extract(path, name, owner=None, namespace=None):
tree = ast.parse(path.read_text())
nodes = tree.body
if owner:
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
ns = namespace if namespace is not None else {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def renderer(catalog, translated=None):
path = SCRIPTS / 'notification_templates.py'
tree = ast.parse(path.read_text())
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
def lookup(obj, key):
for part in key.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
def message(key, language='en', **values):
source = translated if language == 'it' and translated is not None else catalog
namespace = source['runtime']['notifications']
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
else catalog)['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': message}
from typing import Optional
ns['Optional'] = Optional
extract(path, '_parse_vzdump_table', namespace=ns)
extract(path, '_parse_vzdump_message', namespace=ns)
extract(path, '_format_vzdump_body', namespace=ns)
return templates, extract(path, 'render_template', namespace=ns)
class OutcomeWording(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalog = json.loads(CATALOG.read_text())
cls.templates, render = renderer(cls.catalog)
cls.render = staticmethod(render)
def test_spanish_restore_uses_maintainer_guests_terminology(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.render_template('system_restore_completed', {
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
'components':1,'duration':'2m','warnings_block':''}, 'es')
self.assertIn('Configuración de guests copiada: 3', result['body'])
self.assertNotIn('invitados', result['body'].lower())
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
for lang in labels:
path = f'AppImage/messages/{lang}/common.json'
current = json.loads((ROOT / path).read_text())
for event in ('backup_complete', 'error_resolved'):
with self.subTest(lang=lang, event=event):
expected = labels[lang][event]
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
def test_exact_four_english_leaves_match_source_and_catalog(self):
for event, fields in EXPECTED.items():
for field, value in fields.items():
with self.subTest(event=event, field=field):
self.assertEqual(self.templates[event][field], value)
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
cases = [
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', header+'\n'+row_error, 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
('', 'warning', 'Backup scheduled', 'unconfirmed'),
('', 'info', 'Backup complete', 'unconfirmed'),
]
for kind, severity, message, expected in cases:
with self.subTest(message=message, severity=severity):
receiver = Receiver()
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': 'Backup', 'message': message})
event = receiver._queue.items[0]
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.data['backup_outcome'], expected)
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
samples = [('vzdump', 'info', 'Backup', 'job finished'),
('vzdump', 'warning', 'Backup', 'job incomplete'),
('', 'warning', 'backup job', 'Backup scheduled')]
for kind, severity, title, message in samples:
with self.subTest(kind=kind, message=message):
event, entity, _ = classify(None, kind, severity, title, message)
self.assertEqual((event, entity), ('backup_complete', 'vm'))
receiver = Receiver()
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': title, 'message': message})
self.assertEqual(reply['event_type'], event)
self.assertEqual(len(receiver._queue.items), 1)
emitted = receiver._queue.items[0]
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
output = self.render(emitted.event_type, emitted.data, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
from typing import Dict, Optional
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
'runtime_message': lambda key, lang, **kw: key}
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
self.assertIn('❌', formatter(failed_table, False, 'en'))
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
formatted = formatter(mixed, False, 'en')
self.assertEqual(formatted.count('✅'), 1)
self.assertNotIn('❌', formatted)
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
self.assertEqual(conflicting['vms'][0]['status'], 'error')
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
for outcome, message, title_part, body_part in (
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
):
with self.subTest(outcome=outcome):
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
self.assertIn(title_part, output['title'])
self.assertIn(body_part, output['body'])
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertNotIn('successfully', output['body'])
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
self.assertIn('ERROR: archive write failed', conflict['body'])
self.assertNotIn('Backup complete', conflict['title'])
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
actual_render = module.render_template
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
'INFO: Finished Backup of VM 104 (00:01:00)')
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log}, 'en')
self.assertIn('PBS-Cloud',single['title'])
self.assertIn('VM Alpha (104)',single['title'])
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
self.assertIn('PBS-Cloud',batch['title'])
self.assertNotIn('Alpha (104)',batch['title'])
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
self.assertEqual(no_context['title'], 'node-a: Backup complete')
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
self.assertIn('Alpha {literal} (104)', named['title'])
def test_html_email_badge_and_backup_status_are_context_specific(self):
import html
path = SCRIPTS / 'notification_channels.py'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
with self.subTest(lang=lang):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
english = self.catalog['runtime']['notifications']
def text(key, data=None, **values):
def lookup(source):
value = source['channels']
for part in key.split('.'):
value = value.get(part) if isinstance(value, dict) else None
return value
return (lookup(catalog) or lookup(english) or '').format(**values)
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
fmt = extract(path, '_format_html', 'EmailChannel', ns)
class Email:
from notification_channels import EmailChannel
_SEV_STYLE = EmailChannel._SEV_STYLE
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
subject_prefix = 'ProxMenux'
_build_detail_rows = staticmethod(build)
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
'_notification_language': lang, '_group': 'cluster'})
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
self.assertIn(label, html.unescape(email))
badge_label = label.upper()
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
if outcome == 'failed':
self.assertIn('color:#dc2626;font-weight:600;', email)
self.assertIn('background:#fef2f2;', email)
elif outcome == 'unconfirmed':
self.assertIn('background:#f9fafb;', email)
else:
self.assertIn('background:#f0fdf4;', email)
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
import importlib.util
module_path = SCRIPTS / 'notification_templates.py'
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
samples = {
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
}
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
for state, message in samples.items():
with self.subTest(lang=lang, state=state):
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
result = module.render_template('backup_complete', data, lang)
if state != 'unconfirmed':
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
expected_title = (catalog.get('backup', {}).get(key) or
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
self.assertTrue(result['title'].startswith(expected_title), result['title'])
else:
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
self.assertNotIn('{hostname}', result['title'])
if state == 'unconfirmed':
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
else self.catalog['runtime']['notifications'])
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
if state == 'failed':
self.assertIn(catalog.get('backup', {}).get('errorBody') or
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
self.assertIn('Missing module',restore['body'])
if lang == 'en': self.assertNotIn('fully ready',restore['body'].lower())
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
with self.subTest(warnings=warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
'components': 'none', 'duration': '2m',
'warnings': 'missing modules' if warnings else ''}),
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda obj: obj}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
response, status = handler()
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
event = events[0]
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
result = self.render(event['event_type'], event['data'], 'en')
self.assertIn('Post-restore tasks completed', result['body'])
self.assertNotIn('fully ready', result['body'])
if warnings: self.assertIn('missing modules', result['body'])
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
def assign(name):
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
icons = assign('EVENT_EMOJI')
self.assertNotIn('✅', icons['backup_complete'])
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
'Dict': dict, 'Any': Any,
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
'_lxc_update_label_icons': lambda *a: {}}
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
with self.subTest(state=state):
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
{'backup_outcome': state, '_notification_language': 'en'})
self.assertTrue(title.startswith(icon), title)
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,50 @@
"""Principal diagnostics must not be deduplicated against inventory substrings."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
# Frozen native Perl notifier/log-reader shape; fixed-column table is
# source-modeled from the official plaintext renderer, not a live PVE run.
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
f'100 {name} err 1m 1s 0 B {filename}\n\n'
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
class PrincipalInventoryTests(unittest.TestCase):
def assert_principal(self, raw, cause):
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
event.severity, language, manual=manual)
exact = [line for line in result['body'].splitlines()
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
self.assertEqual(len(exact), 1)
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
self.assertNotIn('raw-host', result['text'])
self.assertIn('alias {rack.location}', result['text'])
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
with self.subTest(name=name, storage=storage, filename=filename):
self.assert_principal(noisy_report(name,storage,filename), 'denied')
def test_raw_principal_braces_and_markup_survive_once(self):
cause = 'denied {rack.location} <native>'
self.assert_principal(noisy_report('denied', cause=cause), cause)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,192 @@
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
No git history, host-management import, notification send or generated fixture.
"""
import ast
import importlib.util
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
PVE92 = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
'''
def receiver():
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
for name in names:
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = ns['_classify_pve']
_map_severity = staticmethod(ns['_map_severity'])
_backup_outcome = staticmethod(ns['_backup_outcome'])
process_webhook = ns['process_webhook']
def __init__(self): self._queue = Queue()
return Receiver()
def event_for(message, severity='info'):
target = receiver()
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
'title': 'Backup', 'message': message})
return target._queue.items[0]
class PVE92Tests(unittest.TestCase):
def test_exact_maintainer_report_is_confirmed(self):
event = event_for(PVE92)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('Backup complete', result['title'])
self.assertIn('web (100)', result['title'])
self.assertIn('✅', result['body'])
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
ok = PVE92.splitlines()[3]
for rows in (ok + '\n' + failed, failed + '\n' + ok):
for severity in ('info', 'error'):
with self.subTest(rows=rows, severity=severity):
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
+ PVE92.replace(ok, rows))
event = event_for(message, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
self.assertEqual(event.data.get('vmname'), 'db')
self.assertEqual(event.data.get('vmid'), '101')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertNotIn('01:01:06', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
('other', '', 'VM/CT')):
with self.subTest(prefix=prefix):
message = PVE92.replace('vm/100/', prefix + '/100/')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'], kind)
event = event_for(message)
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn(label + ' web (100)', result['title'])
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
from unittest.mock import patch
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
'warnings_block': ''}
slovak = templates._load_runtime_catalog('sk')
english = templates._load_runtime_catalog('en')
for event, field in (('system_restore_completed', 'body'),):
with self.subTest(event=event, field=field):
value = slovak['templates'][event][field]
result = templates.render_template(event, data, 'sk')
self.assertEqual(result[field].strip(), value.format(**data).strip())
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
# Independently updated and absent leaves use the usual provider.
import copy
future = copy.deepcopy(slovak)
future['templates'][event][field] = 'REVIEWED {hostname}'
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
future['templates'][event].pop(field)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field],
templates.render_template(event, data, 'en')[field])
def test_complete_table_beats_only_truncated_supplemental_log(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
for message, severity, expected in (
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
(message, 'warning', 'completed_with_warnings'),
(message, 'error', 'failed'),
(message + '\nERROR: archive write failed', 'info', 'failed'),
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
):
with self.subTest(message=message, severity=severity):
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
for message in (
PVE92.split('Total running time:')[0],
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
PVE92.replace('1 GiB vm/', 'garbage vm/'),
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
):
with self.subTest(message=message):
# Complete guest logs cannot rescue a genuinely incomplete table.
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
def test_blank_line_between_rows_does_not_hide_a_failure(self):
message = PVE92.replace('\n\nTotal',
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
event = event_for(message)
self.assertEqual(event.data['backup_outcome'], 'failed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
import html
from notification_channels import EmailChannel
email = object.__new__(EmailChannel)
email.subject_prefix = '[ProxMenux]'
failed = PVE92.replace('\n\nTotal',
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
with self.subTest(lang=lang, severity=severity, message=message):
event = event_for(message, severity)
result = templates.render_template(event.event_type, event.data, lang)
context = {**event.data, '_event_type': event.event_type,
'_notification_language': lang, '_group': result['group']}
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
self.assertIn(result['title'], markup)
if event.data['backup_outcome'] == 'failed':
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertIn('❌ CT db (101)', markup)
status = templates.runtime_message('channels.email.status.failed', lang)
else:
self.assertIn('VM web (100)', result['title'])
self.assertIn('✅ VM web (100)', markup)
status = templates.runtime_message('channels.email.status.completed', lang)
badge = (templates.runtime_message('channels.email.severity.critical', lang)
if event.event_type == 'backup_fail' else status)
self.assertIn('>' + badge.upper() + '</span>', markup)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,24 @@
"""Review regressions at actual consumer seams; no operational imports."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
class ReviewCorrectionTests(unittest.TestCase):
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
cause = 'job-end hook denied'
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
self.assertEqual(result['text'].count(cause), 1)
self.assertNotIn('raw-host', result['text'])
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,95 @@
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
import ast
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
def extracted_stack_manager(scope):
source = MENU.read_text(encoding='utf-8').replace(
' from .stack_recreation import modify_stack\n', '')
node = next(item for item in ast.parse(source).body
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope['_manage_stack']
class MonitorModifyContractTests(TestCase):
def test_monitor_sends_modify_for_every_instance(self):
source = MONITOR.read_text(encoding='utf-8')
self.assertIn('action: "update" | "modify" | "recover"', source)
self.assertIn('action: "modify",', source)
self.assertNotIn('"recreate"', source)
def test_the_editor_has_one_name_for_one_container_and_for_several(self):
menu = MENU.read_text(encoding='utf-8')
self.assertIn("('modify', translate('Modify: edit resources, network, paths and GPU'))", menu)
self.assertIn("('modify', translate('Modify extra paths and devices'))", menu)
self.assertEqual(menu.count("('recreate', translate("), 1)
self.assertIn("('recreate', translate('Recreate every container with its saved configuration'))", menu)
def test_recreate_on_a_single_instance_changes_nothing(self):
node = next(item for item in ast.parse(MENU.read_text(encoding='utf-8')).body
if isinstance(item, ast.FunctionDef) and item.name == 'manage_instance')
row = {'vmid': 101, 'reason': 'matched', 'stack': None, 'pending': False, 'status': 'installed'}
run_lifecycle = Mock()
scope = {'carry_records': lambda *_, **__: {}, 'check_selected': lambda _project, selected: selected,
'translate': lambda text: text, '_run_lifecycle': run_lifecycle, '_manage_stack': Mock()}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), str(MENU), 'exec'), scope)
ui = Mock()
self.assertFalse(scope['manage_instance'](Path('/inert'), ui, row, action='recreate'))
self.assertEqual(ui.method_calls, [])
run_lifecycle.assert_not_called()
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
def test_modify_device_filter_matches_the_device_prompt_contract(self):
caller = STACK_RECREATION.read_text(encoding='utf-8')
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
prompt = next(node for node in ast.parse(device_prompt).body
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
primary = {'stack': {'members': [{'vmid': 101}]}}
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda *_: primary
modify_stack = Mock(return_value=True)
run_lifecycle = Mock()
manager = extracted_stack_manager({
'sys': SimpleNamespace(path=[]),
'Path': Path,
'modify_stack': modify_stack,
'_run_lifecycle': run_lifecycle,
})
ui = SimpleNamespace()
with patch.dict(sys.modules, {
'oci_instances': instances,
'oci_stack_replay': ModuleType('oci_stack_replay'),
}):
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
self.assertTrue(result)
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
run_lifecycle.assert_not_called()
if __name__ == '__main__':
import unittest
unittest.main()
@@ -0,0 +1,43 @@
"""Every result the OCI engine reports has a notification the Monitor accepts and words."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
EVENTS = [f'oci_{kind}_{result}' for kind in ('update', 'modify', 'recreate') for result in ('completed', 'failed')]
EVENTS += ['oci_watchdog_restarted', 'oci_watchdog_failed']
class OciOperationEvents(TestCase):
def test_the_monitor_accepts_and_words_every_event(self):
route = (ROOT / 'AppImage/scripts/flask_notification_routes.py').read_text()
accepted = route[route.index('_OCI_EVENTS = {'):route.index('@notification_bp.route(\'/api/internal/oci-event\'')]
templates = (ROOT / 'AppImage/scripts/notification_templates.py').read_text()
for event in EVENTS:
self.assertIn(f"'{event}':", accepted, event)
self.assertEqual(len(re.findall(rf"^ '{event}': \{{$", templates, re.MULTILINE)), 1, event)
self.assertEqual(len(re.findall(rf"^ '{event}': +'\\[uU]", templates, re.MULTILINE)), 1, event)
def test_every_language_has_the_label_and_the_message_of_every_event(self):
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
for locale in LOCALES:
catalog = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())
for event in EVENTS:
self.assertTrue(catalog['settings']['notifications']['eventTypes'][event].strip(), f'{locale} {event}')
message = catalog['runtime']['notifications']['templates'][event]
for part in ('title', 'body', 'label'):
self.assertEqual(fields(message[part]), fields(english[event][part]), f'{locale} {event} {part}')
self.assertEqual(message[part].count('\n'), english[event][part].count('\n'), f'{locale} {event} {part}')
def test_changing_an_application_is_reported_as_modified(self):
single = (ROOT / 'oci/remote/oci_update_current.py').read_text()
self.assertIn("kind = 'update' if operation == 'update' else 'modify'", single)
self.assertIn('oci_operation_notice.operation([vmid], kind,', single)
stack = (ROOT / 'oci/remote/oci_stack_modify.py').read_text()
self.assertIn("oci_operation_notice.operation([vmid], 'modify',", stack)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
self.assertIn('new options', english['oci_modify_completed']['body'])
self.assertNotIn('new options', english['oci_recreate_completed']['body'])
@@ -0,0 +1,235 @@
"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata."""
import ast
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py'
RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a '
'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts '
'to publish two FUSE views on the host; a dry run does not publish them.')
def extract_function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = dict(dependencies)
future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)
exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class RcloneHedgeDocWording(TestCase):
def test_i18n_workflow_runs_for_remote_shell_edits(self):
workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text()
self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2)
def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
shell = ROOT / 'oci/remote/configure_rclone_mount.sh'
keys = [
'Dry run completed; mount configuration was not applied. The CT was already running.',
'Dry run completed; mount configuration was not applied. The CT was started for the remote check.',
]
extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote')
self.assertTrue(set(keys).issubset(extracted))
ui = (ROOT / 'oci/remote/oci_ui.sh').read_text()
lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n'
gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n'
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory) / 'locale.json'
for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]:
for translations, expected in [({}, expected_key),
({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]:
with self.subTest(status=status, translations=translations):
cache.write_text(json.dumps(translations))
setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n'
f'STATUS={status}\nDRY_RUN=1\n' + lookup +
'msg_ok() { printf "%s\\n" "$1"; }\n')
result = subprocess.run(['bash', '-c', setup + gate],
text=True, capture_output=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), expected)
def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self):
script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text()
seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
# This is the real status/listremotes/dry-run gate, not the root-guarded script.
seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n'
for status, dry_run, starts, post_gate in (
('stopped', '1', 1, False), ('running', '1', 0, False),
('stopped', '0', 1, True), ('running', '0', 0, True)):
with self.subTest(status=status, dry_run=dry_run):
with tempfile.TemporaryDirectory() as directory:
trace = Path(directory) / 'pct.log'
harness = '''
set -Eeuo pipefail
VMID=101 REMOTE_NAME=notes DRY_RUN=%s
TRACE_FILE=%s
pct() {
printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE"
case "$1" in
status) printf 'status: %s\\n' ;;
exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;;
start) return 0 ;;
*) return 97 ;;
esac
}
oci_quiet() { "$@"; }
oci_log() { :; }
sleep() { :; }
translate() { printf '%%s' "$1"; }
msg_info() { printf 'info:%%s\\n' "$1"; }
msg_ok() { printf 'ok:%%s\\n' "$1"; }
die() { printf 'error:%%s\\n' "$1"; exit 1; }
''' % (dry_run, str(trace), status)
result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'],
text=True, capture_output=True, timeout=5)
calls = trace.read_text()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls.count('pct:start 101'), starts)
self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls)
self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate)
if dry_run == '1':
self.assertIn('mount configuration was not applied', result.stdout)
if starts:
self.assertIn('CT was started for the remote check', result.stdout)
else:
self.assertIn('CT was already running', result.stdout)
self.assertNotIn('pct:stop', calls)
def test_hedgedoc_overlay_survives_index_enrichment(self):
sys.path.insert(0, str(ROOT / 'oci/src'))
try:
from proxmenux_oci.catalog import Catalog
finally:
sys.path.pop(0)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalog = Catalog(root, source=Mock())
catalog.apps_dir.mkdir(parents=True)
catalog.overlays_dir.mkdir(parents=True)
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
upstream = 'HedgeDoc gives you access to all your files wherever you are.'
corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template['catalog_ui']['description']['en_US'] = upstream
template['catalog_ui']['tagline']['en_US'] = upstream
overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text())
self.assertTrue(overlay['catalog_ui']['hidden'])
self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected)
(catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay))
catalog._apply_overlay('hedgedoc', template)
self.assertEqual(template['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected)
(catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template))
payload = {'applications': [{'id': 'hedgedoc', 'description': upstream},
{'id': 'unmodified', 'description': 'Original summary'}]}
catalog._enrich_index_from_templates(payload)
self.assertEqual(payload['applications'][0]['description'], corrected)
self.assertEqual(payload['applications'][1]['description'], 'Original summary')
def test_rclone_guidance_precedes_inputs_and_confirmation(self):
events = []
class UI:
def message(self, text): events.append(('message', text))
def ask(self, label, default=None, required=True):
events.append(('ask', label))
return {'VMID of the Rclone OCI container': '101',
'Exact name of the remote': 'notes',
'Mount name': 'notes'}.get(label, default or '')
def choose(self, label, options, default):
events.append(('choose', label))
return default
def confirm(self, label, default):
events.append(('confirm', label))
return True
schema = {name: {'default': value} for name, value in (
('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'),
('shared_mount_root', '/mnt/rclone/rw'),
('shared_mount_read_only_root', '/mnt/rclone/ro'))}
schema['vfs_cache_mode']['options'] = ['writes']
template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}}
fn = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=UI, DialogUI=UI, translate=lambda text: text,
re=re, InstallError=ValueError, UserCancelled=ValueError)
deployment = fn(template, UI())
self.assertEqual(events[0], ('message', RCLONE))
self.assertEqual(events[1][0], 'ask')
self.assertEqual(events[-1][0], 'confirm')
self.assertEqual(deployment['remote_name'], 'notes')
def test_hedgedoc_detail_uses_corrected_template_and_index(self):
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if item['id'] == 'hedgedoc')
self.assertEqual(template['catalog_ui']['tagline']['en_US'], key)
self.assertEqual(template['catalog_ui']['description']['en_US'], key)
self.assertEqual(item['description'], key)
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text',
Any=object)
fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lambda text: text,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io',
is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
result = fn(SimpleNamespace(), item, template)
self.assertIn(key, result)
self.assertNotIn('gives you access to all your files', result)
def test_missing_empty_and_synthetic_translation_on_real_consumers(self):
from unittest.mock import Mock
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
scope = {'language': lambda: 'it', '_cache': {}}
lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope)
cache = lookup.__globals__
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if x['id'] == 'hedgedoc')
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text')
detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lookup,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
for values, expected_rclone, expected_hedgedoc in (
({}, RCLONE, key),
({RCLONE: '', key: ''}, RCLONE, key),
({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'},
'GUIDA RCLONE', 'EDITOR COLLABORATIVO')):
with self.subTest(values=values):
cache['_cache'] = values
ui = SimpleNamespace(message=Mock(), ask=Mock(side_effect=StopIteration))
rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=object, DialogUI=object, translate=lookup,
re=re, InstallError=ValueError,
UserCancelled=ValueError)
# Stop at the first prompt: no operational path is entered.
with self.assertRaises(StopIteration):
rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui)
ui.message.assert_called_once_with(expected_rclone)
self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template))
def test_extractor_sees_both_new_sources(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
keys = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertIn(RCLONE, keys)
keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog'])
self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys)
@@ -0,0 +1,223 @@
"""Offline checks of real OCI messages; never import administrative modules."""
import ast
import importlib.util
import json
from pathlib import Path
import re
import runpy
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
'up and replaced with its new image. If the operation fails after a container was changed, the backups are '
'restored. An operation that stops halfway is recovered from the OCI management menu.')
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
def extracted(path, name, scope):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class InventoryMessages(unittest.TestCase):
def preview(self, configs, records, cache=None, translate=lambda text: text):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
def read(root, vmid):
if vmid not in records:
raise OSError('unreadable record')
return records[vmid]
instances.read = read
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
remover.guest_config = lambda vmid: configs.get(vmid)
remover.host_directories = lambda root, members: cache or []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
'translate': translate, 're': re}
summary = extracted(MENU, '_removal_summary', scope)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return summary(Path('/inert'), 101)
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
text = self.preview({101: None, 102: None}, {})
self.assertIn(EMPTY_HOST, text)
self.assertNotIn('No host directory is used by this application.', text)
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
self.assertNotIn('Host directories that are kept, with their content:', text)
def test_removal_notice_qualifies_saved_record_source(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'description: owned',
'host_directories': lambda root, members: ['/bind/saved'], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: None,
'remove_owned_host_firewall': lambda primary: True,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
're': re, 'guest_node': lambda vmid: None,
'remove_host_state': lambda vmid: events.append(('host', vmid)),
'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extracted(REMOVE, 'remove', scope)
remove(Path('/inert'), 101)
self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
self.assertIn(('log', 101), events)
self.assertIn(('host', 101), events)
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
events.clear()
remove(Path('/inert'), 101)
self.assertIn(('info', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
class RecoveryMessages(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.journal = Path(self.tmp.name) / 'journal.json'
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
self.primary = {'stack': {'members': self.members}}
self.calls = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
self.ui = SimpleNamespace(
choose=lambda *args: 'update',
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
confirm=lambda *args: self.fail('host confirmation must not run'),
message=lambda *args: self.fail('unexpected error'))
def manage(self):
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
return next(message for kind, message in self.calls if kind == 'review')
def test_update_preview_names_the_stack_and_what_happens(self):
message = self.manage()
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
for phase in ('committed', 'rolled-back', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.assertEqual(self.manage(), PENDING)
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
self.ui.review = lambda message, *args, **kwargs: True
for phase in ('committed', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
self.assertEqual(self.calls, [
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
'--acknowledge-external-data'], 'Recover OCI stack')])
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
events = []
scope = {'TERMINAL': {'committed', 'rolled-back'},
'translate': lambda message: message,
'member_tx': SimpleNamespace(log=lambda message: None),
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
'member': lambda adapter, vmid: f'CT {vmid}'}
extracted(TRANSACTION, 'save', scope)
recover = extracted(TRANSACTION, 'recover_state', scope)
class Adapter:
def finalize(self, state): events.append('finalize')
def validate(self, plan): events.append('validate')
def restore(self, vmid, backup, txid):
events.append('restore')
raise RuntimeError('restore failed')
def stop(self, vmid): events.append('stop')
plan = {'start_order': [101], 'stop_order': [101]}
terminal = {'phase': 'committed', 'plan': plan}
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
self.assertEqual(events, ['finalize'])
events.clear()
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
recover(self.journal, failed, Adapter())
self.assertEqual(events, ['validate', 'stop', 'restore'])
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(KEYS), len(set(KEYS)))
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
translate=module.translate)
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
# Exercise the actual management consumer, not just the provider.
self.scope['translate'] = module.translate
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.calls.clear()
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,379 @@
"""Exercise extracted removal/UI/guard consumers with inert dependencies only."""
import ast
import argparse
from contextlib import nullcontext
import importlib.util
import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
INSTALL = ROOT / 'oci/remote/install_oci.sh'
PREVIEW = 'Private network targeted for release if no other guest uses it:'
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
RESULT = 'Removal command finished; review any warnings above.'
BRIDGE = 'Private network release attempted:'
BRIDGE_FAILURE = 'Could not complete private network release:'
SUCCESS = 'The application was removed'
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
PORT = 'The host-monitor firewall port does not match exactly one TCP port in the container contract'
OPTIONAL = 'A matching managed host firewall rule may also be removed.'
MEMBERS = 'All of them are targeted for removal.'
WHOLE = ('It cannot be removed on its own, because the application would stop '
'working: continuing targets the whole application for removal.')
TARGETS = 'Containers targeted for removal:'
DATA = 'Container data targeted for deletion:'
KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, PORT, OPTIONAL, MEMBERS, WHOLE, TARGETS, DATA)
def extract(path, name, scope):
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class RemovalWordings(unittest.TestCase):
def preview(self, bridge='vmbr9', translated=None, firewall=True):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}, 'deployment':
{'host_firewall': {'port': 8080}} if firewall else {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: bridge
remover.bridge_in_use = lambda bridge, removed: False
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': translated or (lambda value: value), 're': re}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
def test_preview_conditions_network_and_discloses_optional_firewall(self):
preview = self.preview()
self.assertIn(PREVIEW + ' vmbr9', preview)
self.assertIn(OPTIONAL, preview)
self.assertIn(MEMBERS, preview)
self.assertIn(TARGETS, preview)
self.assertIn(DATA, preview)
self.assertNotIn('Private network of the application that is released:', preview)
self.assertNotIn(PREVIEW, self.preview(bridge=None))
self.assertNotIn(OPTIONAL, self.preview(firewall=False))
def test_member_preview_does_not_promise_whole_stack_removed(self):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': lambda value: value, 're': re}
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
self.assertIn(WHOLE, preview)
def test_cancellation_preserves_lifecycle_boundary_and_warns_precisely(self):
calls = []
ui = SimpleNamespace(review=lambda message, title, **kw: calls.append((message, kw)) or False,
message=lambda *args: self.fail('unexpected error'))
scope = {'_removal_summary': lambda project, vmid: 'Preview',
'_run_lifecycle': lambda *args: self.fail('must not run'),
'translate': lambda value: value, 'sys': SimpleNamespace(executable='python3'),
'Path': Path, 'OSError': OSError, 'ValueError': ValueError, 'KeyError': KeyError}
self.assertFalse(extract(MENU, '_remove', scope)(Path('/inert'), ui, 101))
self.assertEqual(calls, [('Preview', {'question': CONFIRM, 'default': False})])
def test_shared_bridge_and_skipped_member_are_not_reported_removed(self):
events = []
records = {102: {'installation_id': 'owned'}, 101: {'installation_id': 'owned'}}
scope = {'members_of': lambda root, vmid: (101, records[101], [102, 101]),
'instances': SimpleNamespace(read=lambda root, vmid: records[vmid],
identity=lambda raw: 'reassigned' if raw == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'reassigned' if vmid == 102 else b'owned',
'host_directories': lambda root, members: [], 'granted_directories': lambda *args: [], 'private_bridge': lambda primary: 'vmbr9',
'bridge_in_use': lambda bridge, removed: True,
'release_bridge': lambda bridge: self.fail('shared bridge release'),
'remove_owned_host_firewall': lambda record: None,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extract(REMOVE, 'remove', scope)
for skipped_config in (b'reassigned', None):
with self.subTest(skipped_config=skipped_config):
scope['guest_config'] = lambda vmid: skipped_config if vmid == 102 else b'owned'
events.clear()
remove(Path('/inert'), 101)
self.assertEqual([x for x in events if x[0] == 'run'],
[('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1'))])
self.assertFalse(any(x == ('ok', 'The application was removed') for x in events))
self.assertTrue(any('still used' in x[1] for x in events if x[0] == 'info'))
self.assertTrue(any('no longer exists' in x[1] if skipped_config is None
else 'belongs to another container' in x[1]
for x in events if x[0] == 'warn'))
# The actual main() success line is independently guarded by the expected neutral literal.
tree = ast.parse(REMOVE.read_text())
main = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'main')
self.assertIn(RESULT, [n.value for n in ast.walk(main) if isinstance(n, ast.Constant) and isinstance(n.value, str)])
def test_actual_main_success_is_completion_not_all_members_removed(self):
events = self.lifecycle_events(missing=True)
self.assertIn(('warn', 'The container no longer exists: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def lifecycle_events(self, *, missing=False, reassigned=False, bridge='none',
ip_rc=0, pvesh_rc=0, ip_error=False, firewall_failure=False,
firewall_success=False, kept=False):
events, commands = [], []
self.last_commands = commands
record = {'installation_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}
if firewall_failure or firewall_success:
record['deployment'] = {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}
records = {101: record, 102: {'installation_id': 'owned'}}
def fake_command(args, **kwargs):
commands.append(tuple(args))
if ip_error and args[:2] == ['ip', 'link']:
raise OSError('ip unavailable')
if args[:2] == ['pvesh', 'get']:
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + record['installation_id'],
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]), returncode=0)
if args[:2] == ['pvesh', 'delete'] and '/firewall/' in args[2] and firewall_failure:
raise subprocess.CalledProcessError(1, args)
return SimpleNamespace(returncode=ip_rc if args[:2] == ['ip', 'link'] else
pvesh_rc if args[:2] == ['pvesh', 'delete'] else 0)
fake_subprocess = SimpleNamespace(run=fake_command, CalledProcessError=subprocess.CalledProcessError)
scope = {'members_of': lambda root, vmid: (101, record, [102, 101]),
'instances': SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: records[vmid],
identity=lambda cfg: record['installation_id'] if cfg == b'primary' else
'other' if cfg == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: (None if missing else b'reassigned' if reassigned else b'owned')
if vmid == 102 else b'primary',
'host_directories': lambda *args: ['/retained'] if kept else [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9' if bridge != 'none' else None,
'bridge_in_use': lambda *args: bridge == 'shared',
'run': lambda *args: commands.append(args), 'subprocess': fake_subprocess,
'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json, 're': re,
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)),
'msg_error': lambda text: events.append(('error', text)),
'argparse': argparse, 'os': SimpleNamespace(geteuid=lambda: 0),
'sys': SimpleNamespace(argv=['oci_remove.py', '101'])}
scope['release_bridge'] = extract(REMOVE, 'release_bridge', scope)
scope['remove_owned_host_firewall'] = extract(REMOVE, 'remove_owned_host_firewall', scope)
scope['remove'] = extract(REMOVE, 'remove', scope)
with patch.object(sys, 'argv', ['oci_remove.py', '101']):
self.assertEqual(extract(REMOVE, 'main', scope)(), 0)
return events
def test_clean_removal_has_clear_success(self):
events = self.lifecycle_events()
self.assertEqual(events[-1], ('ok', 'The application was removed'))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
def test_skipped_identity_and_kept_bridge_have_partial_result(self):
events = self.lifecycle_events(reassigned=True, bridge='shared')
self.assertIn(('warn', 'The VMID belongs to another container now and is not touched: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_shared_bridge_intentionally_kept_does_not_taint_clean_removal(self):
events = self.lifecycle_events(bridge='shared')
self.assertTrue(any('still used' in text for kind, text in events if kind == 'info'))
self.assertEqual(events[-1], ('ok', 'The application was removed'))
def test_failed_bridge_commands_have_partial_result(self):
for ip_rc, pvesh_rc in ((1, 0), (0, 1), (1, 1)):
with self.subTest(ip_rc=ip_rc, pvesh_rc=pvesh_rc):
events = self.lifecycle_events(bridge='private', ip_rc=ip_rc, pvesh_rc=pvesh_rc)
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_bridge_release_and_retained_paths_are_clean(self):
events = self.lifecycle_events(bridge='private', kept=True)
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
self.assertTrue(any(kind == 'info' and '/retained' in text for kind, text in events))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_bridge_exception_is_reported_but_cleanup_continues(self):
events = self.lifecycle_events(bridge='private', ip_error=True)
self.assertEqual([cmd[:2] for cmd in self.last_commands],
[('pct', 'stop'), ('pct', 'destroy'), ('pct', 'stop'),
('pct', 'destroy'), ('ip', 'link'), ('pvesh', 'delete')])
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_firewall_delete_is_clean(self):
events = self.lifecycle_events(firewall_success=True)
self.assertTrue(any('Host firewall rule removed:' in text for kind, text in events if kind == 'ok'))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_firewall_delete_failure_has_partial_result(self):
events = self.lifecycle_events(firewall_failure=True)
self.assertIn(('warn', FIREWALL), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_ignored_bridge_command_failures_do_not_claim_release(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda cfg: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'owned', 'host_directories': lambda *args: [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9', 'bridge_in_use': lambda *args: False,
'release_bridge': lambda bridge: events.append(('attempt', bridge)),
'remove_owned_host_firewall': lambda record: None, 'run': lambda *args: None,
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'msg_info': lambda text: None,
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
self.assertIn(('attempt', 'vmbr9'), events)
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
events = []
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': subprocess, 'translate': lambda text: text,
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': 'invalid', 'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
def test_ambiguous_firewall_match_warns_without_deleting_other_rules(self):
events, commands = [], []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
rule = {'comment': 'ProxMenux OCI firewall ' + installation_id, 'dport': '8080',
'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in', 'action': 'ACCEPT', 'pos': 2}
def fake_run(args, **kwargs):
commands.append(args)
return SimpleNamespace(stdout=json.dumps([rule, {**rule, 'pos': 3}]))
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': installation_id,
'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
self.assertEqual([command[1] for command in commands], ['get'])
def test_firewall_delete_exception_has_unknown_outcome_not_unchanged_rule(self):
events = []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
commands = []
def fake_run(args, **kwargs):
commands.append(args)
if args[1] == 'get':
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + installation_id,
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]))
raise subprocess.CalledProcessError(1, args)
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)), 'OSError': OSError, 'ValueError': ValueError}
extract(REMOVE, 'remove_owned_host_firewall', scope)({'installation_id': installation_id,
'deployment': {'host_firewall': {'port': 8080, 'source': '192.0.2.0/24'}}})
self.assertEqual(commands[-1][1], 'delete')
self.assertEqual(events, [('warn', FIREWALL)])
def test_extractor_and_runtime_lookup_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = set(generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote']))
shell_keys = set(generator['extract_translate_texts'](ROOT / 'oci/remote'))
self.assertTrue(set(KEYS) - {PORT} <= found, (set(KEYS) - {PORT}) - found)
self.assertIn(PORT, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
for key in KEYS:
self.assertEqual(module.translate(key), key)
module._language, module._cache = 'it', {key: 'IT: ' + key for key in KEYS}
self.assertIn('IT: ' + PREVIEW, self.preview(translated=module.translate))
self.assertIn('IT: ' + OPTIONAL, self.preview(translated=module.translate))
def test_actual_shell_guard_fails_for_missing_tcp_contract_not_web_role(self):
source = INSTALL.read_text()
function = source[source.index('validate_host_monitor_firewall() {'):source.index('\napply_host_monitor_firewall() {')]
script = '''set -eu
translate() { printf '%s' "$1"; }
die() { printf '%s' "$1" >&2; exit 7; }
jq() {
case "$*" in
*'.host_firewall == null'*) printf 'false\\n' ;;
*'.host_firewall | type'*) printf 'true\\n' ;;
*'.host_firewall.bridge'*) printf 'vmbr0\\n' ;;
*'.host_firewall.source'*) printf '192.0.2.0/24\\n' ;;
*'.host_firewall.port'*) printf '8080\\n' ;;
*'.proxmox.installer_profile.host_monitor_firewall.web_port'*) printf '8080\\n' ;;
*'.container_contract.ports'*) printf '0\\n' ;;
*) exit 99 ;;
esac
}
ip() { printf '2: vmbr0 inet 192.0.2.1/24 scope global vmbr0\\n'; }
BRIDGE=vmbr0 HOST_MONITOR=netdata DEPLOYMENT_FILE=/inert/deployment TEMPLATE_FILE=/inert/template
''' + function + '\nvalidate_host_monitor_firewall\n'
result = subprocess.run(['bash', '-c', script], text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(result.stderr, PORT)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,124 @@
"""Exercise the real OCI preview/import functions without importing host-management modules."""
import ast
import json
from pathlib import Path
import runpy
import unittest
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
ROWS = {
'It needs a privileged container, which is not isolated from the host.':
'This profile requires a privileged LXC, which reduces isolation from the host.',
'It asks for capabilities or a relaxed confinement profile.':
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
'The image expects files that are given to it one by one:':
'These container mount paths have an extension-like suffix:',
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
'This import rejects these paths without checking whether they are files or directories.',
}
class ConversionError(Exception):
pass
def extracted(name, **deps):
node = next(n for n in ast.parse(CUSTOM.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
'translate': lambda text: text}
scope.update(deps)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
return scope[name]
class SecurityMountWording(unittest.TestCase):
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
describe = extracted('describe', blocker_text=str)
template = {'container_contract': {'image': {'reference': 'test:latest'},
'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {}}}
for profile, present in (
({}, ()),
({'optional_privileged_lxc': True}, ()),
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
(ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
):
with self.subTest(profile=profile):
template['proxmox']['security_profile'] = profile
text = describe(template)
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.']):
if warning in present:
self.assertIn(' ' + warning, text)
else:
self.assertNotIn(warning, text)
template['proxmox']['security_profile'] = {}
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
text = describe(template)
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
with self.assertRaises(ConversionError) as caught:
fn('services:\n app:\n image: example:latest\n')
self.assertEqual(str(caught.exception),
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
def test_suffix_free_path_still_accepted(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(ROWS), 4)
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
import importlib.util
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for new in ROWS.values():
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
setattr(module, '_cache', {})
for new in ROWS.values():
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
extracted('describe', translate=module.translate, blocker_text=str)({
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,152 @@
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
import ast
import importlib.util
import json
import tempfile
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
OLD_EMPTY = 'No OCI instances are registered.'
EMPTY = 'No registered OCI containers are available for selection on this host.'
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
NO_MEMBERS = 'This stack has no saved members to update.'
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
HTTP = 'The application did not pass its HTTP check:'
def extracted(name, **dependencies):
node = next(n for n in ast.parse(MENU.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
for n in ast.walk(node):
if isinstance(n, (ast.If, ast.With)):
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
scope = dict(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope[name]
class SelectionSetupWording(TestCase):
def test_selector_empty_explains_availability_not_registry_absence(self):
ui = SimpleNamespace(message=Mock(), choose=Mock())
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=lambda project, **options: {},
translate=lambda s: s)
fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called()
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
class Entry:
name = '101'
class Lock:
def __enter__(self): return None
def __exit__(self, *_): return False
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
locked=lambda _: Lock(), has_contract=lambda *args: True,
guest_exists=lambda _: False, read=Mock())
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
instances=fake, public_row=Mock())
self.assertEqual(fn(Path('/fixture')), [])
fake.read.assert_not_called()
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
self._stack({}, NO_MEMBERS).choose.assert_not_called()
def test_stack_unsupported_replay_retains_distinct_verdict(self):
# It cannot be updated, but it can still be changed or removed.
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
ui.choose.assert_called_once()
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'watchdog', 'remove'])
def _stack(self, stack, expected):
record = {'stack': stack}
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
ui = SimpleNamespace(message=Mock(), choose=Mock(return_value=None), review=Mock())
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
oci_stack_replay=replay, translate=lambda s: s)
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
ui.message.assert_called_once_with(expected, 'OCI stack management')
ui.review.assert_not_called()
return ui
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
for app, port in (('paperless', ':8000'), ('tandoor', '')):
with self.subTest(app=app):
path = ROOT / f'oci/remote/install_{app}_stack.sh'
lines = path.read_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
# Actual Bash failure expression, never source the parent installer.
harness = '''set -u
APPLICATION_LAN_IP=192.0.2.1
APPLICATION_ID=101
OCI_LOG=/dev/null
curl() { return 7; }
translate() { printf '%s' "$1"; }
print_first_boot_diagnostics() { :; }
die() { printf 'ERROR:%s\\n' "$1"; }
''' + command + '\n'
run = subprocess.run(['bash', '-c', harness], text=True,
capture_output=True, timeout=3)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(run.stdout.strip(),
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
self.assertIn('2>>"$OCI_LOG"', command)
def test_nextcloud_explicit_setup_guard_kept(self):
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
start = source.index('translate() {')
fn = source[start:source.index('\n}', start) + 2]
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / 'missing.json'
missing.write_text('{}')
translated = Path(tmp) / 'translated.json'
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
cache = json.loads(path.read_text())
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
text=True, capture_output=True, timeout=3)
self.assertEqual(result.returncode, 0, (path, result.stderr))
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
def test_extraction_and_real_lookup_all_locales(self):
import runpy
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (EMPTY, NO_MEMBERS, REPLAY):
self.assertIn(key, extracted_keys)
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
self.assertIn(HTTP, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
self.assertEqual(module.translate(EMPTY), EMPTY)
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
@@ -0,0 +1,193 @@
"""Inert source-as-key checks for OCI update and recreation; no host modules imported."""
import ast
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import re
import runpy
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
RECREATE = ROOT / 'oci/src/proxmenux_oci/recreation.py'
MANAGE = ROOT / 'oci/src/proxmenux_oci/management.py'
UPDATE = ROOT / 'oci/remote/oci_update_current.py'
OLD_CONFIRM = 'Apply the options from the current catalog template? Your data and configuration are kept.'
CONFIRM = ('Apply options from the current catalog template? New required paths and settings may be requested. '
'Review the resulting configuration before recreating the CT.')
OLD_GUARD = 'The current template changes the image or identity; an explicit migration is required'
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
'the new image to update the container. If the update fails after the container was changed, the backup '
'is restored. An update that stops halfway is recovered from the OCI management menu.')
def extracted(path, name, scope):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class RecreationWording(unittest.TestCase):
def refresh(self, mutate, proceed=True):
old = {'id': 'app-old', 'container_contract': {'image': {'repository': 'example/app', 'reference': 'example/app:v1'},
'volumes': [], 'environment': []},
'proxmox': {'installer_profile': {}}, 'catalog_ui': {'title': 'old'}}
latest = copy.deepcopy(old)
mutate(latest)
candidate = {'template': old, 'deployment': {'mounts': [], 'environment': [], 'security': {}}}
events = []
catalog_module = ModuleType('proxmenux_oci.catalog')
class Catalog:
def __init__(self, root):
pass
def load_index(self):
return {'applications': [{'template_id': old['id'], 'id': 'app'}]}
def compose(self, name):
return latest
catalog_module.Catalog = Catalog
ui = SimpleNamespace(confirm=lambda message, default: events.append(('confirm', message, default)) or proceed,
info=lambda message: events.append(('info', message)))
scope = {'__package__': 'proxmenux_oci', '__file__': str(RECREATE), 'Path': Path, 're': re,
'translate': lambda text: text, 'copy': copy}
with patch.dict(sys.modules, {'proxmenux_oci.catalog': catalog_module}):
try:
extracted(RECREATE, 'refresh_template', scope)(candidate, ui)
except ValueError as error:
events.append(('error', str(error)))
return candidate, events
def test_confirmation_describes_followup_without_preservation_guarantee(self):
candidate, events = self.refresh(lambda latest: latest['catalog_ui'].update(title='new'), proceed=False)
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['catalog_ui']['title'], 'old')
self.assertNotIn(OLD_CONFIRM, str(events))
def test_guard_names_exact_rejected_identity_and_repository_changes(self):
for change in (lambda latest: latest.update(id='app-new'),
lambda latest: latest['container_contract']['image'].update(repository='other/app')):
with self.subTest(change=change):
candidate, events = self.refresh(change)
self.assertEqual(events, [('confirm', CONFIRM, True), ('error', GUARD)])
self.assertEqual(candidate['template']['id'], 'app-old')
def test_same_repository_new_tag_does_not_trigger_migration_guard(self):
candidate, events = self.refresh(lambda latest: latest['container_contract']['image'].update(reference='example/app:v2'))
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['container_contract']['image']['reference'], 'example/app:v2')
class UpdateWording(unittest.TestCase):
def test_actual_native_backup_verification_succeeds_or_fails_before_replacement(self):
transaction = ROOT / 'oci/remote/oci_instance_transaction.py'
for failures in (1, 2):
with self.subTest(failed_integrity_checks=failures), tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
archive = directory / 'vzdump-lxc-101.tar.zst'
calls = []
def inert_run(*args):
calls.append(args)
if args[0] == 'vzdump':
archive.write_bytes(b'inert fixture')
elif args[0] == 'zstd' and sum(call[0] == 'zstd' for call in calls) <= failures:
raise RuntimeError('invalid backup')
scope = {'run': inert_run, 'log': lambda text: None,
'msg_warn': lambda text: None, 'translate': lambda text: text}
backup = extracted(transaction, 'verified_backup', scope)
if failures == 2:
with self.assertRaisesRegex(RuntimeError, 'invalid backup'):
backup(101, directory, 'zstd', 'missing backup')
else:
self.assertEqual(backup(101, directory, 'zstd', 'missing backup'), archive)
self.assertEqual([call[0] for call in calls], ['vzdump', 'zstd', 'vzdump', 'zstd'])
self.assertTrue(all(call[call.index('--mode') + 1] == 'stop'
for call in calls if call[0] == 'vzdump'))
apply_source = transaction.read_text()
apply_node = next(n for n in ast.parse(apply_source).body
if isinstance(n, ast.FunctionDef) and n.name == 'apply')
calls = [(n.lineno, n.func.id) for n in ast.walk(apply_node) if isinstance(n, ast.Call)
and isinstance(n.func, ast.Name) and n.func.id in ('verified_backup', 'checkpoint', 'install_candidate')]
backup_line = next(line for line, name in calls if name == 'verified_backup')
ready_line = next(line for line, name in calls if name == 'checkpoint' and line > backup_line)
install_line = next(line for line, name in calls if name == 'install_candidate')
self.assertLess(backup_line, ready_line)
self.assertLess(ready_line, install_line)
def test_preview_blocks_lifecycle_when_cancelled_and_keeps_command_on_accept(self):
events = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'status': 'installed'}
instances.command = lambda *args: b'description: owned'
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
decision = False
def review(message, title, **kwargs):
events.append(('review', message, title, kwargs))
return decision
ui = SimpleNamespace(review=review, unattended=True)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_instance_reconcile': reconcile}):
manage = extracted(MANAGE, 'manage_instance', scope)
self.assertFalse(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events, [('review', PREVIEW, 'Update OCI', {'question': 'Update now?', 'default': True})])
decision = True
self.assertTrue(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events[-1], ('run', ['python3', str(ROOT / 'oci/remote/oci_update_current.py'), '101',
'--acknowledge-external-data'], 'Update OCI'))
self.assertNotIn(OLD_PREVIEW, str(events))
def test_noop_image_digest_returns_before_any_backup_or_apply(self):
events = []
record = {'status': 'installed', 'observed': {'image': {'manifest_digest': 'sha256:current'}}}
instances = SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: record, command=lambda *args: b'arch: amd64')
transaction = SimpleNamespace(candidate_contract=lambda *args: {'deployment': {}},
external_changes=lambda *args: {}, preflight=lambda *args: None,
require_backup_space=lambda *args: self.fail('backup space checked on no-op'),
apply=lambda *args, **kwargs: self.fail('apply on no-op'))
scope = {'instances': instances, 'transaction': transaction,
'translate': lambda text: text,
'msg_info': lambda text: events.append(text), 'msg_ok': lambda text: events.append(text),
'resolve_archive': lambda desired, config, current, check: (None, current),
'kept_settings': lambda *args: self.fail('kept settings on no-op')}
extracted(UPDATE, 'update', scope)(101, keep_backup='not-a-real-storage')
self.assertIn('The image is already up to date; nothing was changed.', events)
def test_new_keys_extracted_and_shipped_and_missing_locale_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (CONFIRM, GUARD, PREVIEW):
self.assertIn(key, found)
scope = {'json': json, 'Path': Path, 'BASE_DIR': ROOT,
'_language': 'it', '_cache': None}
lookup = extracted(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', scope)
scope['language'] = lambda: scope['_language']
for path in sorted((ROOT / 'lang').glob('*.json')):
scope['_language'] = path.stem
scope['_cache'] = None
values = json.loads(path.read_text())
for key in (CONFIRM, GUARD, PREVIEW):
self.assertEqual(lookup(key), values.get(key) or key, (path.name, key))
scope['_language'] = 'it'
for key in (CONFIRM, GUARD, PREVIEW):
scope['_cache'] = {}
self.assertEqual(lookup(key), key, ('missing', key))
scope['_cache'] = {key: 'LOCALIZED: ' + key}
self.assertEqual(lookup(key), 'LOCALIZED: ' + key, ('synthetic', key))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,231 @@
"""OCI validation record: generated list, record checks, credited authors and the report bot."""
import datetime
import importlib.util
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("oci_validation", ROOT / ".github/scripts/oci_validation.py")
validation = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validation)
TODAY = datetime.date(2026, 9, 28)
REPORT = "https://github.com/MacRimi/ProxMenux/issues/400"
INDEX = {"applications": [
{"id": "glances", "title": "Glances", "category_label": "Monitoring", "automatic_install_candidate": True},
{"id": "2fauth", "title": "2FAuth", "category_label": "Security", "automatic_install_candidate": True},
{"id": "legacy", "title": "Legacy | App", "category_label": "Other", "automatic_install_candidate": False},
{"id": "hidden", "title": "Hidden", "hidden": True},
]}
def form(**answers):
fields = {"Application": "Glances", "Image version": "4.3", "Image digest": "2026-09-06 · B1F339CF",
"Proxmox VE version": "9.1", "Install mode": "Default configuration",
"Data storage": "Volume on Proxmox storage", "Network": "DHCP", "GPU": "No GPU",
"Result": "Works", "Notes": "_No response_"}
fields.update(answers)
return "\n\n".join(f"### {label}\n\n{value}" for label, value in fields.items())
def event(body=None, labels=("oci-validation", "validated"), user="Tester-1"):
return {"issue": {"number": 400, "html_url": REPORT, "user": {"login": user}, "body": form() if body is None else body,
"labels": [{"name": name} for name in labels]}}
class Render(unittest.TestCase):
def test_lists_community_and_installable_lab_entries(self):
record = {"applications": {
"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}},
"2fauth": {"status": "laboratory-validated"},
"legacy": {"status": "laboratory-validated"}}}
text = validation.render(record, INDEX)
self.assertIn("2 of 3 applications", text)
self.assertIn("| Glances | [@Vaso73](https://github.com/Vaso73) | 2026-09-27 | — | — | [Report](" + REPORT + ") |", text)
self.assertIn("| 2FAuth | Security |", text)
# An application that cannot be installed is not listed as verified.
self.assertNotIn("Legacy", text)
def test_empty_record_and_escaped_titles(self):
text = validation.render({"applications": {}}, INDEX)
self.assertIn("No application has been tested by the community yet.", text)
record = {"applications": {"legacy": {"community_tested": {"by": "a", "date": "2026-09-27"}}}}
self.assertIn("| Legacy \\| App |", validation.render(record, INDEX))
def test_repository_record_is_valid_and_current(self):
record = validation.load(validation.VERIFICATION)
index = validation.load(validation.INDEX)
self.assertEqual(validation.problems(record, index, validation.OUTPUT.read_text(encoding="utf-8")), [])
class Problems(unittest.TestCase):
def check(self, entry):
return validation.problems({"applications": {"glances": entry}}, INDEX, today=TODAY)
def test_valid_entries(self):
self.assertEqual(self.check({"status": "laboratory-validated"}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27",
"report": "https://github.com/MacRimi/ProxMenux/discussions/392#discussioncomment-18623868"}}), [])
def test_rejected_entries(self):
cases = [
({"status": "works"}, "status can only be"),
({"community_tested": {"by": "bad user", "date": "2026-09-27"}}, "GitHub user name"),
({"community_tested": {"by": "a", "date": "27/09/2026"}}, "YYYY-MM-DD"),
({"community_tested": {"by": "a", "date": "2027-01-01"}}, "in the future"),
({"community_tested": {"by": "a", "date": "2026-09-27", "report": "https://example.com/x"}}, "must link"),
({"community_tested": {"by": "a", "date": "2026-09-27", "extra": 1}}, "unknown community_tested keys"),
({"verified": True}, "unknown keys"),
({}, "non-empty object"),
]
for entry, message in cases:
with self.subTest(entry=entry):
self.assertTrue(any(message in problem for problem in self.check(entry)), self.check(entry))
self.assertTrue(validation.problems({"applications": {"nope": {"status": "laboratory-validated"}}}, INDEX))
def test_stale_list_is_reported(self):
record = {"applications": {"2fauth": {"status": "laboratory-validated"}}}
self.assertTrue(any("out of date" in p for p in validation.problems(record, INDEX, "old", TODAY)))
class Authors(unittest.TestCase):
base = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}},
"2fauth": {"status": "laboratory-validated"}}}
def head(self, **changes):
data = json.loads(json.dumps(self.base))
data["applications"].update(changes)
return data
def test_author_adds_own_test(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "F3rs3n"), [])
def test_crediting_someone_else_is_refused(self):
head = self.head(pocketbase={"community_tested": {"by": "Vaso73", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "f3rs3n"),
["pocketbase: credits @Vaso73, but the pull request is from @f3rs3n"])
def test_lab_status_and_removals_are_for_the_maintainer(self):
head = self.head(glances={}, pocketbase={"status": "laboratory-validated"})
found = validation.author_problems(self.base, head, "f3rs3n")
self.assertIn("glances: a community test is removed by the maintainer only", found)
self.assertIn("pocketbase: the ProxMenux lab status is changed by the maintainer only", found)
self.assertEqual(validation.author_problems(self.base, head, "MacRimi"), [])
def test_missing_base_file(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems({}, {"applications": {"pocketbase": head["applications"]["pocketbase"]}},
"f3rs3n"), [])
class Record(unittest.TestCase):
def test_parse_form_reads_github_issue_form_body(self):
fields = validation.parse_form(form(Notes="Line one\r\nLine two"))
self.assertEqual(fields["Application"], "Glances")
self.assertEqual(fields["Notes"], "Line one\nLine two")
self.assertEqual(validation.parse_form(form())["Notes"], "")
def test_records_the_report_author(self):
data, app, message = validation.record(event(), {"applications": {"glances": {"status": "laboratory-validated"}}},
INDEX, TODAY)
self.assertEqual(app, "glances")
self.assertEqual(data["applications"]["glances"], {"status": "laboratory-validated", "community_tested": {
"by": "Tester-1", "date": "2026-09-28", "report": REPORT, "digest": "b1f339cf",
"scenario": "Default configuration · Volume on Proxmox storage · DHCP · No GPU · Proxmox VE 9.1 · image 4.3"}})
self.assertIn("@Tester-1", message)
self.assertEqual(validation.problems(data, INDEX, today=TODAY), [])
def test_matches_id_or_title_and_replaces_older_test(self):
record = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}}}}
data, app, message = validation.record(event(form(Application=" glances ")), record, INDEX, TODAY)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("replaces the previous one from @Vaso73", message)
self.assertEqual(record["applications"]["glances"]["community_tested"]["by"], "Vaso73")
def test_refusals(self):
for case, message in [
(event(form(Application="Unknown")), "does not match"),
(event(form(Result="Does not work")), "does not work"),
(event(labels=("validated",)), "not an OCI validation report"),
(event(user="bad user"), "could not be read"),
(event(form(**{"Image digest": "latest"})), "does not hold an image digest"),
]:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
validation.record(case, {"applications": {}}, INDEX, TODAY)
class ReviewerComment(unittest.TestCase):
allowed = frozenset({"macrimi", "vaso73", "f3rs3n"})
def comment(self, reviewer, author="Tester-1"):
data = event(labels=("oci-validation",), user=author)
data["comment"] = {"user": {"login": reviewer}, "body": "/validated"}
return data
def test_listed_reviewer_validates_and_is_named(self):
data, app, message = validation.record(self.comment("Vaso73"), {"applications": {}}, INDEX, TODAY, self.allowed)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("Validated by @Vaso73.", message)
def test_unlisted_reviewer_and_own_report_are_refused(self):
with self.assertRaisesRegex(ValueError, "not in the list of OCI validation reviewers"):
validation.record(self.comment("someone"), {"applications": {}}, INDEX, TODAY, self.allowed)
with self.assertRaisesRegex(ValueError, "other than its author"):
validation.record(self.comment("f3rs3n", author="f3rs3n"), {"applications": {}}, INDEX, TODAY, self.allowed)
def test_maintainer_may_validate_own_report_and_label_needs_no_list(self):
validation.record(self.comment("MacRimi", author="MacRimi"), {"applications": {}}, INDEX, TODAY, self.allowed)
validation.record(event(), {"applications": {}}, INDEX, TODAY)
def test_reviewer_list_file(self):
self.assertEqual(validation.reviewers("# comment\nMacRimi\n Vaso73 # tester\nbad name\n"),
{"macrimi", "vaso73"})
listed = validation.reviewers(validation.REVIEWERS.read_text(encoding="utf-8"))
self.assertEqual(listed, self.allowed)
class ImageAndScenario(unittest.TestCase):
def test_digest_as_the_monitor_shows_it_or_in_full(self):
full = "sha256:" + "ab" * 32
self.assertEqual(validation.image_digest("2026-09-06 · b1f339cf"), "b1f339cf")
self.assertEqual(validation.image_digest(full.upper()), "ab" * 32)
for value in ("", "b1f3", "latest", "sha256:xyz12345"):
self.assertIsNone(validation.image_digest(value))
def test_scenario_names_problems_and_skips_empty_fields(self):
fields = validation.parse_form(form(Result="Works with problems", GPU="_No response_"))
self.assertEqual(validation.scenario(fields), "Works with problems: Default configuration · "
"Volume on Proxmox storage · DHCP · Proxmox VE 9.1 · image 4.3")
def test_list_shows_image_and_scenario(self):
record = {"applications": {"glances": {"community_tested": {
"by": "Vaso73", "date": "2026-09-27", "digest": "b1f339cf08ae", "scenario": "Default | DHCP"}}}}
self.assertIn("| `b1f339cf08ae` | Default \\| DHCP |", validation.render(record, INDEX))
def test_bad_digest_and_long_scenario_are_rejected(self):
found = validation.problems({"applications": {"glances": {"community_tested": {
"by": "a", "date": "2026-09-27", "digest": "XYZ", "scenario": "x" * 301}}}}, INDEX, today=TODAY)
self.assertTrue(any("digest" in f for f in found) and any("scenario" in f for f in found), found)
class IssueForm(unittest.TestCase):
def test_form_labels_match_the_bot(self):
text = (ROOT / ".github/ISSUE_TEMPLATE/oci-validation.yml").read_text(encoding="utf-8")
labels = re.findall(r"^\s+label: (.+)$", text, re.M)
self.assertIn("Application", labels)
self.assertIn("Result", labels)
self.assertIn("Image digest", labels)
for name in validation.SCENARIO_FIELDS:
self.assertIn(name, labels)
self.assertIn("- Does not work", text)
self.assertIn('labels: ["oci-validation"]', text)
self.assertIn("template=oci-validation.yml", validation.FORM)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
import ast
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class OciWatchdogWiring(TestCase):
def test_changing_it_needs_an_administrator_and_goes_through_the_engine(self):
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
route = re.search(r"@app\.route\('/api/lxc/<int:vmid>/oci-watchdog', methods=\['POST'\]\)\n@(\w+)\ndef (\w+)", server)
self.assertEqual(route.group(1), 'require_admin_scope')
body = server[route.end():server.index("@app.route('/api/vms/<int:vmid>/logs'")]
self.assertIn("if not isinstance(enabled, bool):", body)
self.assertIn("oci/engine/remote/oci_watchdog.py',", body)
self.assertNotIn('shell=True', body)
self.assertIn('"watchdog": False,', (ROOT / 'AppImage/scripts/oci_instance_info.py').read_text())
def test_the_toggle_is_only_offered_for_an_oci_application(self):
modal = (ROOT / 'AppImage/components/virtual-machines.tsx').read_text()
block = modal[modal.index('{/* Watchdog of an OCI application'):]
self.assertTrue(block.split('\n', 3)[2].strip().startswith('{ociInstance?.oci_instance && ('))
self.assertIn('t("vmLxc.details.watchdog")', block)
self.assertIn('`/api/lxc/${selectedVM.vmid}/oci-watchdog`', modal)
def test_every_language_names_and_explains_it(self):
for locale in LOCALES:
details = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['vmLxc']['details']
self.assertTrue(details['watchdog'].strip(), locale)
self.assertTrue(details['watchdogHelp'].strip(), locale)
def test_the_engine_restarts_its_service_when_proxmenux_replaces_it(self):
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
self.assertIn('systemctl try-restart proxmenux-oci-watchdog.service', (ROOT / installer).read_text(), installer)
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn("UNIT = 'proxmenux-oci-watchdog.service'", engine)
class OciWatchdogInstaller(TestCase):
CLI = (ROOT / 'oci/src/proxmenux_oci/cli.py').read_text()
MENU = (ROOT / 'oci/src/proxmenux_oci/management.py').read_text()
def default(self, template):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'watchdog_default')
scope = {'Any': object}
exec(compile(ast.Module(body=[node], type_ignores=[]), 'cli.py', 'exec'), scope)
return scope['watchdog_default'](template)
def test_the_recipe_decides_what_is_proposed(self):
self.assertTrue(self.default({'container_contract': {'restart': 'unless-stopped'}}))
self.assertTrue(self.default({'container_contract': {'restart': 'always'}}))
self.assertTrue(self.default({'compose_stack': {'services': [{'compose': {}}, {'compose': {'restart': 'on-failure'}}]}}))
self.assertFalse(self.default({'container_contract': {'restart': None}}))
self.assertFalse(self.default({'container_contract': {'restart': 'no'}, 'compose_stack': {'services': [{'compose': {'restart': 'no'}}]}}))
self.assertFalse(self.default({}))
def test_every_installation_asks_it_and_the_engine_never_receives_the_answer(self):
install = self.CLI[self.CLI.index('def install_template('):self.CLI.index('def _rclone_mount(')]
asked = install.index('deployment["watchdog"] = wizard.confirm(')
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
self.assertLess(asked, install.index('approved = wizard.review('))
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
def test_both_management_menus_offer_it(self):
self.assertEqual(self.MENU.count("('watchdog', "), 2)
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
def test_the_service_is_written_through_the_change_journal(self):
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn('pmx_write_file "$2" && systemctl daemon-reload && pmx_enable_service "$3"', engine)
journal = (ROOT / 'scripts/global/pmx_journal.sh').read_text()
for helper in ('pmx_journal_context()', 'pmx_write_file()', 'pmx_enable_service()'):
self.assertIn(helper, journal)
+181
View File
@@ -0,0 +1,181 @@
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
import ast
import importlib.util
import io
import json
import runpy
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CHOICE = 'Recover the previous installation'
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
NEW_KEYS = (
'The Compose file describes several services:',
'Only one service at a time can be installed this way.',
'No docker run command was given',
'Image selection was cancelled',
'Could not inspect the image in its registry:',
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
BEFORE, AFTER,
)
class ConversionError(Exception):
pass
class UserCancelled(Exception):
pass
def function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Only read_definition's infrastructure import is omitted; keep the function intact.
if name == 'read_definition':
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
scope.update(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class OciWordingTests(TestCase):
def test_extractor_shipped_locales_and_synthetic_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in NEW_KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in NEW_KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
def test_multiple_services_named_not_images(self):
template = function(CUSTOM, 'template_from_compose',
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
with self.assertRaises(ConversionError) as caught:
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
'beta': {'image': 'same:latest'}}}))
self.assertEqual(str(caught.exception),
'The Compose file describes several services: alpha, beta. '
'Only one service at a time can be installed this way.')
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
for title, expected in ((None, 'No Compose file was given'),
('docker run command of the application', 'No docker run command was given')):
def paste(content):
return function(CUSTOM, '_read_pasted',
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
msg_info2=lambda *_: None),
sys=SimpleNamespace(stdin=io.StringIO(content)))
with self.assertRaises(UserCancelled) as caught:
paste('')(None, title)
self.assertEqual(str(caught.exception), expected)
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
def test_cancelled_image_menu(self):
with self.assertRaises(UserCancelled) as caught:
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
def test_registry_nonzero_and_success_command_unchanged(self):
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
run = Mock(return_value=result)
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
self.assertEqual(report('example:latest'),
(False, 'Could not inspect the image in its registry: example:latest'))
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
capture_output=True, text=True, check=False, timeout=120)
run.reset_mock()
run.return_value = SimpleNamespace(returncode=0,
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
self.assertEqual(run.call_count, 1)
def test_failed_registry_inspection_has_neutral_advice(self):
ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
fn(ui)
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
'Check the image reference and registry access. If the registry is unavailable, '
'try again later. A private registry needs credentials, which are not supported yet.')
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
# Pin the real translated management choice, not an English AST constant alone.
source = ast.parse(MENU.read_text())
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
and n.elts[1].func.id == 'translate' and n.elts[1].args
and isinstance(n.elts[1].args[0], ast.Constant)
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
label = module.translate(CHOICE)
self.assertNotEqual(label, CHOICE, path.name)
expected = [(cache.get(key) or key).replace('{choice}', label)
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
for key in (BEFORE, AFTER)]
self.assertEqual(warnings, expected, path.name)
# An intentionally absent message key proves fallback independently of shipped catalogs.
setattr(module, '_cache', {CHOICE: label})
warnings.clear()
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module._language = 'it'
for translation in ('Before: «{choice}».', 'Before: label missing.'):
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
AFTER: 'After: «{choice}».'}
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [
('Before: «Recupero».' if '{choice}' in translation
else BEFORE.replace('{choice}', 'Recupero')),
'After: «Recupero».'])
@@ -0,0 +1,71 @@
"""A preview or a warning promises only what the code always does."""
import ast
import json
from pathlib import Path
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
def oci_menu_texts():
tree = ast.parse((ROOT / 'oci/src/proxmenux_oci/management.py').read_text())
return [node.args[0].value for node in ast.walk(tree)
if isinstance(node, ast.Call) and getattr(node.func, 'id', '') == 'translate'
and node.args and isinstance(node.args[0], ast.Constant)]
class OutcomePromises(TestCase):
def test_a_failure_is_not_promised_to_end_in_a_restore(self):
texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 3)
def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
self.assertIn('If the container had already been changed', preview)
self.assertIn('when the cleanup succeeds', preview)
def test_a_rclone_mount_is_not_listed_as_a_registration_problem(self):
prompt = next(text for text in oci_menu_texts() if text.startswith('Until they are registered again'))
self.assertIn('does not prevent it', prompt)
self.assertIn('not started automatically', prompt)
def test_an_empty_retention_is_the_one_of_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertIn('Leave empty to use the retention of the storage.', menu)
self.assertNotIn('keep every backup', menu)
def test_host_backups_are_said_to_follow_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertNotIn('stop running', menu)
self.assertIn('Another job that writes to the same storage still starts them.', menu)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertNotIn('stop running', english['deleteAttached'] + english['storageAttached'])
self.assertIn('scheduled runs', english['disableBody'])
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
for key in ('deleteAttached', 'storageAttached'):
self.assertEqual(texts[key].count('{ids}'), 1, f'{locale} {key}')
self.assertEqual(texts['disableBody'].count('. '), 1, f'{locale} disableBody')
def test_the_host_restore_notice_reports_tasks_and_does_not_call_the_node_ready(self):
for locale in LOCALES:
body = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['runtime']['notifications']['templates']['system_restore_completed']['body']
self.assertEqual(body.split('\n')[-1], '{warnings_block}', locale)
self.assertEqual(body.count('\n'), 7, locale)
def test_the_watchdog_failure_covers_an_application_that_did_not_start(self):
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']['oci_watchdog_failed']
self.assertNotIn('keeps stopping', english['title'] + english['body'] + english['label'])
self.assertIn('did not start', english['body'])
def test_other_wording_that_promised_too_much(self):
texts = oci_menu_texts()
self.assertIn('Private network kept, because other guests still use it:', texts)
recreation = (ROOT / 'oci/src/proxmenux_oci/stack_recreation.py').read_text()
self.assertNotIn('if anything fails', recreation)
self.assertIn('a stopped one is left stopped', recreation)
for script in ('scripts/post_install/customizable_post_install.sh', 'scripts/post_install/uninstall-tools.sh'):
self.assertNotIn('leaving it unchanged', (ROOT / script).read_text(), script)
@@ -0,0 +1,178 @@
"""Offline actual scheduler gates; never source the administrative script."""
import importlib.util
import json
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCHEDULER = ROOT / 'scripts/backup_restore/backup_scheduler.sh'
MESSAGES = {
'pbs': 'No matching PVE vzdump jobs were found for the PBS backend.',
'local': 'No matching PVE vzdump jobs were found for the local archive backend.',
}
HINT = 'Create one first in Datacenter → Backup, then return here to attach.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def consumers():
text = SCHEDULER.read_text()
attached = function(text, '_create_job_attached').split(' local pve_prune\n')[0] + '}\n'
# Only the backend/attach selection gate, not ID/config writes or scheduling.
new = function(text, '_create_job')
new = '_create_job() {\n local id=fixture backend on_calendar\n' + new[new.index(' backend=$(dialog'):new.index(' on_calendar=$(dialog')]
new += ' return 42\n}\n'
return attached + new
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
locale='en', catalog=None, real_dialog=None):
directory = Path(directory)
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
(directory / 'rows').write_text(rows)
(directory / 'second').write_text(rows if second is None else second)
bindir = directory / 'bin'
bindir.mkdir(exist_ok=True)
# Only jq is executable via PATH; wrapper validates all lookup arguments.
(bindir / 'jq').symlink_to('/usr/bin/jq') if not (bindir / 'jq').exists() else None
translation = function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
filter_code = function((ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text(), 'hb_pve_list_vzdump_jobs_for_backend')
preamble = f'''PATH={shlex.quote(str(bindir))}
LANG_DIR={shlex.quote(str(directory))}
LANGUAGE={shlex.quote(locale)}
FIXTURE={shlex.quote(str(directory))}
BACKEND={backend}
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // .[($text | gsub("\\\\\\\\n"; "\\n"))] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
hb_pve_list_vzdump_jobs() {{
local file="$FIXTURE/rows" line
[[ -f "$FIXTURE/read" ]] && file="$FIXTURE/second"
printf read >> "$FIXTURE/read"
while IFS= read -r line; do printf '%s\\n' "$line"; done < "$file"
}}
dialog() {{
printf '%s\\0' "$@" >> "$FIXTURE/dialogs"
local title='' kind='' message='' previous='' arg
for arg in "$@"; do
[[ "$previous" == --title ]] && title="$arg"
[[ "$previous" == --msgbox || "$previous" == --menu ]] && message="$arg"
[[ "$arg" == --msgbox || "$arg" == --menu ]] && kind="$arg"
previous="$arg"
done
if [[ "$kind" == --msgbox ]]; then
printf '%s' "$message" > "$FIXTURE/message"
{'command ' + shlex.quote(real_dialog) + ' "$@"' if real_dialog else ':'}
return 0
fi
case "$title" in
"$(translate 'Backend')") printf '%s' "$BACKEND" >&2 ;;
"$(translate 'How to schedule')") printf attach >&2 ;;
"$(translate 'Pick PVE vzdump job')") return 1 ;;
*) printf 'unexpected dialog:%s' "$title" >> "$FIXTURE/errors"; return 99 ;;
esac
}}
'''
return preamble + translation + filter_code + consumers() + f'\nif _create_job{"_attached fixture " + backend if consumer == "attached" else ""}; then rc=0; else rc=$?; fi\nprintf "%s" "$rc" > "$FIXTURE/status"\n'
def run_case(**kwargs):
with tempfile.TemporaryDirectory(prefix='scheduler-') as directory:
code = fixture(directory, **kwargs)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', code],
env={'PATH': '/nonexistent', 'LC_ALL': 'C.UTF-8'}, capture_output=True, text=True)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr))
p = Path(directory)
if (p / 'errors').exists():
raise AssertionError((p / 'errors').read_text())
return {name: (p / name).read_text() if (p / name).exists() else ''
for name in ('message', 'status', 'dialogs', 'read')}
class SchedulerMessages(unittest.TestCase):
def test_new_whole_message_and_separate_hint(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend, consumer='new')
self.assertEqual(result['message'], MESSAGES[backend] + '\n\n' + HINT)
self.assertEqual(result['status'], '1')
def test_lookup_cache_modes_at_both_consumers(self):
shipped = json.loads((ROOT / 'lang/it.json').read_text())
for backend, message in MESSAGES.items():
for consumer in ('attached', 'new'):
for locale, catalog, expected in (
('en', {message: 'ignored'}, message),
('missing', {}, message),
('xx', {'unrelated': 'unrelated'}, message),
('xx', shipped, shipped.get(message, message)),
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
'SYNTHETIC reordered backend: ' + backend),
):
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
self.assertEqual(result['message'], expected + suffix)
self.assertEqual(result['status'], '1')
def test_nonempty_menus_disabled_and_second_read_empty(self):
for backend, storage in (('pbs', 'pbs'), ('local', 'nfs')):
for count in (1, 3):
rows = ''.join(f'job{i}\tstore{i}\t{storage}\tdaily\t-\t0\n' for i in range(count))
for consumer in ('attached', 'new'):
with self.subTest(backend=backend, count=count, consumer=consumer):
result = run_case(backend=backend, consumer=consumer, rows=rows)
self.assertEqual(result['message'], '')
self.assertEqual(result['status'], '1') # cancel at actual parent picker
for i in range(count):
self.assertIn(f'job{i} · store{i} · daily (disabled)', result['dialogs'])
result = run_case(backend=backend, consumer='new', rows=rows, second='')
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['read'], 'readread')
self.assertEqual(result['status'], '1')
def test_actual_filter_types_and_enabled_states(self):
for backend in MESSAGES:
for storage in ('pbs', 'dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs', 'rbd', '-'):
for enabled in ('0', '1'):
with self.subTest(backend=backend, storage=storage, enabled=enabled):
result = run_case(backend=backend, rows=f'job\tstore\t{storage}\tdaily\t-\t{enabled}\n')
matches = storage == 'pbs' if backend == 'pbs' else storage in ('dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs')
self.assertEqual(result['message'], '' if matches else MESSAGES[backend])
if matches:
self.assertEqual('(disabled)' in result['dialogs'], enabled == '0')
def test_borg_excludes_attach_gate(self):
result = run_case(backend='borg', consumer='new')
self.assertEqual(result['status'], '42') # stopped before real schedule/config work
self.assertEqual(result['message'], '')
self.assertEqual(result['read'], '')
self.assertNotIn('How to schedule', result['dialogs'])
def test_actual_offline_extractor_discovers_whole_messages(self):
spec = importlib.util.spec_from_file_location('scheduler_cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
discovered = module.extract_translate_texts(ROOT / 'scripts')
for message in MESSAGES.values():
self.assertIn(message, discovered)
def test_attached_whole_message(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend)
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['status'], '1')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,42 @@
"""A script writes a line break as \\n inside the text it translates; the
language files store that text with a real line break."""
import json
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class TranslateLineBreaks(TestCase):
def translate(self, text, catalog):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / 'xx.json').write_text(json.dumps(catalog))
script = (f'LANGUAGE=xx LANG_DIR={folder}\n' + function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
+ 'translate "$1"\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script, 'bash', text],
capture_output=True, text=True, timeout=10)
self.assertEqual((result.returncode, result.stderr), (0, ''))
return result.stdout.rstrip('\n')
def test_a_text_stored_with_a_real_line_break_is_found(self):
catalog = {'Restore this backup?\nThe configuration is overwritten.': '¿Restaurar?\nSe sobrescribe.'}
self.assertEqual(self.translate('Restore this backup?\\nThe configuration is overwritten.', catalog),
'¿Restaurar?\nSe sobrescribe.')
def test_the_exact_text_is_preferred_and_a_missing_one_comes_back_unchanged(self):
catalog = {'one\\ntwo': 'LITERAL', 'one\ntwo': 'REAL', 'plain': 'simple'}
self.assertEqual(self.translate('one\\ntwo', catalog), 'LITERAL')
self.assertEqual(self.translate('plain', catalog), 'simple')
self.assertEqual(self.translate('not\\nthere', catalog), 'not\\nthere')
def test_the_shipped_texts_with_a_line_break_reach_the_spanish_file(self):
spanish = json.loads((ROOT / 'lang/es.json').read_text())
text = 'Are you sure you want to restore this backup?\\nCurrent configuration will be overwritten.'
self.assertEqual(self.translate(text, spanish), spanish[text.replace('\\n', '\n')])
@@ -0,0 +1,152 @@
"""Offline, bounded real Bash consumers: no administrative entrypoints are sourced."""
import importlib.util
import json
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'scripts'
NETWORK = {
'share/iscsi_host.sh': 1,
'share/nfs_client.sh': 1,
'share/nfs_host.sh': 2,
'share/samba_client.sh': 1,
'share/samba_host.sh': 2,
}
MOUNT = 'Mounting existing filesystem ({filesystem})...'
RAID = 'If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def network_blocks(path):
lines = path.read_text().splitlines()
blocks = []
for i, line in enumerate(lines):
if re.search(r'^\s*if (?:pct exec .* -- )?nc -z -w 2 ', line):
depth = len(line) - len(line.lstrip())
end = next(j for j in range(i + 1, len(lines))
if lines[j].strip() == 'fi' and len(lines[j]) - len(lines[j].lstrip()) == depth)
block = '\n'.join(lines[i:end + 1])
assert '$(translate "Open")' in block and '$(translate "Closed")' in block or '$(translate "Probe failed")' in block
blocks.append(block)
return blocks
def raid_dialog():
"""The dialog that reports a RAID member, wherever it sits in the script."""
lines = (SCRIPTS / 'storage/disk-passthrough_ct.sh').read_text().splitlines()
message = next(i for i, line in enumerate(lines) if RAID in line)
assert lines[message - 2].lstrip().startswith('dialog ')
return '\n'.join(lines[message - 2:message + 1])
def run(code, translations=None, statuses='1', variables='', dialog=False):
translations = translations or {}
with tempfile.TemporaryDirectory(prefix='tui-wording-') as directory:
root = Path(directory)
(root / 'xx.json').write_text(json.dumps(translations))
body = f'''LANGUAGE=xx LANG_DIR={root!s}
{function((SCRIPTS / 'utils.sh').read_text(), 'translate')}
GN='\\033[32m' RD='\\033[31m' CL='\\033[0m'
{variables}
statuses=({statuses})
nc() {{ local code=${{statuses[0]}}; statuses=("${{statuses[@]:1}}"); return "$code"; }}
pct() {{ [[ "$1" == exec && "$3" == -- && "$4" == nc ]] || return 98; shift 3; nc "$@"; }}
msg_info() {{ printf 'INFO:%s\\n' "$1"; }}
msg_error() {{ printf 'ERROR:%s\\n' "$1"; }}
msg_ok() {{ printf 'OK:%s\\n' "$1"; }}
msg_title() {{ :; }}
show_proxmenux_logo() {{ :; }}
pmx_journal_context() {{ :; }}
blkid() {{ [[ "$2" == TYPE ]] && printf 'ext4\\n' || printf 'UUID123\\n'; }}
mkdir() {{ :; }}
mount() {{ return 1; }}
dialog() {{ printf 'DIALOG:%s\\n' "$*"; }}
{code}
'''
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', body],
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C.UTF-8'},
capture_output=True, text=True, timeout=10)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr, result.stdout))
return result.stdout
class TuiNetworkStorageWording(unittest.TestCase):
def test_failed_tcp_probe_is_not_declared_closed_at_every_caller(self):
for path, count in NETWORK.items():
blocks = network_blocks(SCRIPTS / path)
self.assertEqual(len(blocks), count, path)
for index, block in enumerate(blocks):
cases = [('0 1', 'Open'), ('1 1', 'Probe failed'), ('124 124', 'Probe failed')]
if 'samba_' in path:
cases.append(('1 0', 'Open'))
for codes, expected in cases:
with self.subTest(path=path, index=index, codes=codes):
output = run(block, statuses=codes, translations={'Probe failed': 'SONDAGGIO FALLITO'})
self.assertIn('SONDAGGIO FALLITO' if expected == 'Probe failed' else 'Open', output)
self.assertNotIn('Closed', output)
if expected == 'Open':
self.assertNotIn('SONDAGGIO FALLITO', output)
else:
fallback = run(block, statuses=codes)
self.assertIn('Probe failed', fallback)
self.assertNotIn('Closed', fallback)
def test_mount_progress_is_whole_and_uses_real_lookup_with_fallback(self):
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
portion = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\n'
for translation, expected in [({}, MOUNT.replace('{filesystem}', 'ext4')),
({MOUNT: 'SYNTHETIC {filesystem} in corso'}, 'SYNTHETIC ext4 in corso'),
({MOUNT: 'SYNTHETIC malformed'}, MOUNT.replace('{filesystem}', 'ext4'))]:
with self.subTest(translation=translation):
output = run(portion + '\nmount_existing_disk /dev/fixture /mnt/fixture', translations=translation)
self.assertIn('INFO:' + expected, output)
self.assertNotIn('{filesystem}', output)
def test_raid_warning_is_complete_message_with_disk_identifier_and_fallback(self):
invocation = raid_dialog() # the dialog invocation, not the raid detection command
for translation, expected in [({}, RAID), ({RAID: 'SYNTHETIC RAID metadata remains'}, 'SYNTHETIC RAID metadata remains')]:
with self.subTest(translation=translation):
output = run(invocation, translations=translation,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture 2TiB' UI_RESULT_H=16 UI_RESULT_W=80")
self.assertIn(expected, output)
self.assertIn('/dev/fixture 2TiB', output)
self.assertNotIn('remove the RAID metadata or format it manually using external tools. RAID metadata', output)
def test_shipped_catalogs_use_english_fallback_for_new_keys(self):
catalogs = sorted((ROOT / 'lang').glob('*.json'))
self.assertTrue(catalogs)
block = network_blocks(SCRIPTS / 'share/iscsi_host.sh')[0]
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
mount = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\nmount_existing_disk /dev/fixture /mnt/fixture'
raid = raid_dialog()
for path in catalogs:
with self.subTest(locale=path.name):
catalog = json.loads(path.read_text())
self.assertIn(catalog.get('Probe failed') or 'Probe failed', run(block, statuses='1', translations=catalog))
self.assertIn((catalog.get(MOUNT) or MOUNT).replace('{filesystem}', 'ext4'), run(mount, translations=catalog))
self.assertIn(catalog.get(RAID) or RAID, run(raid, translations=catalog,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture' UI_RESULT_H=16 UI_RESULT_W=80"))
def test_extractor_discovers_whole_keys(self):
spec = importlib.util.spec_from_file_location('cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
keys = module.extract_translate_texts(SCRIPTS)
for key in (MOUNT, RAID, 'Probe failed'):
self.assertTrue(key in keys, key)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,23 @@
"""Run the inert Node TUI consumer fixture in the existing offline i18n lane."""
import os
from pathlib import Path
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[3]
class TuiUpgradeGpuWording(unittest.TestCase):
def test_source_extracted_node_consumer(self):
fixture = ROOT / 'tests/test_tui_upgrade_gpu_wording.cjs'
self.assertTrue(fixture.is_file(), 'Node fixture must be packaged with the candidate')
node = os.environ.get('NODE_BINARY') or 'node'
result = subprocess.run([node, str(fixture)], cwd=ROOT, capture_output=True,
text=True, timeout=60, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
self.assertEqual(result.returncode, 0, result.stdout + '\n' + result.stderr)
self.assertIn('PASS: extracted upgrade/GPU consumers', result.stdout)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,69 @@
"""The VM/CT backup jobs feature is protected, shipped and translated."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class VmBackupJobsWiring(TestCase):
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
source = ROUTES.read_text()
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
self.assertEqual(len(routes), 8)
for path, method, guard, name in routes:
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
self.assertEqual(guard, expected, f'{method} {path} ({name})')
def test_proxmox_is_called_without_a_shell(self):
source = ROUTES.read_text()
self.assertNotIn('shell=True', source)
self.assertNotIn('os.system', source)
self.assertEqual(source.count('subprocess.run('), 1)
self.assertIn('subprocess.run(["pvesh", *args]', source)
def test_the_monitor_ships_and_registers_it(self):
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
self.assertIn('<HostBackup />', tab)
self.assertIn('<VmBackupJobs />', tab)
# The backups of the host and those of the guests are separate views.
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertEqual(used - set(english), set())
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
self.assertEqual(set(texts), set(english), locale)
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
for key, value in english.items():
if isinstance(value, str):
self.assertTrue(texts[key].strip(), f'{locale} {key}')
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
source = SCRIPT.read_text()
self.assertNotIn('apt-get', source)
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
@@ -0,0 +1,104 @@
name: Build AppImage Release
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: AppImage
run: npm install --legacy-peer-deps
- name: Build Next.js app
working-directory: AppImage
run: npm run build
- name: Install Python dependencies
run: |
# 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
working-directory: AppImage
run: chmod +x scripts/build_appimage.sh
- 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
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Generate SHA256 checksum
run: |
cd AppImage/dist
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
echo "Generated SHA256:"
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: |
AppImage/dist/*.AppImage
AppImage/dist/*.sha256
retention-days: 30
- name: Commit AppImage to main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
cp AppImage/dist/*.AppImage AppImage/
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
git add AppImage/*.AppImage AppImage/*.sha256
git commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin main
+104
View File
@@ -0,0 +1,104 @@
name: Build AppImage Beta
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout develop
uses: actions/checkout@v6
with:
ref: develop
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: AppImage
run: npm install --legacy-peer-deps
- name: Build Next.js app
working-directory: AppImage
run: npm run build
- name: Install Python dependencies
run: |
# 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
working-directory: AppImage
run: chmod +x scripts/build_appimage.sh
- 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
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Generate SHA256 checksum
run: |
cd AppImage/dist
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
echo "Generated SHA256:"
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage artifact
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
path: |
AppImage/dist/*.AppImage
AppImage/dist/*.sha256
retention-days: 30
- name: Commit AppImage to develop
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
cp AppImage/dist/*.AppImage AppImage/
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
git add AppImage/*.AppImage AppImage/*.sha256
git commit -m "Update AppImage beta build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin develop
+105
View File
@@ -0,0 +1,105 @@
name: Build ProxMenux Monitor AppImage
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Install dependencies
working-directory: AppImage
run: npm install --legacy-peer-deps
- name: Build Next.js app
working-directory: AppImage
run: npm run build
- name: Install Python dependencies
run: |
# 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
working-directory: AppImage
run: chmod +x scripts/build_appimage.sh
- 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
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
retention-days: 30
- name: Generate SHA256 checksum
run: |
cd AppImage/dist
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
echo "Generated SHA256:"
cat ProxMenux-Monitor.AppImage.sha256
- name: Upload AppImage and checksum to /AppImage folder in main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout main
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
# Copy new files
cp AppImage/dist/*.AppImage AppImage/
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
git add AppImage/*.AppImage AppImage/*.sha256
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
git push origin main
+80
View File
@@ -0,0 +1,80 @@
name: Build ProxMenux Monitor AppImage
on:
push:
branches: [ main ]
paths: [ 'AppImage/**' ]
pull_request:
branches: [ main ]
paths: [ 'AppImage/**' ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: AppImage
run: npm install --legacy-peer-deps
- name: Build Next.js app
working-directory: AppImage
run: npm run build
- name: Install Python dependencies
run: |
# 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
working-directory: AppImage
run: chmod +x scripts/build_appimage.sh
- 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
working-directory: AppImage
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Upload AppImage artifact
uses: actions/upload-artifact@v7
with:
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
path: AppImage/dist/*.AppImage
retention-days: 30
+138
View File
@@ -0,0 +1,138 @@
name: Build i18n messages
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
# against the English source whenever the source changes.
#
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
# runtime fallback (locale → en → key), so this workflow doesn't break
# anything if it misses a key: it just eliminates the visible-English
# blocks in non-en locales.
#
# Guardrails baked into build_i18n_messages.py:
# - Never overwrites a key whose target value differs from EN (i.e.
# already translated by a human). This is what makes it safe to
# include sk in the default set: Vaso73's curated strings are
# protected end-to-end; auto only fills keys still on the EN
# fallback.
# - `{placeholder}` tokens are protected end-to-end.
#
# Triggers:
# - push to develop touching AppImage/messages/en/common.json
# - manual via workflow_dispatch
on:
push:
branches: [develop]
paths:
# Only en/ triggers the run — it's the source of truth. Other
# locales are destinations, and the bot auto-commits them at
# the end of every run; if they were in the trigger too, each
# auto-commit would fire another (empty) run.
# Manual edits to a curated locale that empty a key for the
# workflow to refill are the exception — dispatch this
# workflow manually from Actions in that case, or piggy-back
# a trivial en/ change onto the commit.
- 'AppImage/messages/en/common.json'
- '.github/scripts/build_i18n_messages.py'
- '.github/workflows/build-i18n-messages.yml'
workflow_dispatch:
inputs:
refresh:
description: 'Re-translate every key (overwrites human translations!)'
type: boolean
default: false
languages:
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
default: 'es,de,fr,it,pt,sk,sv'
# Prevent two runs from racing on the same branch and fighting over the
# auto-commit. cancel-in-progress:false because a full first-run may take
# ~30 min for the initial bootstrap and interrupting mid-flight would
# waste the calls already made.
concurrency:
group: build-i18n-messages-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write # auto-commit AppImage/messages/*/common.json to develop
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
# 0.15s/call with rate-limit backoffs. 90 min headroom.
timeout-minutes: 90
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
# Full history so the auto-commit doesn't drift when another
# push landed between trigger and this job start.
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install googletrans
run: |
python -m pip install --upgrade pip
# Same pinning as build-translation-cache.yml so the two
# workflows share behavior. Bump both in lockstep.
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Translate missing keys
run: |
REFRESH_FLAG=""
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
REFRESH_FLAG="--refresh"
fi
LANGS="${{ github.event.inputs.languages }}"
# Keep this fallback in sync with the workflow_dispatch input
# default above AND with DEFAULT_LANGUAGES in the Python script —
# `push` triggers hit this branch (inputs are empty on push).
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
python .github/scripts/build_i18n_messages.py \
--source AppImage/messages/en/common.json \
--messages-dir AppImage/messages \
--languages "$LANGS" \
--provider googletrans \
$REFRESH_FLAG
- name: Commit + push if changed
run: |
if git diff --quiet -- AppImage/messages/; then
echo "No translation changes — skipping commit."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add AppImage/messages/
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-translation-cache auto-commit) or a manual push can
# land on develop first, and a naked push fails with
# non-fast-forward — losing the freshly generated translation
# commit. Rebasing the local i18n commit on top of the newer
# HEAD is safe: paths don't overlap with cache/script
# workflows, and the same-branch collisions between two i18n
# runs are already blocked by the concurrency group above.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
@@ -0,0 +1,129 @@
name: Build translation cache
# Regenerates lang/*.json whenever a bash script under scripts/ changes.
# The runtime translate() in scripts/utils.sh reads these JSON files and
# falls back to the English source on miss, so keeping them up-to-date is
# what makes ProxMenux multilingual without any runtime googletrans
# dependency on the user's host.
#
# Triggers:
# - push to develop touching scripts/**/*.sh
# - manual via workflow_dispatch (force --refresh)
on:
push:
branches: [develop]
paths:
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'menu'
- 'install_proxmenux.sh'
- 'install_proxmenux_beta.sh'
- 'oci/src/**/*.py'
- 'oci/remote/*.sh'
- 'oci/remote/*.py'
- 'oci/catalog/**/*.json'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-translation-cache.yml'
workflow_dispatch:
inputs:
refresh:
description: 'Re-translate every entry (ignore cached values)'
type: boolean
default: false
# Avoid two cache rebuilds from racing each other on the same branch and
# fighting over the auto-commit.
concurrency:
group: build-translation-cache-${{ github.ref }}
cancel-in-progress: false
jobs:
rebuild-cache:
runs-on: ubuntu-latest
permissions:
contents: write # auto-commit lang/*.json back to develop
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
# Need full history so the auto-commit doesn't fail when the
# cache job runs minutes after the trigger push (GH default
# fetch-depth=1 sometimes diverges from origin/develop after a
# quick follow-up push).
fetch-depth: 0
# Use a PAT (or default GITHUB_TOKEN if branch protections allow
# it) so the push back to develop actually fires later steps
# (workflow runs from auto-commits) if you ever need them.
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install googletrans
run: |
python -m pip install --upgrade pip
# 4.0.0-rc1 is the same pin that build_translation_cache.py
# was written against. Bump in lockstep with the script.
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Regenerate lang/*.json
run: |
REFRESH_FLAG=""
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
REFRESH_FLAG="--refresh"
fi
# Extra files outside scripts/ that also contain translate "..."
# calls. Keep this list in sync with the `paths` trigger above.
python .github/scripts/build_translation_cache.py \
--scripts-dir scripts \
--extra-file menu \
--extra-file install_proxmenux.sh \
--extra-file install_proxmenux_beta.sh \
--extra-dir oci/remote \
--python-dir scripts \
--python-dir oci/src \
--python-dir oci/remote \
--catalog-dir oci/catalog \
--output-dir lang \
--provider googletrans \
$REFRESH_FLAG
- name: Commit + push if changed
run: |
if git diff --quiet -- lang/; then
echo "No translation changes — skipping commit."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add lang/
git commit -m "chore(lang): auto-rebuild translation cache
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
# Rebase-and-retry against develop: between the initial
# checkout and this push another workflow (e.g.
# build-i18n-messages auto-commit) or a manual push can land
# on develop first, and a naked push fails with
# non-fast-forward — losing the freshly built cache commit.
# Paths don't overlap with the other workflows, and
# same-branch collisions between two cache runs are already
# blocked by the concurrency group.
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
echo "push succeeded on attempt ${attempt}"
exit 0
fi
echo "push attempt ${attempt} failed, retrying..."
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
+119
View File
@@ -0,0 +1,119 @@
name: Build web documentation translations
on:
push:
branches: [develop]
paths:
- 'web/messages/en/*.json'
- 'web/messages/en/**/*.json'
- '.github/scripts/build_web_docs_i18n.py'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-web-docs-i18n.yml'
workflow_dispatch:
inputs:
languages:
description: 'Comma-separated locales'
default: 'es,de,fr,it,pt,sk,sv'
section:
description: 'File or directory below web/messages/en'
default: '.'
max_files:
description: 'Maximum pending files per locale; 0 means all'
default: '0'
refresh:
description: 'Overwrite existing translations in the selected scope'
type: boolean
default: false
dry_run:
description: 'Report pending coverage without writing files'
type: boolean
default: false
concurrency:
group: build-web-docs-i18n-${{ github.ref }}
cancel-in-progress: false
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 120
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install translation provider
run: |
python -m pip install --upgrade pip
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Build missing translations
shell: bash
run: |
LANGUAGES="${{ github.event.inputs.languages }}"
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
SECTION="${{ github.event.inputs.section }}"
SECTION="${SECTION:-.}"
MAX_FILES="${{ github.event.inputs.max_files }}"
MAX_FILES="${MAX_FILES:-0}"
EXTRA_ARGS=()
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
EXTRA_ARGS+=(--refresh)
fi
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
EXTRA_ARGS+=(--dry-run)
fi
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "$LANGUAGES" \
--section "$SECTION" \
--max-files "$MAX_FILES" \
--provider googletrans \
--workers 4 \
"${EXTRA_ARGS[@]}"
- name: Validate catalogs
run: |
python .github/scripts/build_web_docs_i18n.py \
--source-dir web/messages/en \
--messages-dir web/messages \
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
--check
- name: Commit and push changes
if: ${{ github.event.inputs.dry_run != 'true' }}
shell: bash
run: |
if git diff --quiet -- web/messages/; then
echo "No documentation translations changed."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add web/messages/
git commit -m "docs(i18n): update documentation translations"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $((attempt * 3))
done
exit 1
+9 -9
View File
@@ -6,7 +6,7 @@ on:
- "main"
paths:
- "web/**"
- "guides/**"
- "lang/**"
- "CHANGELOG.md"
workflow_dispatch:
@@ -24,22 +24,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: 'web/package.json'
cache-dependency-path: 'web/package-lock.json'
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v6
- name: Install dependencies and generate lock file
- name: Install dependencies
run: |
cd web
npm install
npm ci
- name: Build with Next.js
run: |
@@ -47,7 +47,7 @@ jobs:
npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: web/out
@@ -60,4 +60,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
@@ -0,0 +1,25 @@
name: Label OCI validation pull requests
# A pull request that changes verification.json gets the "oci-validation"
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
# never checked out: only its list of changed files is read.
on:
pull_request_target:
types: [opened, reopened, synchronize]
paths:
- 'oci/catalog/verification.json'
permissions:
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.pull_request.number }}
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
@@ -0,0 +1,86 @@
name: Record OCI validation
# When an OCI validation report is validated, the report's author is recorded
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
# regenerated. A report is validated by the "validated" label, which only users
# with write access can add, or by a /validated comment from a user listed in
# .github/oci-validation-reviewers, checked by the script. The issue body and
# the comment are read from the event file, never interpolated into a shell.
on:
issues:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: oci-validation-record
cancel-in-progress: false
jobs:
record:
if: >-
contains(github.event.issue.labels.*.name, 'oci-validation') && (
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/validated')))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Record the report
id: record
run: |
set +e
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: Commit + push
if: steps.record.outputs.code == '0'
env:
APP: ${{ steps.record.outputs.app }}
NUMBER: ${{ github.event.issue.number }}
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add oci/catalog/verification.json oci/VALIDATION.md
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin HEAD:develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
- name: Reply and close
if: steps.record.outputs.code == '0'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
- name: Explain why it was not recorded
if: steps.record.outputs.code == '2'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
if [ "$GITHUB_EVENT_NAME" = issues ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
fi
- name: Fail on an unexpected error
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
run: exit 1
+49
View File
@@ -0,0 +1,49 @@
name: OCI validation record
# verification.json is the record of OCI applications tested for real, and
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
# the community tests credited to its own author.
on:
pull_request:
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
push:
branches: [main, develop]
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check the record and oci/VALIDATION.md
run: python3 .github/scripts/oci_validation.py check
- name: Check who the pull request credits
if: github.event_name == 'pull_request'
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
BASE: ${{ github.event.pull_request.base.sha }}
run: |
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
+27
View File
@@ -0,0 +1,27 @@
name: Update project growth
on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: repo-growth
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: MacRimi/repo-growth@v1
with:
title: ProxMenux growth
output: images/project-growth.svg
history: .github/repo-growth/history.json
metrics: stars,forks,clones
traffic-token: ${{ secrets.REPO_GROWTH_TRAFFIC_TOKEN }}
layout: dashboard
+67
View File
@@ -0,0 +1,67 @@
name: Offline i18n tests
on:
pull_request:
paths:
- '.github/scripts/**'
- '.github/workflows/test-i18n.yml'
- '.github/workflows/build-i18n-messages.yml'
- 'AppImage/messages/**'
- 'AppImage/lib/i18n/**'
- 'AppImage/components/terminal-panel.tsx'
- 'CONTRIBUTING.md'
# Scheduler consumers and the offline extractor's .sh/.func inputs.
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
push:
branches: [main, develop]
paths:
- '.github/scripts/**'
- '.github/workflows/test-i18n.yml'
- '.github/workflows/build-i18n-messages.yml'
- 'AppImage/messages/**'
- 'AppImage/lib/i18n/**'
- 'AppImage/components/terminal-panel.tsx'
- 'CONTRIBUTING.md'
# Scheduler consumers and the offline extractor's .sh/.func inputs.
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
workflow_dispatch:
permissions:
contents: read
jobs:
offline-i18n:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Check scheduler test dependencies
run: |
test -x /bin/bash
test -x /usr/bin/jq
- name: Install OCI wording test dependency
run: python3 -m pip install 'PyYAML>=6,<7'
- name: Run complete offline script test suite
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s .github/scripts/tests -v
@@ -0,0 +1,83 @@
name: Qualified offline tests
on:
pull_request:
paths:
- '.github/workflows/test-offline-qualified.yml'
- '.github/scripts/run_offline_qualified.py'
- '.github/ci/offline-node/**'
- 'CONTRIBUTING.md'
- 'tests/**'
- 'AppImage/package.json'
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
- 'AppImage/lib/**'
- 'AppImage/messages/**'
- 'lang/*.json'
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
push:
branches: [main, develop]
paths:
- '.github/workflows/test-offline-qualified.yml'
- '.github/scripts/run_offline_qualified.py'
- '.github/ci/offline-node/**'
- 'CONTRIBUTING.md'
- 'tests/**'
- 'AppImage/package.json'
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
- 'AppImage/lib/**'
- 'AppImage/messages/**'
- 'lang/*.json'
- 'scripts/**/*.sh'
- 'scripts/**/*.func'
workflow_dispatch:
permissions:
contents: read
jobs:
offline-python:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run eight qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
offline-node:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install only locked fixture dependencies (no lifecycle scripts)
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
- name: Run eleven qualified Node files
env:
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane node
@@ -0,0 +1,164 @@
name: Update App Tracking Hints
on:
# Manual trigger from the Actions UI
workflow_dispatch:
# Re-merge whenever the generator, workflow, or the maintainer-
# curated runtime overrides change. `runtime_verified_overrides.json`
# is the file to edit when a real LXC reveals a canonical path the
# community-scripts helper doesn't ship (legacy /app/package.json,
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
# into the operational catalog every run.
push:
branches: [main]
paths:
- ".github/scripts/generate_app_tracking_catalog.py"
- ".github/workflows/update-app-tracking-hints.yml"
- "json/runtime_verified_overrides.json"
# Regen every 6h — picks up new community-scripts LXC apps and
# detector-relevant script edits without needing a manual trigger.
schedule:
- cron: "0 */6 * * *"
jobs:
update-hints:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The generator writes 4 files; only `.generated.json` is
# consumed downstream by the merge step. The v2 catalog and
# per-app audit are useful for local review but not kept in
# the repo — written under /tmp so they never appear as
# dirty files here.
#
# `--runtime-overrides` folds real-CT evidence into the
# operational hints (canonical paths, cross-method fallbacks
# per app) so the runtime doesn't get fed helper-marker
# false-positives. Modern helper markers are included as their
# official version contract; runtime keeps them distinguishable from
# canonical package/binary/manual detectors for legacy compatibility.
run: |
python .github/scripts/generate_app_tracking_catalog.py \
--helpers-cache json/helpers_cache.json \
--existing json/app_tracking_hints.json \
--runtime-overrides json/runtime_verified_overrides.json \
--include-helper-markers \
--output json/app_tracking_hints.generated.json \
--v2-output /tmp/app_tracking_catalog.v2.json \
--audit-output /tmp/app_tracking_hints.audit.json
- name: 🧬 Smart-merge generated into app_tracking_hints.json
# Single source of truth: `app_tracking_hints.json` is the ONE
# file. It contains 3 kinds of entries:
# 1. Auto-verified from community-scripts (the generator
# manages every "generator-owned" field on these).
# 2. User-edited additions to those entries — extra fields
# the generator doesn't touch (default_ports,
# file_fallbacks, custom logo overrides…).
# 3. User-only entries the generator can't verify (Docker,
# AdGuard, Pi-hole, WireGuard, …) — left alone.
# Merge rule: for slugs the generator produces, refresh only
# the whitelisted fields; preserve everything else. For slugs
# NOT in the generator's output, keep the existing entry
# untouched.
run: |
python - <<'PY'
import json
from pathlib import Path
GEN = Path("json/app_tracking_hints.generated.json")
OUT = Path("json/app_tracking_hints.json")
# Fields owned by the generator — refreshed on every run.
# These are all populated deterministically by the generator
# (the audit script folds `runtime_verified_overrides.json`
# in as it runs), so a local hand-edit for a generator-known
# slug would get overwritten on the next tick. To add a new
# canonical path or a cross-method fallback for a slug the
# generator already knows, edit `runtime_verified_overrides
# .json` — that file IS the maintainer-controlled input.
#
# For user-only slugs (Docker, WireGuard, Pi-hole and any
# other entry not in the generator's output) EVERY field is
# preserved verbatim by the merge below — the whitelist only
# governs generator-covered slugs.
GENERATOR_FIELDS = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "binary_args", "python_path", "distribution",
"container_name", "label", "command_argv", "installed_version",
"repo", "github_source", "tag_regex", "installed_regex",
# Upstream source discriminator + per-type fields
# (http_json + docker_hub). Kept in the whitelist so a
# curated entry in runtime_verified_overrides.json can
# supply them and the smart merge won't drop them on the
# next regeneration.
"upstream_type", "upstream_url", "upstream_json_path",
"docker_image",
"logo", "website",
"default_ports", "file_fallbacks", "alt_detectors",
}
generated = json.loads(GEN.read_text(encoding="utf-8"))
existing = {}
if OUT.is_file():
try:
existing = json.loads(OUT.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except json.JSONDecodeError:
existing = {}
merged = {}
for slug, gen_entry in generated.items():
base = dict(existing.get(slug) or {})
# Refresh generator-owned fields (add/update).
for k, v in gen_entry.items():
if k in GENERATOR_FIELDS:
base[k] = v
# Drop generator-owned fields that the generator no
# longer emits for this slug (e.g. path renamed away).
for k in list(base):
if k in GENERATOR_FIELDS and k not in gen_entry:
del base[k]
merged[slug] = base
# Preserve user-only entries the generator can't verify.
for slug, entry in existing.items():
if slug not in generated and isinstance(entry, dict):
merged[slug] = dict(entry)
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
added = sorted(set(generated) - set(existing))
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
set(e.keys()) - GENERATOR_FIELDS
)})
print(f"merged: {len(merged)} entries "
f"(generated={len(generated)}, existing={len(existing)})")
if added:
print(f" new from generator: {len(added)}")
# Clean up the intermediate file so it doesn't get committed.
GEN.unlink()
PY
- name: 📤 Commit + push if changed
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add json/app_tracking_hints.json
git diff --cached --quiet || git commit -m "Update app tracking hints"
git push
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: ⬇️ Checkout the repository
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: 🐍 Set up Python
uses: actions/setup-python@v4
+21
View File
@@ -6,6 +6,14 @@ web/out/
web/node_modules/
node_modules/
# Local-only — accidental pagefind install at project root.
# Pagefind is declared and installed from web/package.json; the
# CI build (.github/workflows/deploy.yml) only runs
# `cd web && npm install`, so a root-level package.json/lock is
# never consumed and just adds noise. Keep them ignored.
/package.json
/package-lock.json
# Logs
web/*.log
*.log
@@ -33,6 +41,17 @@ Thumbs.db
/web/.next
/web/out
# Search index written by `build`: pagefind --site out produces it into
# public/pagefind/ and the GitHub Pages CI regenerates it on every deploy,
# so committing it would only bloat the repo and add noise to `git status`.
/web/public/pagefind/
# Left ignored so the copy that `sync:scripts` used to generate disappears
# from a working tree that still has it. The docs link to the scripts on
# GitHub, never to a copy served from the site, and pagefind indexes HTML
# only — so nothing read it and the static export was publishing it anyway.
/web/public/scripts/
# Cache
.cache
/web/.cache
@@ -51,3 +70,5 @@ Thumbs.db
!guides/
!web/
# GitHub authentication
.github/auth.sh
Binary file not shown.
@@ -0,0 +1 @@
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
+835
View File
@@ -0,0 +1,835 @@
# ProxMenux Monitor
A modern, responsive dashboard for monitoring Proxmox VE systems built with Next.js and React.
---
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Technology Stack](#technology-stack)
- [Installation](#installation)
- [Authentication & Security](#authentication--security)
- [Setup Authentication](#setup-authentication)
- [Two-Factor Authentication (2FA)](#two-factor-authentication-2fa)
- [Security Best Practices for API Tokens](#security-best-practices-for-api-tokens)
- [API Documentation](#api-documentation)
- [API Authentication](#api-authentication)
- [Generating API Tokens](#generating-api-tokens)
- [Available Endpoints](#available-endpoints)
- [Integration Examples](#integration-examples)
- [Homepage Integration](#homepage-integration)
- [Home Assistant Integration](#home-assistant-integration)
- [License](#license)
---
## Overview
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, backups, health status and system logs — all from a single browser tab.
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
**Full documentation:** [proxmenux.com/docs/monitor](https://proxmenux.com/docs/monitor) — per-feature walkthroughs, API reference and integration guides.
## Screenshots
A tour of the Monitor's main areas. See the [public documentation](https://proxmenux.com/docs/monitor) for the full walkthrough.
### Dashboard overview
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/dashboard-home.png" alt="Dashboard home" width="900"/>
<br/>
<em>Real-time CPU, memory, temperature, storage and network activity on one glance-optimised page.</em>
</p>
### Backup & Restore
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/images/docs/backup-restore/scheduled-backup-monitor.png" alt="Scheduled backup jobs" width="900"/>
<br/>
<em>Integrated host backup & restore — Local, PBS or Borg destinations; own timer or attached to a PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore.</em>
</p>
### Storage & SMART
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/storage-top-row.png" alt="Storage top row" width="900"/>
<br/>
<em>Per-disk cards with capacity palette shared across Storage and Backups. USB-NVMe enclosures (ASMedia / JMicron / Realtek) show the drive's real identity and temperature, not the bridge.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/disk-modal-smart.png" alt="Disk SMART modal" width="900"/>
<br/>
<em>Per-disk detail modal — SMART attributes, temperature history, observations and downloadable PDF report.</em>
</p>
### Network — live topology
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-flow-overview.png" alt="Network Flow diagram" width="900"/>
<br/>
<em>Network Flow — live view of NICs → host → bridges → guests with animated rx / tx pulses on every link. Bridges without active guests are hidden.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-latency-historical.png" alt="Network latency historical" width="900"/>
<br/>
<em>Latency modal — historical view and real-time ping test against Gateway / Cloudflare / Google, with a downloadable PDF report.</em>
</p>
### Virtual machines & LXCs
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-top-row.png" alt="VMs & LXCs overview" width="900"/>
<br/>
<em>Inventory of running VMs and containers with resource usage and per-guest controls.</em>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-modal-status.png" alt="VM detail modal — status" width="900"/>
<br/>
<em>Per-guest modal with status, backups, mounts and (for LXC) apt / apk / community-scripts update inventory.</em>
</p>
### Health Monitor
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/health-monitor.png" alt="Health Monitor" width="900"/>
<br/>
<em>Ten categories of proactive health checks with hysteresis, per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke.</em>
</p>
### System overview — top processes
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/system-overview-top-processes.png" alt="Top processes" width="900"/>
<br/>
<em>Per-process CPU / memory / I/O sorted by consumer, with a click-through detail modal.</em>
</p>
### Mobile
<p align="center">
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/mobile-home.png" alt="Mobile responsive layout" width="360"/>
<br/>
<em>Fully responsive — every panel adapts to phone and tablet layouts.</em>
</p>
---
## Features
- **Host Backup & Restore** — integrated section covering Local, PBS and Borg destinations; schedule with a systemd timer or attach to an existing PVE vzdump job; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore with kernel-agnostic hydration (IOMMU / VFIO / GRUB), cascade-safe package replay, and NIC auto-remap by MAC after a motherboard swap
- **System Overview** — real-time CPU / memory / temperature / uptime with a per-process drill-down (Top Processes view) and click-through detail modal
- **Storage & SMART** — per-disk cards in a responsive grid; temperature history, SMART attributes and a downloadable PDF SMART report per drive. USB-NVMe bridges (ASMedia / JMicron / Realtek) show the real drive's identity and temperature instead of the enclosure's
- **Network** — live topology (Network Flow) with animated rx / tx pulses, per-interface RRD charts, latency modal against Gateway / Cloudflare / Google with a downloadable PDF report
- **Virtual Machines & LXCs** — inventory, per-guest metrics and controls, per-LXC update inventory (apt / apk / community-scripts), backup state, mount inspection
- **Hardware** — CPU / GPU / PCIe / disks / NICs, with correct SSD vs HDD classification even behind USB-SATA bridges
- **System Logs** — journalctl with severity / since / free-text filters and a download-as-text action
- **Health Monitor** — ten categories of proactive checks with hysteresis and configurable thresholds; per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke
- **Notifications** — five channels (Telegram, Discord, Gotify, Email, Apprise for ~80 endpoints), per-event toggles, Quiet Hours, Daily Digest, optional AI enrichment (Groq / OpenAI / Ollama / Gemini / Anthropic / OpenRouter)
- **Authentication & 2FA** — optional password protection with TOTP-based two-factor authentication and long-lived API tokens (365 days) for integrations
- **RESTful API** — complete access with JWT auth; ready-made recipes for Homepage, Home Assistant and custom dashboards; Prometheus scrape endpoint
- **Reports (PDF)** — SMART report per-disk and Network Latency report per-target, ready to send to a vendor or an ISP
- **Dark / Light Theme** — Proxmox-inspired palette with live switch
- **Responsive Design** — desktop, tablet and mobile layouts
- **Release Notes** — automatic notification of new features on each Monitor upgrade
## Technology Stack
- **Frontend**: Next.js 15, React 19, TypeScript
- **Styling**: Tailwind CSS v4 with custom Proxmox-inspired theme
- **Charts**: Recharts for data visualization
- **UI Components**: Radix UI primitives with shadcn/ui
- **Backend**: Flask (Python) server for system data collection
- **Packaging**: AppImage for easy distribution and deployment
## Installation
**ProxMenux Monitor is integrated into [ProxMenux](https://proxmenux.com) and comes enabled by default.** No manual installation is required if you're using ProxMenux.
The monitor automatically starts when ProxMenux is installed and runs as a systemd service on your Proxmox server.
### Accessing the Dashboard
You can access ProxMenux Monitor in two ways:
1. **Direct Access**: `http://your-proxmox-ip:8008`
2. **Via Proxy** (Recommended): `https://your-domain.com/proxmenux-monitor/`
**Note**: All API endpoints work seamlessly with both direct access and proxy configurations. When using a reverse proxy, the application automatically detects and adapts to the proxied environment.
### Proxy Configuration
ProxMenux Monitor includes built-in support for reverse proxy configurations. If you're using Nginx, Caddy, or Traefik, the application will automatically:
- Detect the proxy headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`)
- Adjust API endpoints to work correctly through the proxy
- Maintain full functionality for all features including authentication and API access
## Authentication & Security
ProxMenux Monitor includes an optional authentication system to protect your dashboard with a password and two-factor authentication.
### Setup Authentication
On first launch, you'll be presented with three options:
1. **Set up authentication** - Create a username and password to protect your dashboard
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
3. **Skip** - Continue without authentication (not recommended for production environments)
![Authentication Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/auth-setup.png)
### Two-Factor Authentication (2FA)
After setting up your password, you can enable 2FA using any TOTP authenticator app (Google Authenticator, Authy, 1Password, etc.):
1. Navigate to **Settings > Authentication**
2. Click **Enable 2FA**
3. Scan the QR code with your authenticator app
4. Enter the 6-digit code to verify
5. Save your backup codes in a secure location
![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png)
### Embedding in Trusted Iframes
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
```bash
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
```
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
### Security Best Practices for API Tokens
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
**Option 1: Environment Variables**
Store your token in an environment variable:
```bash
# Linux/macOS - Add to ~/.bashrc or ~/.zshrc
export PROXMENUX_API_TOKEN="your_actual_token_here"
# Windows PowerShell - Add to profile
$env:PROXMENUX_API_TOKEN = "your_actual_token_here"
```
Then reference it in your scripts:
```bash
# Linux/macOS
curl -H "Authorization: Bearer $PROXMENUX_API_TOKEN" \
http://your-proxmox-ip:8008/api/system
# Windows PowerShell
curl -H "Authorization: Bearer $env:PROXMENUX_API_TOKEN" `
http://your-proxmox-ip:8008/api/system
```
**Option 2: Secrets File**
Create a dedicated secrets file (make sure to add it to `.gitignore`):
```bash
# Create secrets file
echo "PROXMENUX_API_TOKEN=your_actual_token_here" > ~/.proxmenux_secrets
# Secure the file (Linux/macOS only)
chmod 600 ~/.proxmenux_secrets
# Load in your script
source ~/.proxmenux_secrets
```
**Option 3: Homepage Secrets (Recommended)**
Homepage supports secrets management. Create a `secrets.yaml` file:
```yaml
# secrets.yaml (add to .gitignore!)
proxmenux_token: "your_actual_token_here"
```
Then reference it in your `services.yaml`:
```yaml
- ProxMenux Monitor:
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/system
headers:
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
```
**Option 4: Home Assistant Secrets**
Home Assistant has built-in secrets support. Edit `secrets.yaml`:
```yaml
# secrets.yaml
proxmenux_api_token: "your_actual_token_here"
```
Then reference it in `configuration.yaml`:
```yaml
sensor:
- platform: rest
name: ProxMenux CPU
resource: http://proxmox.example.tld:8008/api/system
headers:
Authorization: !secret proxmenux_api_token
```
**Token Security Checklist:**
- ✅ Store tokens in environment variables or secrets files
- ✅ Add secrets files to `.gitignore`
- ✅ Set proper file permissions (chmod 600 on Linux/macOS)
- ✅ Rotate tokens periodically (every 3-6 months)
- ✅ Use different tokens for different integrations
- ✅ Delete tokens you no longer use
- ❌ Never commit tokens to version control
- ❌ Never share tokens in screenshots or logs
- ❌ Never hardcode tokens in configuration files
---
## API Documentation
ProxMenux Monitor provides a comprehensive RESTful API for integrating with external services like Homepage, Home Assistant, or custom dashboards.
### API Authentication
When authentication is enabled on ProxMenux Monitor, all API endpoints (except `/api/health` and `/api/auth/*`) require a valid JWT token in the `Authorization` header.
### API Endpoint Base URL
**Direct Access:**
```
http://your-proxmox-ip:8008/api/
```
**Via Proxy:**
```
https://your-domain.com/proxmenux-monitor/api/
```
**Note**: All API examples in this documentation work with both direct and proxied URLs. Simply replace the base URL with your preferred access method.
### Generating API Tokens
To use the API with authentication enabled, you need to generate a long-lived API token.
#### Option 1: Generate via Web Panel (Recommended)
The easiest way to generate an API token is through the ProxMenux Monitor web interface:
1. Navigate to **Settings** tab in the dashboard
2. Scroll to the **API Access Tokens** section
3. Enter your password
4. If 2FA is enabled, enter your 6-digit code
5. Provide a name for the token (e.g., "Homepage Integration")
6. Click **Generate Token**
7. Copy the token immediately - it will not be shown again
![Generate API Token](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/api-tokens.png)
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
#### Option 2: Generate via API Call
For advanced users or automation, you can generate tokens programmatically:
```bash
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
-H "Content-Type: application/json" \
-d '{
"username": "your-username",
"password": "your-password",
"totp_token": "123456",
"token_name": "Homepage Integration"
}'
```
**Response:**
```json
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_name": "Homepage Integration",
"expires_in": "365 days",
"message": "API token generated successfully. Store this token securely, it will not be shown again."
}
```
**Notes:**
- If 2FA is enabled, include the `totp_token` field with your 6-digit code
- If 2FA is not enabled, omit the `totp_token` field
- The token is valid for **365 days** (1 year)
- Store the token securely - it cannot be retrieved again
#### Option 3: Generate via cURL (without 2FA)
```bash
# Without 2FA
curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \
-H "Content-Type: application/json" \
-d '{"username":"pedro","password":"your-password","token_name":"Homepage"}'
```
### Using API Tokens
Once you have your API token, include it in the `Authorization` header of all API requests:
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN_HERE" \
http://your-proxmox-ip:8008/api/system
```
---
### Available Endpoints
Below is a complete list of all API endpoints with descriptions and example responses.
#### System & Metrics
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/system` | GET | Yes | Complete system information (CPU, memory, temperature, uptime) |
| `/api/system-info` | GET | No | Lightweight system info for header (hostname, uptime, health) |
| `/api/node/metrics` | GET | Yes | Historical metrics data (RRD) for CPU, memory, disk I/O |
| `/api/prometheus` | GET | Yes | Export metrics in Prometheus format |
**Example `/api/system` Response:**
```json
{
"hostname": "pve",
"cpu_usage": 15.2,
"memory_usage": 45.8,
"temperature": 42.5,
"uptime": 345600,
"kernel": "6.2.16-3-pve",
"pve_version": "8.0.3"
}
```
#### Storage
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/storage` | GET | Yes | Complete storage information with SMART data |
| `/api/storage/summary` | GET | Yes | Optimized storage summary (without SMART) |
| `/api/proxmox-storage` | GET | Yes | Proxmox storage pools information |
| `/api/backups` | GET | Yes | List of all backup files |
**Example `/api/storage/summary` Response:**
```json
{
"total_capacity": 1431894917120,
"used_space": 197414092800,
"free_space": 1234480824320,
"usage_percentage": 13.8,
"disks": [
{
"device": "/dev/sda",
"model": "Samsung SSD 970",
"size": "476.94 GB",
"type": "SSD"
}
]
}
```
#### Network
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/network` | GET | Yes | Complete network information for all interfaces |
| `/api/network/summary` | GET | Yes | Optimized network summary |
| `/api/network/<interface>/metrics` | GET | Yes | Historical metrics (RRD) for specific interface |
**Example `/api/network/summary` Response:**
```json
{
"interfaces": [
{
"name": "vmbr0",
"ip": "192.168.1.100",
"state": "up",
"rx_bytes": 1234567890,
"tx_bytes": 987654321
}
]
}
```
#### Virtual Machines & Containers
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/vms` | GET | Yes | List of all VMs and LXC containers |
| `/api/vms/<vmid>` | GET | Yes | Detailed configuration for specific VM/LXC |
| `/api/vms/<vmid>/metrics` | GET | Yes | Historical metrics (RRD) for specific VM/LXC |
| `/api/vms/<vmid>/logs` | GET | Yes | Download real logs for specific VM/LXC |
| `/api/vms/<vmid>/control` | POST | Yes | Control VM/LXC (start, stop, shutdown, reboot) |
| `/api/vms/<vmid>/config` | PUT | Yes | Update VM/LXC configuration (description/notes) |
**Example `/api/vms` Response:**
```json
{
"vms": [
{
"vmid": "100",
"name": "ubuntu-server",
"type": "qemu",
"status": "running",
"cpu": 2,
"maxcpu": 4,
"mem": 2147483648,
"maxmem": 4294967296,
"uptime": 86400
}
]
}
```
#### Hardware
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/hardware` | GET | Yes | Complete hardware information (CPU, GPU, PCIe, disks) |
| `/api/gpu/<slot>/realtime` | GET | Yes | Real-time monitoring for specific GPU |
**Example `/api/hardware` Response:**
```json
{
"cpu": {
"model": "AMD Ryzen 9 5950X",
"cores": 16,
"threads": 32,
"frequency": "3.4 GHz"
},
"gpus": [
{
"slot": "0000:01:00.0",
"vendor": "NVIDIA",
"model": "GeForce RTX 3080",
"driver": "nvidia"
}
]
}
```
#### Logs, Events & Notifications
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/logs` | GET | Yes | System logs (journalctl) with filters |
| `/api/logs/download` | GET | Yes | Download logs as text file |
| `/api/notifications` | GET | Yes | Proxmox notification history |
| `/api/notifications/download` | GET | Yes | Download full notification log |
| `/api/events` | GET | Yes | Recent Proxmox tasks and events |
| `/api/task-log/<upid>` | GET | Yes | Full log for specific task using UPID |
**Example `/api/logs` Query Parameters:**
```
/api/logs?severity=error&since=1h&search=failed
```
#### Health Monitoring
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/health` | GET | No | Basic health check (for external monitoring) |
| `/api/health/status` | GET | Yes | Summary of system health status |
| `/api/health/details` | GET | Yes | Detailed health check results |
| `/api/health/acknowledge` | POST | Yes | Dismiss/acknowledge health warnings |
| `/api/health/active-errors` | GET | Yes | Get active persistent errors |
#### ProxMenux Optimizations
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/proxmenux/installed-tools` | GET | Yes | List of installed ProxMenux optimizations |
#### Authentication
| Endpoint | Method | Auth Required | Description |
|----------|--------|---------------|-------------|
| `/api/auth/status` | GET | No | Current authentication status |
| `/api/auth/login` | POST | No | Authenticate and receive JWT token |
| `/api/auth/generate-api-token` | POST | No | Generate long-lived API token (365 days) |
| `/api/auth/setup` | POST | No | Initial setup of username/password |
| `/api/auth/enable` | POST | No | Enable authentication |
| `/api/auth/disable` | POST | Yes | Disable authentication |
| `/api/auth/change-password` | POST | No | Change password |
| `/api/auth/totp/setup` | POST | Yes | Initialize 2FA setup |
| `/api/auth/totp/enable` | POST | Yes | Enable 2FA after verification |
| `/api/auth/totp/disable` | POST | Yes | Disable 2FA |
---
## Integration Examples
### Homepage Integration
[Homepage](https://gethomepage.dev/) is a modern, fully static, fast, secure fully proxied, highly customizable application dashboard.
#### Basic Configuration (No Authentication)
```yaml
- ProxMenux Monitor:
href: http://proxmox.example.tld:8008/
icon: lucide:flask-round
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/system
refreshInterval: 10000
mappings:
- field: uptime
label: Uptime
icon: lucide:clock-4
format: text
- field: cpu_usage
label: CPU
icon: lucide:cpu
format: percent
- field: memory_usage
label: RAM
icon: lucide:memory-stick
format: percent
- field: temperature
label: Temp
icon: lucide:thermometer-sun
format: number
suffix: °C
```
#### With Authentication Enabled (Using Secrets)
First, generate an API token via the web interface (Settings > API Access Tokens) or via API.
Then, store your token securely in Homepage's `secrets.yaml`:
```yaml
# secrets.yaml (add to .gitignore!)
proxmenux_token: "your_actual_api_token_here"
```
Finally, reference the secret in your `services.yaml`:
```yaml
- ProxMenux Monitor:
href: http://proxmox.example.tld:8008/
icon: lucide:flask-round
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/system
headers:
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
refreshInterval: 10000
mappings:
- field: uptime
label: Uptime
icon: lucide:clock-4
format: text
- field: cpu_usage
label: CPU
icon: lucide:cpu
format: percent
- field: memory_usage
label: RAM
icon: lucide:memory-stick
format: percent
- field: temperature
label: Temp
icon: lucide:thermometer-sun
format: number
suffix: °C
```
#### Advanced Multi-Widget Configuration
```yaml
# Store token in secrets.yaml
# proxmenux_token: "your_actual_api_token_here"
- ProxMenux System:
href: http://proxmox.example.tld:8008/
icon: lucide:server
description: Proxmox VE Host
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/system
headers:
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
refreshInterval: 5000
mappings:
- field: cpu_usage
label: CPU
icon: lucide:cpu
format: percent
- field: memory_usage
label: RAM
icon: lucide:memory-stick
format: percent
- field: temperature
label: Temp
icon: lucide:thermometer-sun
format: number
suffix: °C
- ProxMenux Storage:
href: http://proxmox.example.tld:8008/#/storage
icon: lucide:hard-drive
description: Storage Overview
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/storage/summary
headers:
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
refreshInterval: 30000
mappings:
- field: usage_percentage
label: Used
icon: lucide:database
format: percent
- field: used_space
label: Space
icon: lucide:folder
format: bytes
- ProxMenux Network:
href: http://proxmox.example.tld:8008/#/network
icon: lucide:network
description: Network Stats
widget:
type: customapi
url: http://proxmox.example.tld:8008/api/network/summary
headers:
Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}}
refreshInterval: 5000
mappings:
- field: interfaces[0].rx_bytes
label: Received
icon: lucide:download
format: bytes
- field: interfaces[0].tx_bytes
label: Sent
icon: lucide:upload
format: bytes
```
### Home Assistant Integration
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
#### Store Token Securely
First, add your API token to Home Assistant's `secrets.yaml`:
```yaml
# secrets.yaml
proxmenux_api_token: "Bearer your_actual_api_token_here"
```
**Note**: Include "Bearer " prefix in the secrets file for Home Assistant.
#### Configuration.yaml
```yaml
# ProxMenux Monitor Sensors
sensor:
- platform: rest
name: ProxMenux CPU
resource: http://proxmox.example.tld:8008/api/system
headers:
Authorization: !secret proxmenux_api_token
value_template: "{{ value_json.cpu_usage }}"
unit_of_measurement: "%"
scan_interval: 30
- platform: rest
name: ProxMenux Memory
resource: http://proxmox.example.tld:8008/api/system
headers:
Authorization: !secret proxmenux_api_token
value_template: "{{ value_json.memory_usage }}"
unit_of_measurement: "%"
scan_interval: 30
- platform: rest
name: ProxMenux Temperature
resource: http://proxmox.example.tld:8008/api/system
headers:
Authorization: !secret proxmenux_api_token
value_template: "{{ value_json.temperature }}"
unit_of_measurement: "°C"
device_class: temperature
scan_interval: 30
- platform: rest
name: ProxMenux Uptime
resource: http://proxmox.example.tld:8008/api/system
headers:
Authorization: !secret proxmenux_api_token
value_template: >
{% set uptime_seconds = value_json.uptime | int %}
{% set days = (uptime_seconds / 86400) | int %}
{% set hours = ((uptime_seconds % 86400) / 3600) | int %}
{% set minutes = ((uptime_seconds % 3600) / 60) | int %}
{{ days }}d {{ hours }}h {{ minutes }}m
scan_interval: 60
```
#### Lovelace Card Example
```yaml
type: entities
title: Proxmox Monitor
entities:
- entity: sensor.proxmenux_cpu
name: CPU Usage
icon: mdi:cpu-64-bit
- entity: sensor.proxmenux_memory
name: Memory Usage
icon: mdi:memory
- entity: sensor.proxmenux_temperature
name: Temperature
icon: mdi:thermometer
- entity: sensor.proxmenux_uptime
name: Uptime
icon: mdi:clock-outline
```
---
## License
This project is licensed under the **GNU General Public License, version 3 (GPL-3.0)**.
You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root.
---
**ProxMenux Monitor** - Made with ❤️ for the Proxmox community
+9
View File
@@ -0,0 +1,9 @@
import { ProxmoxDashboard } from "../../components/proxmox-dashboard"
export default function DashboardPage() {
return (
<main className="min-h-screen bg-background">
<ProxmoxDashboard />
</main>
)
}
+177
View File
@@ -0,0 +1,177 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ===================== */
/* Light Mode (default) */
/* ===================== */
:root {
--background: oklch(1 0 0); /* blanco */
--foreground: oklch(0.145 0 0); /* casi negro */
--card: oklch(1 0 0);
--card-foreground: var(--foreground);
--popover: var(--card);
--popover-foreground: var(--foreground);
--primary: oklch(0.205 0 0); /* gris oscuro */
--primary-foreground: oklch(0.985 0 0); /* blanco */
--secondary: oklch(0.97 0 0);
--secondary-foreground: var(--primary);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); /* gris medio */
--accent: oklch(0.97 0 0);
--accent-foreground: var(--primary);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.145 0 0);
--border: oklch(0.922 0 0);
--input: var(--border);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: var(--primary);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
/* ===================== */
/* Dark Mode (gris) */
/* ===================== */
.dark {
--background: oklch(0.22 0 0); /* gris oscuro */
--foreground: oklch(0.97 0 0); /* blanco/gris claro */
--card: oklch(0.24 0 0);
--card-foreground: var(--foreground);
--popover: var(--card);
--popover-foreground: var(--foreground);
--primary: oklch(0.83 0 0); /* casi blanco */
--primary-foreground: var(--background);
--secondary: oklch(0.28 0 0);
--secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.26 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.28 0 0);
--accent-foreground: var(--primary);
--destructive: oklch(0.53 0.25 27);
--destructive-foreground: oklch(0.9 0 0);
--border: oklch(0.34 0 0);
--input: var(--border);
--ring: oklch(0.55 0 0);
--chart-1: oklch(0.60 0.20 255);
--chart-2: oklch(0.70 0.16 165);
--chart-3: oklch(0.76 0.19 70);
--chart-4: oklch(0.63 0.25 305);
--chart-5: oklch(0.66 0.24 20);
--sidebar: oklch(0.24 0 0);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--chart-1);
--sidebar-primary-foreground: var(--foreground);
--sidebar-accent: oklch(0.28 0 0);
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
/* ===================== */
/* Base layer */
/* ===================== */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* Foco accesible */
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus {
@apply outline-none;
}
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus-visible {
@apply ring-2;
--tw-ring-color: var(--ring);
--tw-ring-opacity: 0.5; /* equivalente al /50 */
}
}
/* ===================== */
/* Ajustes para Charts */
/* ===================== */
@layer components {
/* Recharts axis */
.recharts-cartesian-axis-tick tspan {
fill: var(--muted-foreground);
}
.recharts-cartesian-axis-line,
.recharts-cartesian-grid line {
stroke: var(--border);
}
/* Chart.js axis */
.chartjs-render-monitor text {
fill: var(--muted-foreground);
}
.chartjs-render-monitor line {
stroke: var(--border);
}
}
/* ===================== */
/* Ajustes para xterm.js */
/* ===================== */
/* Quitar padding para que la terminal ocupe el 100% del ancho */
.xterm {
padding: 0 !important;
}
/* Por si acaso el viewport añade padding extra */
.xterm .xterm-viewport {
padding: 0 !important;
}
/* Opcional: asegurar que no haya margen raro */
.xterm-rows {
margin: 0 !important;
}
/* ===================== */
/* Progress Animations */
/* ===================== */
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
+5
View File
@@ -0,0 +1,5 @@
import Hardware from "@/components/hardware"
export default function HardwarePage() {
return <Hardware />
}
+59
View File
@@ -0,0 +1,59 @@
import type React from "react"
import type { Metadata, Viewport } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "../components/theme-provider"
import { PwaRegister } from "../components/pwa-register"
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
import { I18nProvider } from "../lib/i18n/provider"
import { Suspense } from "react"
import "./globals.css"
export const metadata: Metadata = {
title: "ProxMenux Monitor",
description: "Proxmox System Dashboard and Monitor",
generator: "v0.app",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon.png", type: "image/png", sizes: "32x32" },
],
shortcut: "/favicon.ico",
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#2b2f36" },
],
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
<Suspense fallback={null}>
<I18nProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
<PwaInstallPrompt />
</I18nProvider>
</Suspense>
<PwaRegister />
</body>
</html>
)
}
+136
View File
@@ -0,0 +1,136 @@
"use client"
import { useState, useEffect } from "react"
import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export default function Home() {
const t = useT()
const [authStatus, setAuthStatus] = useState<{
loading: boolean
authEnabled: boolean
authConfigured: boolean
authenticated: boolean
}>({
loading: true,
authEnabled: false,
authConfigured: false,
authenticated: false,
})
useEffect(() => {
checkAuthStatus()
}, [])
const checkAuthStatus = async () => {
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/status"), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
// 401 here means the token is present but invalid — typically signed
// under a previous jwt_secret (rotated on AppImage upgrade or fresh
// install). If we let this fall into the catch below, the dashboard
// would render and every authenticated component would fire its own
// 401 in parallel, flooding the backend logs and looping reloads.
// Drop the dead token and force the Login screen instead.
if (response.status === 401) {
try {
localStorage.removeItem("proxmenux-auth-token")
} catch {
// private browsing — best-effort
}
setAuthStatus({
loading: false,
authEnabled: true,
authConfigured: true,
authenticated: false,
})
return
}
// Check if response is valid JSON before parsing
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Response is not JSON")
}
const data = await response.json()
const authenticated = data.auth_enabled ? data.authenticated : true
// Clear the 401 cascade-prevention flag when we successfully end
// up in the authenticated state. The flag is meant to dedupe a
// burst of 401s during a single page load; once we've confirmed
// the user is in, a future 401 (token rotation, restart, etc.)
// should be allowed to reload again. Without this, a stale flag
// can prevent the post-2FA dashboard from recovering from any
// transient 401 and leaves the UI blocked.
if (authenticated) {
try {
sessionStorage.removeItem("proxmenux-auth-401-handled")
} catch {
// private browsing — best-effort
}
}
setAuthStatus({
loading: false,
authEnabled: data.auth_enabled,
authConfigured: data.auth_configured,
authenticated,
})
} catch {
// API not available - assume no auth configured (silent fail, no console error)
setAuthStatus({
loading: false,
authEnabled: false,
authConfigured: false,
authenticated: true,
})
}
}
const handleAuthComplete = () => {
checkAuthStatus()
}
const handleLoginSuccess = () => {
checkAuthStatus()
}
if (authStatus.loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
</div>
</div>
)
}
if (authStatus.authEnabled && !authStatus.authenticated) {
return <Login onLogin={handleLoginSuccess} />
}
// Show dashboard in all other cases
return (
<>
{!authStatus.authConfigured && <AuthSetup onComplete={handleAuthComplete} />}
<ProxmoxDashboard />
</>
)
}
+234
View File
@@ -0,0 +1,234 @@
"use client"
import Image from "next/image"
import {
Github,
Heart,
BookOpen,
MessageSquare,
Bug,
Sparkles,
Scale,
ExternalLink,
} from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { APP_VERSION } from "./release-notes-modal"
import { useT } from "../lib/i18n/provider"
// Issue #191: a dedicated About tab. Centralises project metadata
// (version, license, author) and every external link the project
// already exposes — GitHub, docs, donation. Replaces the lone
// "Support and contribute to the project" footer link with a proper
// information surface that's easy to extend with new social channels
// without re-cluttering the dashboard footer.
interface LinkRow {
labelKey: string
descriptionKey: string
href: string
Icon: React.ComponentType<{ className?: string }>
accent?: keyof typeof ACCENT_CLASSES
}
// Tailwind only emits classes that appear as literal strings in the
// source. A dynamic `bg-${accent}/10` template does not survive the
// purge step, so each accent maps to a fully-spelled class pair below.
const ACCENT_CLASSES = {
gray: "bg-gray-500/10 text-gray-400",
blue: "bg-blue-500/10 text-blue-500",
purple: "bg-purple-500/10 text-purple-400",
red: "bg-red-500/10 text-red-500",
pink: "bg-pink-500/10 text-pink-500",
} as const
const PROJECT_LINKS: LinkRow[] = [
{
labelKey: "about.links.repository.label",
descriptionKey: "about.links.repository.description",
href: "https://github.com/MacRimi/ProxMenux",
Icon: Github,
accent: "gray",
},
{
labelKey: "about.links.documentation.label",
descriptionKey: "about.links.documentation.description",
href: "https://proxmenux.com",
Icon: BookOpen,
accent: "blue",
},
{
labelKey: "about.links.discussions.label",
descriptionKey: "about.links.discussions.description",
href: "https://github.com/MacRimi/ProxMenux/discussions",
Icon: MessageSquare,
accent: "purple",
},
{
labelKey: "about.links.issues.label",
descriptionKey: "about.links.issues.description",
href: "https://github.com/MacRimi/ProxMenux/issues",
Icon: Bug,
accent: "red",
},
]
const SUPPORT_LINKS: LinkRow[] = [
{
labelKey: "about.links.support.label",
descriptionKey: "about.links.support.description",
href: "https://ko-fi.com/macrimi",
Icon: Heart,
accent: "pink",
},
]
function LinkCard({ row }: { row: LinkRow }) {
const t = useT()
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
// translucent background by default, slightly lighter on hover, no
// accent-coloured borders or text colour changes — keeps the look
// consistent with the rest of the project.
return (
<a
href={row.href}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span
className={`inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md ${accentClass}`}
>
<row.Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{t(row.labelKey)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
</div>
</a>
)
}
export function About() {
const t = useT()
return (
<div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */}
<Card>
<CardContent className="pt-6 pb-6">
<div className="flex flex-col md:flex-row items-center md:items-start gap-4 md:gap-6">
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
<Image
src="/images/proxmenux-logo.png"
alt={t("about.logoAlt")}
fill
priority
className="object-contain"
/>
</div>
<div className="text-center md:text-left flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-semibold text-foreground">
ProxMenux Monitor
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("about.heroDescription")}
</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
<Sparkles className="h-3 w-3" />
v{APP_VERSION}
</span>
{/* Beta versions surface their pre-release notes on the
GitHub Releases page (where each beta is tagged + signed);
stable versions point at the canonical web changelog
which only carries shipped releases. Detection: the
APP_VERSION string carries a "-beta" / "-rc" /
"-alpha" suffix for any non-stable build. */}
{(() => {
const isPrerelease = /-(beta|rc|alpha)/i.test(APP_VERSION)
const href = isPrerelease
? "https://github.com/MacRimi/ProxMenux/releases"
: "https://proxmenux.com/en/changelog"
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
>
{label}
<ExternalLink className="h-3 w-3" />
</a>
)
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Project links — GitHub, docs, discussions, bug tracker. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" />
{t("about.project.title")}
</CardTitle>
<CardDescription>{t("about.project.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{PROJECT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
</div>
</CardContent>
</Card>
{/* Support + License combined — donation link and licensing
info in one card. The previous layout had a separate "Author"
block that has been removed by request. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" />
{t("about.support.title")}
</CardTitle>
<CardDescription>
{t("about.support.description")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
{SUPPORT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-500/10 text-gray-400">
<Scale className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{t("about.license.label")}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{t("about.license.description")}
</p>
</div>
</a>
</div>
</CardContent>
</Card>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
"use client"
import { useId, useState } from "react"
import { Info, ExternalLink, Check, Loader2, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea"
import { Label } from "./ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
export type AppUpdateMethod = "none" | "helper" | "custom"
export function AppUpdaterEditor({ method, command, helperAvailable, helperSlug, configured, saving, changed,
onMethodChange, onCommandChange, onSave, onCancel, onRemove }: {
method: AppUpdateMethod
command: string
helperAvailable: boolean
helperSlug?: string
configured: boolean
saving: boolean
changed: boolean
onMethodChange: (method: AppUpdateMethod) => void
onCommandChange: (command: string) => void
onSave: () => void
onCancel: () => void
onRemove: () => void
}) {
const t = useT()
const commandId = useId()
const [help, setHelp] = useState<"helper" | "custom" | null>(null)
const scriptUrl = helperSlug && /^[a-z0-9][a-z0-9._-]*$/.test(helperSlug)
? `https://github.com/community-scripts/ProxmoxVE/blob/main/ct/${helperSlug}.sh` : null
// Keep the editable command readable. Download guards belong to the runner,
// which also protects this literal launcher when saved as a custom command.
const helperCommand = scriptUrl
? `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${helperSlug}.sh)"`
: null
const valid = method === "helper" ? helperAvailable && !!helperCommand : method === "custom" && !!command.trim()
const displayedCommand = method === "helper" ? (helperCommand || "") : command
const editCommand = (value: string) => {
// Edited launchers belong to the existing custom-command execution path.
// Never leave the method as "helper": saving it would discard the edits.
if (method === "helper") {
if (value.trim() === (helperCommand || "").trim()) return
onMethodChange("custom")
}
onCommandChange(value)
}
const customExamples = [
{ title: "customScriptTitle", description: "customScriptDescription", command: "/opt/my-app/update.sh" },
{ title: "customPackageTitle", description: "customPackageDescription", command: "apt-get update &&\napt-get install -y --only-upgrade my-package" },
{ title: "customBinaryTitle", description: "customBinaryDescription", command: "install -b -m 0755 /tmp/my-app.new /opt/my-app/my-app &&\nsystemctl restart my-app" },
]
const onlineExampleCommand = `script=$(mktemp) || exit 1
trap 'rm -f "$script"' EXIT
curl -fsSL 'https://example.com/my-app/update.sh' -o "$script" &&
bash "$script"`
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.updaterChoiceHint")}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={t("vmLxc.updates.updaterMethodLabel")}>
{([...(helperAvailable ? ["helper" as const] : []), "custom" as const]).map((choice) => (
<div key={choice} className="flex items-center gap-1">
<Button type="button" size="sm" variant={method === choice ? "default" : "outline"}
aria-pressed={method === choice} disabled={saving} onClick={() => onMethodChange(choice)}>
{t(`vmLxc.updates.${choice === "helper" ? "helperMethod" : "customMethod"}`)}
</Button>
<Button type="button" size="icon" variant="ghost" className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400" onClick={() => setHelp(choice)}
aria-label={t(`vmLxc.updates.${choice === "helper" ? "helperMethodHelp" : "customMethodHelp"}`)}>
<Info className="h-4 w-4" />
</Button>
</div>
))}
</div>
{helperAvailable && <p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperDetectedChoice")}</p>}
{method === "helper" && (!helperAvailable || !helperCommand) && (
<p className="text-xs text-amber-500">{t("vmLxc.updates.helperUnavailableChoice")}</p>
)}
{(method === "helper" || method === "custom") && (
<div>
<Label htmlFor={commandId} className="text-xs uppercase tracking-wider text-muted-foreground">
{t(`vmLxc.updates.${method === "helper" ? "helperCommandLabel" : "customCommandLabel"}`)}
</Label>
<Textarea id={commandId} value={displayedCommand} onChange={(event) => editCommand(event.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")} disabled={saving}
className="font-mono text-xs mt-2 min-h-[100px]" maxLength={4096} />
{method === "helper" && helperCommand && (
<p className="mt-2 text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandEditHint")}</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div>{configured && (
<Button type="button" size="sm" variant="outline" className="text-red-400" disabled={saving} onClick={onRemove}>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />{t("vmLxc.updates.disableUpdater")}
</Button>
)}</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={onCancel}>{t("vmLxc.updates.cancelButton")}</Button>
<Button type="button" size="sm" className="bg-blue-500 hover:bg-blue-600 text-white" disabled={saving || !valid || !changed} onClick={onSave}>
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1.5" />}
{t("vmLxc.updates.saveButton")}
</Button>
</div>
</div>
<Dialog open={help !== null} onOpenChange={(open) => { if (!open) setHelp(null) }}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t(`vmLxc.updates.${help === "helper" ? "helperMethod" : "customMethod"}`)}</DialogTitle>
<DialogDescription>{t(`vmLxc.updates.${help === "helper" ? "helperMethodDescription" : "customMethodDescription"}`)}</DialogDescription>
</DialogHeader>
{help === "helper" ? (
<div className="space-y-3 text-sm">
{helperCommand && <div className="min-w-0 space-y-2">
<p className="font-medium">{t("vmLxc.updates.helperCommandLabel")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{helperCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandFallback")}</p>
</div>}
<p>{t("vmLxc.updates.updaterInstructions")}</p>
<a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href="https://community-scripts.org/docs/tools/pve/update-apps" target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperDocumentation")}<ExternalLink className="h-4 w-4" />
</a>
{scriptUrl && <div><a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href={scriptUrl} target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperSource")}<ExternalLink className="h-4 w-4" />
</a></div>}
</div>
) : (
<div className="space-y-4 text-sm">
<p className="font-medium">{t("vmLxc.updates.customExamplesHeading")}</p>
{customExamples.map((example) => <div key={example.title} className="min-w-0 space-y-2">
<p className="font-medium">{t(`vmLxc.updates.${example.title}`)}</p>
<p className="text-muted-foreground">{t(`vmLxc.updates.${example.description}`)}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{example.command}</code></pre>
{example.title === "customScriptTitle" && <>
<p className="text-muted-foreground">{t("vmLxc.updates.customOnlineDescription")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{onlineExampleCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.customOnlineExampleNote")}</p>
</>}
</div>)}
<p className="text-muted-foreground">{t("vmLxc.updates.customMethodExample")}</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+752
View File
@@ -0,0 +1,752 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import useSWR from "swr"
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { ThemeAwareLogo } from "./lxc-app-panel"
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
import { Button } from "./ui/button"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
// ─── Local subset of /api/vms shape ─────────────────────────────
// Kept narrow on purpose — this component only needs what feeds a
// launcher card. Full VMData / LxcAppWatch types live in
// virtual-machines.tsx.
interface AppPort {
port: number
description?: string
scheme?: "http" | "https"
web_path?: string
logo_url?: string | null
category?: string
custom_url?: string
}
interface AppWatch {
id: string
name: string | null
logo_url?: string | null
ports?: AppPort[]
installed_version: string | null
latest_version: string | null
update_available: boolean | null
managed_oci_app_id?: string | null
helper_slug?: string
}
interface DockerImageUpdate {
reference: string
display_name?: string | null
used_by?: string[]
update_available: boolean | null
}
// Locate the docker_inventory image whose lifecycle matches a given
// Web Link. The port's `description` is a user-typed label (e.g.
// "Paperless") so exact match on `used_by` (real container names
// like "paperless-webserver-1") almost never hits. Fall back through:
// 1. exact match in `used_by`
// 2. case-insensitive substring either way in `used_by`
// 3. substring in `display_name`
// 4. substring in `reference` (the full image path)
// Returns undefined when nothing matches — the caller treats that as
// "no upstream update signal for this port".
function findDockerImageForPort(
port: AppPort,
images: DockerImageUpdate[],
): DockerImageUpdate | undefined {
const desc = (port.description || "").trim().toLowerCase()
if (!desc || !images.length) return undefined
const exact = images.find((i) =>
(i.used_by || []).some((c) => c.toLowerCase() === desc),
)
if (exact) return exact
const inclUsedBy = images.find((i) =>
(i.used_by || []).some((c) => {
const cl = c.toLowerCase()
return cl.includes(desc) || desc.includes(cl)
}),
)
if (inclUsedBy) return inclUsedBy
const byDisplay = images.find((i) => {
const d = (i.display_name || "").toLowerCase()
return !!d && (d.includes(desc) || desc.includes(d))
})
if (byDisplay) return byDisplay
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
}
interface VM {
vmid: number
name: string
ip?: string
type: string
app_watches?: AppWatch[]
docker_inventory?: { images?: DockerImageUpdate[] }
}
interface LaunchLink {
key: string
// Present for LXC-registered apps and for custom links with a
// guest binding. Absent when the link is an unbound custom entry
// (e.g. an external service).
vmid: number | null
guestType: "lxc" | "qemu" | null
ctName: string
appName: string
logoUrl: string | null
weblink: string
category: string
updateAvailable: boolean
// Set for user-defined custom links so the card can offer edit
// and delete actions in edit mode.
isCustom: boolean
customId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────
// Same URL construction as the Web Link row in the App tab.
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
// is scoped to that module, and copying keeps this component free of
// hidden cross-file dependencies. A per-port `custom_url` overrides
// the ip:port composition entirely — used for apps served behind a
// reverse-proxy domain.
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
const custom = (port.custom_url || "").trim()
if (custom) return custom
const raw = (ip || "").trim().split("/")[0]
if (!raw || raw === "DHCP" || !port?.port) return null
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
return `${scheme}://${host}:${port.port}${path}`
}
type SortMode = "name" | "ct" | "category"
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
const ALL_CATEGORIES = "__all__"
const fetcher = async (url: string) => fetchApi(url)
// ─── Component ───────────────────────────────────────────────────
export function AppsDashboard() {
const t = useT()
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
// and rarely changes, so we don't poll on an interval — mutate() is
// called explicitly after create / update / delete.
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
)
// Category presets for the "+ Add link" modal.
const { data: categoryPresets } = useSWR<string[]>(
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
)
const isLightTheme = useIsLightTheme()
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
// for LXC-registered apps, plus one card per user-defined custom
// link. A custom link with a binding resolves its ctName from the
// matching guest in `vms` so renames stay in sync automatically.
const links = useMemo<LaunchLink[]>(() => {
const out: LaunchLink[] = []
const vmsList = Array.isArray(vms) ? vms : []
for (const vm of vmsList) {
const apps = vm.app_watches || []
if (!apps.length) continue
for (const app of apps) {
// Skip the synthetic entry ProxMenux inserts for managed
// OCI apps (Secure Gateway) — it has no user-assigned
// Web Link and doesn't belong in a launcher.
if (app.managed_oci_app_id) continue
// `app.update_available` refers to the app itself. For a
// Docker registration that app is the Docker engine, and its
// ports are containers running INSIDE Docker (Portainer,
// Frigate…) — each with an independent image update
// lifecycle in `vm.docker_inventory.images[]`. Propagating
// the engine-level flag to every container card would falsely
// mark Portainer/Frigate as updatable when only the engine
// needs bumping; missing the per-image flag would hide real
// Portainer/Frigate updates that ARE tracked in the App tab
// and fire notifications. Resolution: for each Docker port,
// find the image entry whose `used_by` includes the port's
// container name (== `port.description`) and use THAT image's
// update_available. Engine update stays out of the port cards
// — it belongs in the Updates tab.
const isDockerApp = app.helper_slug === "docker"
const dockerImages = vm.docker_inventory?.images || []
for (const port of app.ports || []) {
const url = buildWebUrl(vm.ip, port)
if (!url) continue
let updateAvailable = false
if (isDockerApp) {
const img = findDockerImageForPort(port, dockerImages)
updateAvailable = img?.update_available === true
} else {
updateAvailable = app.update_available === true
}
out.push({
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
vmid: vm.vmid,
guestType: "lxc",
ctName: vm.name,
appName: (port.description || app.name || vm.name || "").trim(),
logoUrl: port.logo_url || app.logo_url || null,
weblink: url,
category: (port.category || "").trim(),
updateAvailable,
isCustom: false,
})
}
}
}
// Merge user-defined custom links. Their `binding` decides how the
// CT/VM reference renders and where clicking it navigates.
const guestByVmid = new Map<number, { name: string; type: string }>()
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
for (const link of customLinks || []) {
let ctName = ""
let vmid: number | null = null
let guestType: "lxc" | "qemu" | null = null
if (link.binding) {
const guest = guestByVmid.get(link.binding.vmid)
vmid = link.binding.vmid
guestType = link.binding.guest_type
ctName = guest?.name || ""
}
out.push({
key: `custom-${link.id}`,
vmid,
guestType,
ctName,
appName: link.name,
logoUrl: link.logo_url || null,
weblink: link.url,
category: (link.category || "").trim(),
updateAvailable: false,
isCustom: true,
customId: link.id,
})
}
return out
}, [vms, customLinks])
// Category list for the filter dropdown — built from the data so
// it always reflects reality (presets and custom-entered names).
const categoryCounts = useMemo(() => {
const map = new Map<string, number>()
for (const l of links) {
const key = l.category || t("apps.uncategorized")
map.set(key, (map.get(key) || 0) + 1)
}
return map
}, [links, t])
const sortedCategoryEntries = useMemo(
() => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
[categoryCounts, t],
)
// ─── Controls state ────────────────────────────────────────────
const [query, setQuery] = useState("")
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
const [sortMode, setSortMode] = useState<SortMode>("name")
const [searchExpanded, setSearchExpanded] = useState(false)
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Restore sort from localStorage on mount.
useEffect(() => {
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY)
if (saved === "name" || saved === "ct" || saved === "category") {
setSortMode(saved)
}
} catch (_) { /* private mode / storage disabled — silent */ }
}, [])
// Persist sort choice — only this one preference survives reload;
// category filter and search reset each visit so the dashboard
// always opens showing every app.
useEffect(() => {
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
}, [sortMode])
// Filter category resets if the user removes/renames the currently
// selected one and it disappears from the list.
useEffect(() => {
if (currentCat === ALL_CATEGORIES) return
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
}, [currentCat, categoryCounts])
// ─── Custom link editor state ──────────────────────────────────
const [editorOpen, setEditorOpen] = useState(false)
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
const [editMode, setEditMode] = useState(false)
// Guest list feeds the binding dropdown in the editor modal.
const guestOptions = useMemo<GuestOption[]>(() => {
if (!Array.isArray(vms)) return []
return vms
.filter((v) => v.type === "lxc" || v.type === "qemu")
.map((v) => ({
vmid: v.vmid,
name: v.name,
type: v.type as "lxc" | "qemu",
}))
}, [vms])
const openNewLink = () => {
setEditingLink(null)
setEditorOpen(true)
}
const openEditForLink = (customId: string) => {
const found = (customLinks || []).find((l) => l.id === customId)
if (!found) return
setEditingLink(found)
setEditorOpen(true)
}
// ─── Filter + sort ─────────────────────────────────────────────
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const uncatKey = t("apps.uncategorized")
let filtered = links
if (currentCat !== ALL_CATEGORIES) {
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
}
if (q) {
filtered = filtered.filter((l) =>
l.appName.toLowerCase().includes(q) ||
(l.ctName || "").toLowerCase().includes(q) ||
(l.vmid != null && String(l.vmid).includes(q)) ||
(l.category || "").toLowerCase().includes(q) ||
getCategoryLabel(t, l.category || "").toLowerCase().includes(q)
)
}
const sorted = [...filtered]
sorted.sort((a, b) => {
if (sortMode === "name") return a.appName.localeCompare(b.appName)
if (sortMode === "ct") {
// Unbound custom links have no vmid; sort them after every
// bound entry, ordered alphabetically by app name.
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
if (a.vmid == null) return 1
if (b.vmid == null) return -1
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
}
// category — grouped alphabetically, then by app name inside
const catA = a.category || uncatKey
const catB = b.category || uncatKey
const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB))
return c !== 0 ? c : a.appName.localeCompare(b.appName)
})
return sorted
}, [links, query, currentCat, sortMode, t])
// ─── Empty state ───────────────────────────────────────────────
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
if (vms && !hasAnyData) {
return (
<>
<div className="text-center text-muted-foreground py-16">
<div className="text-sm">{t("apps.emptyTitle")}</div>
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
<Button
onClick={openNewLink}
variant="outline"
className="mt-4"
>
<Plus className="h-4 w-4 mr-1.5" />
{t("apps.customLinkAdd")}
</Button>
</div>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</>
)
}
// ─── Render ────────────────────────────────────────────────────
const countLabel = shown.length === 1
? t("apps.countOne")
: t("apps.countMany", { n: shown.length })
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{/* Search — icon-only until tapped on narrow screens */}
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
{!searchExpanded && (
<button
type="button"
onClick={() => {
setSearchExpanded(true)
requestAnimationFrame(() => searchInputRef.current?.focus())
}}
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
aria-label={t("apps.searchAriaLabel")}
>
<Search className="h-4 w-4" />
</button>
)}
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
placeholder={t("apps.searchPlaceholder")}
aria-label={t("apps.searchAriaLabel")}
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
{/* Category filter */}
<select
value={currentCat}
onChange={(e) => setCurrentCat(e.target.value)}
aria-label={t("apps.filterAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
{sortedCategoryEntries.map(([cat, n]) => (
<option key={cat} value={cat}>{`${getCategoryLabel(t, cat)} · ${n}`}</option>
))}
</select>
{/* Sort */}
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
aria-label={t("apps.sortAriaLabel")}
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
style={{
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
}}
>
<option value="name">{t("apps.sortName")}</option>
<option value="ct">{t("apps.sortId")}</option>
<option value="category">{t("apps.sortCategory")}</option>
</select>
{/* Count — desktop only. Mobile gives the horizontal room to
the + button instead so everything stays on one line. */}
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
{countLabel}
</span>
{/* + Add custom link. Icon-only on mobile (mirrors the search
icon-toggle pattern) so the toolbar fits in one line even
in the narrowest viewport. On desktop shows label + icon. */}
<Button
type="button"
variant="outline"
onClick={openNewLink}
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
aria-label={t("apps.customLinkAdd")}
>
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
</Button>
{/* Edit mode toggle — only shown when at least one custom link
exists, since it's the only card type that carries per-card
edit/delete actions. LXC-registered apps are edited in the
LXC App tab of their guest modal. */}
{(customLinks && customLinks.length > 0) && (
<Button
type="button"
variant="outline"
onClick={() => setEditMode((v) => !v)}
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
aria-pressed={editMode}
aria-label={t("apps.editModeToggle")}
>
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
</Button>
)}
</div>
{/* Grid — grouped headers when sorted by category */}
<CardsGrid
links={shown}
grouped={sortMode === "category"}
uncategorizedLabel={t("apps.uncategorized")}
openLabel={t("apps.openAriaLabel")}
isLightTheme={isLightTheme}
editMode={editMode}
onEditCustom={openEditForLink}
/>
<CustomLinkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editingLink}
guests={guestOptions}
categoryPresets={categoryPresets || []}
onSaved={() => mutateCustomLinks()}
/>
</div>
)
}
// ─── Cards grid + card ───────────────────────────────────────────
function CardsGrid({
links,
grouped,
uncategorizedLabel,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
links: LaunchLink[]
grouped: boolean
uncategorizedLabel: string
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
if (!links.length) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{/* No results after filter/search */}
</div>
</div>
)
}
if (!grouped) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{links.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
)
}
// Group by category, insert header rows spanning the full grid width.
const groups: Array<[string, LaunchLink[]]> = []
let currentCat: string | null = null
let bucket: LaunchLink[] = []
for (const link of links) {
const cat = link.category || uncategorizedLabel
if (cat !== currentCat) {
if (bucket.length) groups.push([currentCat!, bucket])
currentCat = cat
bucket = []
}
bucket.push(link)
}
if (bucket.length) groups.push([currentCat!, bucket])
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{groups.map(([cat, items]) => (
<div key={`grp-${cat}`} className="contents">
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
<span>{getCategoryLabel(t, cat)}</span>
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
</h3>
{items.map((link) => (
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
))}
</div>
))}
</div>
)
}
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
// lib/category-color.ts so the LXC App tab can render the same chip.
// Dispatch the pair of events that jumps from the Apps dashboard to
// the VMs modal on the App tab for a given CT. Two events by design:
// `changeTab` switches the outer tab (dashboard-level) and
// `openLxcAppModal` tells VirtualMachines which guest to open and on
// which inner tab to land. Both fire in the same tick.
function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
// (QEMU guests don't have the App tab). Used by custom links whose
// binding is a VM instead of an LXC.
function openVmModalOnStatusTab(vmid: number) {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
}
function AppCard({
link,
openLabel,
isLightTheme,
editMode,
onEditCustom,
}: {
link: LaunchLink
openLabel: string
isLightTheme: boolean
editMode: boolean
onEditCustom: (customId: string) => void
}) {
const t = useT()
// Navigate to the bound guest's modal on the appropriate inner tab:
// LXC → App tab (where the weblink was registered), VM → Status tab
// (VMs don't have an App tab). Unbound custom links have no CT ref
// to click, so this handler is only wired when `link.vmid` exists.
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
if (link.guestType === "qemu") {
openVmModalOnStatusTab(link.vmid)
} else {
openLxcModalOnAppTab(link.vmid)
}
}
// The update icon leads to where the update is applied: the Updates
// tab of the container.
const goToUpdates = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
openLxcModalOnAppTab(link.vmid, "updates")
}
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.customId) onEditCustom(link.customId)
}
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
const hasBinding = link.vmid != null
return (
<a
href={link.weblink}
target="_blank"
rel="noopener noreferrer"
aria-label={openLabel.replace("{name}", link.appName)}
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
>
{/* Head: logo + name (+ update icon) */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
{link.logoUrl ? (
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
) : (
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
</div>
{/* Edit mode on a custom card takes over the update-icon slot
with a proper edit button — custom links never carry the
update signal, so nothing is displaced. Falls back to the
update icon in every other case. */}
{editMode && link.isCustom ? (
<button
type="button"
onClick={goToEditor}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
title={t("actions.edit")}
>
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
link.vmid != null && link.guestType !== "qemu" ? (
<button
type="button"
onClick={goToUpdates}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToUpdates(e) }}
className="rounded-full flex-shrink-0 self-start mt-0.5 text-purple-400 hover:text-purple-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.openUpdatesAria", { name: link.appName })}
title={t("apps.openUpdatesTitle")}
>
<ArrowUpCircle className="h-5 w-5" />
</button>
) : (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
)
)}
</div>
{/* Foot: weblink + CT ref + category chip */}
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
<span className="truncate">{link.weblink}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{/* Guest ref → click opens that guest's modal (LXC → App
tab, VM → Status tab). stopPropagation keeps the outer
anchor from firing at the same time. Unbound custom
links: in edit mode show the edit button here, otherwise
show nothing. */}
{hasBinding && (
<button
type="button"
onClick={goToBoundGuest}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
aria-label={t("apps.openGuestAriaLabel", {
name: link.ctName || String(link.vmid),
type: guestPrefix,
id: link.vmid!,
})}
>
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
</button>
)}
{link.category && (
<span
style={categoryChipStyle(link.category, isLightTheme)}
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
title={getCategoryLabel(t, link.category)}
>
{getCategoryLabel(t, link.category)}
</span>
)}
</div>
</div>
</a>
)
}
+489
View File
@@ -0,0 +1,489 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package,
Play, Settings2, Sparkles, Terminal, Wrench,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* What ProxMenux changed on this host.
*
* The complaint this answers is not that the tool changes things: it is
* that afterwards nobody can say what it changed. Showing the script
* does not answer it either — a function of four hundred lines may alter
* two values, and the reader cannot tell which two. So what is shown
* here is the difference and nothing else.
*
* Two distinctions are kept in front of the reader, because both bear on
* what they can do about what they are looking at: whether ProxMenux
* authored the change or merely ran something the user asked for, and
* how much of the previous state is actually known.
*/
interface Change {
id: number
recorded_at: number
class: string
operation: string
source: string
function: string
function_version: string
target: string
before_ref: string
after_ref: string
capture: string
revert: string
exactness: string
result: string
recoverable: boolean
detail: Record<string, unknown>
diff?: {
available: boolean; reason?: string
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
} | null
}
interface Summary {
total: number
by_class: Record<string, number>
functions: Array<{
function: string; source: string; version: string
changes: number; last_change: number; first_change: number
}>
journal_started: number | null
}
// A file that did not exist before was created, not replaced — a sysadmin
// reading this must see "new file", not "file replaced". `capture` carries
// that distinction: "created" for a file born here, "present" for one that
// already had contents we captured before overwriting them.
function operationLabelKey(change: { operation: string; capture: string }): string {
// Two truthful labels for a file: created if it did not exist, modified if
// it did. The diff below shows exactly what changed either way, so there is
// no need to distinguish write/edit/append in the label.
if (["write_file", "edit_file", "append_file"].includes(change.operation)) {
return change.capture === "created" ? "operation.file_created" : "operation.file_modified"
}
return `operation.${change.operation}`
}
// Undoing follows `revert`, not `exactness`: a created file is undone by
// deleting it (there was nothing before), an overwritten one by restoring
// what we captured.
function undoKey(change: { revert: string; exactness: string }): string {
if (change.revert === "remove") return "undo.remove"
if (change.revert === "restore" && change.exactness === "exact") return "undo.restore"
if (change.revert === "purge") return "undo.purge"
return `exactness.${change.exactness}`
}
const FN_LABEL: Record<string, string> = {
// Internal helpers of a post-install feature show its menu name, not their
// raw name — and never the source ("auto"/"customizable").
_update_existing_log2ram_auto: "Install and configure Log2RAM",
_update_existing_log2ram_custom: "Install and configure Log2RAM",
update_snapshot_schedule: "Install ZFS auto-snapshot",
apply_amd_fixes: "Apply AMD CPU fixes",
apply_network_optimizations: "Apply network optimizations",
apt_upgrade: "Update and upgrade system",
cleanup_duplicate_repos_pve9: "Configure Proxmox APT repositories",
configure_fastfetch: "Install and configure Fastfetch",
configure_figurine: "Install Figurine",
configure_kernel_panic: "Enable restart on kernel panic",
configure_log2ram: "Install and configure Log2RAM",
configure_pigz: "Use pigz for faster gzip compression",
configure_time_sync: "Synchronize time automatically",
customize_bashrc: "Customize bashrc",
disable_rpc: "Disable portmapper/rpcbind",
enable_ha: "Enable High Availability services",
enable_kexec: "Enable fast reboots",
enable_tcp_fast_open: "Enable TCP BBR/Fast Open control",
enable_vfio_iommu: "Enable VFIO IOMMU support",
enable_zfs_autotrim: "Enable ZFS autotrim (SSD/NVMe pools)",
force_apt_ipv4: "Force APT to use IPv4",
increase_system_limits: "Increase various system limits",
install_ceph: "Add latest Ceph support",
install_guest_agent: "Install relevant guest agent",
install_log2ram: "Install and configure Log2RAM",
install_log2ram_auto: "Install and configure Log2RAM",
install_openvswitch: "Install Open vSwitch",
install_ovh_rtm: "Install OVH Real Time Monitoring",
install_system_utils: "Install common system utilities",
install_zfs_auto_snapshot: "Install ZFS auto-snapshot",
optimize_journald: "Optimize journald",
optimize_logrotate: "Optimize logrotate",
optimize_memory_settings: "Optimize Memory",
optimize_vzdump: "Increase vzdump backup speed",
optimize_zfs_arc: "Optimize ZFS ARC size",
remove_subscription_banner: "Remove subscription banner",
setup_motd: "Set up custom MOTD banner",
setup_persistent_network: "Interface Names (persistent)",
setup_proxmox_repositories: "Configure Proxmox APT repositories",
skip_apt_languages: "Skip downloading additional languages",
update_pve8: "Update and upgrade system",
update_pve9: "Update and upgrade system",
update_pve_appliance_manager: "Update Proxmox VE Appliance Manager",
}
// Post-install functions run from the auto/customizable scripts; everything
// else is a general host script (nvidia/tpu installers, PVE update, vfio…).
const POST_INSTALL_SOURCES = new Set(["auto", "customizable"])
// A few sources deserve a friendly name instead of a raw script identifier;
// everything else shows the script it came from.
const FRIENDLY_SOURCE = new Set(["install_proxmenux", "monitor"])
// Which of the three sections a change belongs to: installations are their
// own block, post-install optimizations another, general scripts the rest.
function blockOf(c: { class: string; source: string }): "installs" | "postInstall" | "scripts" {
if (c.class === "installation") return "installs"
if (POST_INSTALL_SOURCES.has(c.source)) return "postInstall"
return "scripts"
}
// A post-install function shows its menu name; anything else shows the script
// that made the change.
function groupLabel(fn: string, source: string): string {
// A post-install change shows its feature's menu name; if the function is
// not a known optimization, its own name — never the bare source.
return FN_LABEL[fn] || fn || source || "—"
}
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 },
installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package },
execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play },
registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
}
function ChangeCard({ change, expanded, onToggle, t, when }: {
change: Change; expanded: boolean; onToggle: () => void
t: (k: string, params?: Record<string, string>) => string
when: (n: number) => string
}) {
const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration
const Icon = style.Icon
const installed = String(change.detail?.installed || "")
return (
<Card className="bg-card border-border">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="w-full text-left p-3 flex flex-wrap items-center gap-2
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${style.chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.changes.${operationLabelKey(change)}`)}
</Badge>
<span className="min-w-0 font-mono text-sm text-foreground break-all">
{change.target}
</span>
{change.diff?.available && (
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
+{change.diff.added} −{change.diff.removed}
</Badge>
)}
{change.capture === "unknown" && (
<Badge variant="outline" className="text-xs shrink-0">
{t("audit.changes.capture.unknown")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{when(change.recorded_at)}
</span>
</button>
{expanded && (
<CardContent className="pt-0 pl-10 space-y-3">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>{t("audit.changes.source")}:{" "}
<span className="font-mono">{change.source || "—"}</span></span>
{change.revert && change.revert !== "none" && (
<span>{t("audit.changes.reversibility")}:{" "}
{t(`audit.changes.${undoKey(change)}`)}</span>
)}
</div>
{(change.operation === "enable_service" || change.operation === "disable_service")
&& Boolean(change.detail?.before_state || change.detail?.after_state) && (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-muted-foreground">
{String(change.detail?.before_state || "—").replace(/\s+/g, " ")}
</span>
<span className="text-muted-foreground">→</span>
<span className="font-mono text-foreground">
{String(change.detail?.after_state || "—").replace(/\s+/g, " ")}
</span>
</div>
)}
{installed && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.packagesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
<Badge key={pkg} variant="outline" className="font-mono text-xs">
{pkg}
</Badge>
))}
</div>
</div>
)}
{change.class === "execution" && Boolean(change.detail?.command) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.commandRun")}
</p>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{String(change.detail.command)}
</pre>
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.executionNote")}
</p>
</div>
)}
{change.diff && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.changes.difference")}
</p>
{change.diff.available ? (
<>
<pre className="text-xs font-mono bg-background border border-border
rounded-md p-2.5 overflow-x-auto">
{(change.diff.hunks || []).map((line: string, i: number) => (
<div key={i} className={
line.startsWith("+") ? "text-green-500"
: line.startsWith("-") ? "text-red-400"
: line.startsWith("@@") ? "text-blue-400" : ""
}>{line}</div>
))}
</pre>
{change.diff.truncated && (
<p className="text-xs text-muted-foreground mt-1">
{t("audit.changes.diffTruncated")}
</p>
)}
</>
) : (
<p className="text-xs text-muted-foreground">
{t("audit.changes.diffUnavailable")}
</p>
)}
</div>
)}
{change.capture === "unknown" && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.unknownNote")}
</p>
)}
</CardContent>
)}
</Card>
)
}
function GroupSection({ title, icon: Icon, iconClass, groups, openFn, toggleFn, open, toggle, t, when }: {
title: string
icon: typeof Settings2
iconClass: string
groups: { key: string; label: string; version: string; last: number; items: Change[] }[]
openFn: Set<string>; toggleFn: (k: string) => void
open: Set<number>; toggle: (id: number) => void
t: (k: string, params?: Record<string, string>) => string
when: (n: number) => string
}) {
if (groups.length === 0) return null
return (
// Padding on the child: the container's space-y-4 overrides any mt-*.
<div className="space-y-2 pt-2">
{/* Same type and icon size as CardTitle, so a block heading here reads
exactly like a card heading on the Settings page. */}
<h3 className="px-1 flex items-center gap-2 text-2xl font-semibold
leading-none tracking-tight text-foreground">
<Icon className={`h-5 w-5 shrink-0 ${iconClass}`} />
{title}
</h3>
{groups.map((g) => {
const fnOpen = openFn.has(g.key)
return (
<Card key={g.key} className="bg-card border-border">
<button
type="button"
onClick={() => toggleFn(g.key)}
aria-expanded={fnOpen}
className="w-full text-left p-3 flex flex-wrap items-center gap-2
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{fnOpen
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<FileCode className="h-4 w-4 shrink-0 text-blue-400" />
<span className="min-w-0 text-sm font-medium text-foreground break-words">
{g.label}
</span>
{g.version && (
<Badge variant="outline" className="text-xs shrink-0">v{g.version}</Badge>
)}
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
{t("audit.changes.count", { count: String(g.items.length) })}
</Badge>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{when(g.last)}
</span>
</button>
{fnOpen && (
<CardContent className="pt-0 pl-10 pr-3 space-y-2">
{g.items.map((change) => (
<ChangeCard key={change.id} change={change}
expanded={open.has(change.id)} onToggle={() => toggle(change.id)}
t={t} when={when} />
))}
</CardContent>
)}
</Card>
)
})}
</div>
)
}
export function AuditChanges() {
const t = useT()
const { language } = useI18n()
const [changes, setChanges] = useState<Change[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
try {
const res: any = await fetchApi("/api/audit/changes?limit=500")
if (res?.success) {
setChanges(res.changes || [])
setSummary(res.summary || null)
setError(null)
} else {
setError(res?.message || t("audit.changes.failed"))
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const toggle = (id: number) => setOpen((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const [openFn, setOpenFn] = useState<Set<string>>(new Set())
const toggleFn = (fn: string) => setOpenFn((prev) => {
const next = new Set(prev)
next.has(fn) ? next.delete(fn) : next.add(fn)
return next
})
// A sysadmin reads this in three sections: what ProxMenux optimized
// (post-install), what its other scripts changed, and what it installed.
// Within each, changes are grouped under a card that opens to reveal them.
type Group = { key: string; label: string; version: string; last: number; items: Change[] }
const blocks = useMemo(() => {
const mk = () => new Map<string, Group>()
const post = mk(), scripts = mk(), installs = mk()
const pick = (b: string) => b === "installs" ? installs : b === "postInstall" ? post : scripts
for (const c of changes) {
const b = blockOf(c)
const target = pick(b)
// Post-install groups by function under its menu name; the script and
// install sections group by the script that made the change, and show
// that script's name — not a per-function label.
// The same script can appear in more than one section (it changed
// config and also installed a package), so the accordion key is scoped
// by section — otherwise opening one card opens its twin elsewhere.
const rawKey = b === "postInstall" ? groupLabel(c.function, c.source) : (c.source || c.function || "—")
const key = `${b}:${rawKey}`
const label = b === "postInstall"
? groupLabel(c.function, c.source)
: FRIENDLY_SOURCE.has(c.source)
? t(`audit.changes.scriptLabel.${c.source}`)
: (c.source || c.function || "—")
const g = target.get(key) || { key, label, version: "", last: 0, items: [] }
g.items.push(c)
if (c.recorded_at > g.last) g.last = c.recorded_at
if (b === "postInstall" && c.function_version) g.version = c.function_version
target.set(key, g)
}
const sort = (m: Map<string, Group>) => Array.from(m.values()).sort((a, b) => b.last - a.last)
return { post: sort(post), scripts: sort(scripts), installs: sort(installs) }
}, [changes, t])
const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language)
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.changes.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
return (
<div className="space-y-4">
<Card className="bg-card border-border">
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
{/* A host with nothing recorded should say why, rather than
looking like a host nothing has touched. */}
{summary && summary.total === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
)}
{summary && summary.journal_started && (
<p className="text-xs text-muted-foreground">
{t("audit.changes.since", { date: when(summary.journal_started) })}
</p>
)}
</CardContent>
</Card>
<GroupSection title={t("audit.changes.section.postInstall")}
icon={Sparkles} iconClass="text-orange-500"
groups={blocks.post} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
<GroupSection title={t("audit.changes.section.scripts")}
icon={Terminal} iconClass="text-cyan-500"
groups={blocks.scripts} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
<GroupSection title={t("audit.changes.section.installs")}
icon={Wrench} iconClass="text-purple-400"
groups={blocks.installs} openFn={openFn} toggleFn={toggleFn}
open={open} toggle={toggle} t={t} when={when} />
{summary && summary.total > 0
&& blocks.post.length + blocks.scripts.length + blocks.installs.length === 0 && (
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.empty")}</p>
)}
</div>
)
}
+276
View File
@@ -0,0 +1,276 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import {
ArrowDownRight, ArrowUpRight, ChevronDown, ChevronRight, Flag, Loader2,
MinusCircle, PlusCircle, ShieldOff, TrendingUp,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
/**
* How this assessment differs from an earlier one.
*
* A single run says what the host is like now. It cannot say whether
* that is better or worse than last week, which is the question anyone
* maintaining a machine actually asks — and the one that turns an audit
* from a snapshot into a record.
*
* The distinction the engine draws and this view keeps: a finding that
* stopped being reported because the host was fixed is not the same as
* one that stopped because somebody accepted it. Both leave the list;
* only the first is progress, and merging them would tell the reader a
* problem went away when the decision was to live with it.
*
* The same care applies to a finding that is still reported. One that
* was already failing has not appeared now, so it is shown as having got
* worse or better with where it came from, rather than as new — reading
* "new" against work that lowered a critical to a warning would punish
* exactly the reader who fixed something.
*
* It sits inside the assessment rather than in a view of its own,
* because "what changed since last time" is context for the run being
* read, not a separate place to visit.
*/
interface Finding {
check_id: string
area: string
classification: string
// Only the findings that moved carry where they came from.
previous_classification?: string
previous_affected?: number
affected_count?: number
}
interface Comparison {
from: string
to: string
new: Finding[]
worse: Finding[]
better: Finding[]
resolved: Finding[]
accepted: Finding[]
unchanged: Finding[]
retired: Finding[]
unverified: Finding[]
}
/** What moved, in the reader's terms: the gravity when that is what
* changed, otherwise how many objects the finding now covers. */
function movement(f: Finding, t: (k: string) => string): string | null {
if (!f.previous_classification) return null
if (f.previous_classification !== f.classification) {
return `${t(`audit.classifications.${f.previous_classification}`)} → ${t(
`audit.classifications.${f.classification}`,
)}`
}
if (f.previous_affected === undefined || f.affected_count === undefined) return null
return `${f.previous_affected} → ${f.affected_count}`
}
const GROUPS = [
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
{ key: "worse", Icon: ArrowUpRight, tone: "text-red-400" },
{ key: "better", Icon: ArrowDownRight, tone: "text-emerald-400" },
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
] as const
export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
runId: string
isBaseline: boolean
onBaselineSet: () => void
}) {
const t = useT()
const { language } = useI18n()
const [comparison, setComparison] = useState<Comparison | null>(null)
const [baseline, setBaseline] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [open, setOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
// fetchApi turns a non-2xx response into an Error whose message is the
// backend's own English prose and whose `body` carries the parsed
// payload. Both paths therefore go through here: only the reason code
// crosses into a view that exists in eight languages.
const reason = (source: any): string => {
const code = String(source?.reason ?? source?.body?.reason ?? "")
const key = `audit.comparison.reasons.${code}`
const translated = t(key)
return translated !== key ? translated : t("audit.comparison.failed")
}
const load = useCallback(async () => {
setLoading(true)
try {
// Asked for separately: a host with a single run answers the
// comparison with an error, and inside a Promise.all that error
// takes the status with it — leaving no way to tell a first
// assessment from a comparison that genuinely failed, which is
// how "two runs are required" reached a reader who had simply
// never chosen a reference.
const status: any = await fetchApi("/api/audit/status").catch(() => null)
setBaseline(status?.baseline || null)
// With no reference chosen there is nothing to compare against,
// and asking anyway answered 400 — an error in the browser console
// for the ordinary state of a host assessed for the first time.
let diff: any = null
let failure: unknown = null
if (status?.baseline) {
try {
diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`)
} catch (e) {
failure = e
}
}
setComparison(diff?.success ? diff : null)
// Having no reference run yet is the ordinary state of a host
// assessed for the first time, and the view already says so.
const failed = failure ?? (diff?.success === false ? diff : null)
setError(failed && status?.baseline ? reason(failed) : null)
} finally {
setLoading(false)
}
}, [runId])
useEffect(() => { load() }, [load])
const markBaseline = async () => {
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/baseline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: runId }),
})
if (res?.success) { onBaselineSet(); await load() }
else setError(reason(res))
} catch (e) {
setError(reason(e))
} finally {
setSaving(false)
}
}
if (loading) {
return (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("audit.comparison.loading")}
</p>
)
}
const counts = comparison
? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length }))
.filter((g) => g.n > 0)
: []
const comparable = comparison && comparison.from !== comparison.to
return (
<div className="space-y-2">
{error && <p className="text-xs text-red-400">{error}</p>}
{!comparable ? (
<p className="text-sm text-muted-foreground">
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
</p>
) : (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<TrendingUp className="h-4 w-4 shrink-0 text-blue-500" />
<span className="text-sm text-muted-foreground">
{t("audit.comparison.since", {
date: baseline?.started_at
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
: t("audit.comparison.previousRun"),
})}
</span>
{counts.length === 0 ? (
<Badge variant="outline" className="text-sm">
{t("audit.comparison.noChange")}
</Badge>
) : counts.map(({ key, Icon, tone, n }) => (
<Badge key={key} variant="outline" className={`text-sm gap-1.5 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="tabular-nums font-semibold">{n}</span>
</Badge>
))}
</button>
{open && (
<div className="space-y-3 pl-6">
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
({ key, Icon, tone }) => (
<div key={key}>
<p className={`flex items-center gap-1.5 text-sm font-medium mb-1 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="font-normal text-muted-foreground">
— {t(`audit.comparison.${key}Note`)}
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{(comparison[key] || []).map((f) => {
const moved = movement(f, t)
return (
<Badge key={f.check_id} variant="outline" className="text-xs">
{t(`audit.checks.${f.check_id}.title`)}
{moved && (
<span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
{moved}
</span>
)}
</Badge>
)
})}
</div>
</div>
),
)}
{(comparison.unchanged || []).length > 0 && (
<p className="text-sm text-muted-foreground">
{t("audit.comparison.unchanged", {
count: String(comparison.unchanged.length),
})}
</p>
)}
</div>
)}
</>
)}
{/* Choosing a reference is what makes every later run comparable,
so the action lives beside the comparison it enables. */}
{!isBaseline && (
<Button
variant="outline"
size="sm"
onClick={markBaseline}
disabled={saving}
className="h-7 text-xs"
>
{saving
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
: <Flag className="h-3 w-3 mr-1.5" />}
{t("audit.comparison.setBaseline")}
</Button>
)}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format"
/**
* Renders a finding's evidence as tables and labelled values.
*
* The evidence exists so a reader can verify the conclusion for
* themselves. A JSON dump technically contains the same facts but asks
* the reader to parse it first, which is the part they came here to
* avoid.
*/
export function AuditEvidence({ evidence, locale }: {
evidence: string | null
locale: string
}) {
const blocks = parseEvidence(evidence, locale)
if (blocks.length === 0) return null
return (
<div className="space-y-3">
{blocks.map((block, i) => <Block key={i} block={block} />)}
</div>
)
}
function Block({ block }: { block: EvidenceBlock }) {
const title = block.title
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
: null
if (block.kind === "table") {
return (
<div>
{title}
{/* Wide evidence scrolls inside its own box so the page itself
never scrolls sideways. */}
{/* Evidence is the widest thing on the page; on a narrow screen
it stacks like the rest rather than scrolling sideways. */}
<div className="sm:hidden space-y-2">
{block.rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">
{block.columns[j]}</span>
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
{cell}</span>
</div>
))}
</div>
))}
</div>
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
{block.columns.map((c) => (
<th key={c} className="text-left font-medium px-3 py-1.5
text-muted-foreground whitespace-nowrap">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
if (block.kind === "pairs") {
return (
<div>
{title}
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
rounded-md border border-border px-3 py-2">
{block.entries.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
<dd className="text-foreground break-words tabular-nums">{value}</dd>
</div>
))}
</dl>
</div>
)
}
return (
<div>
{title}
{block.lines.length > 0 && (
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
border border-border px-3 py-2">
{block.lines.map((line, i) => (
<li key={i} className="break-words">{line}</li>
))}
</ul>
)}
</div>
)
}
@@ -0,0 +1,34 @@
"use client"
import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation"
export function AuditFindingData({ finding, t, locale }: {
finding: PresentedFinding; t: AuditTranslate; locale: string
}) {
return <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
<section key={index}>
<p className="text-sm font-medium mb-2">{group.title}</p>
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
{/* Wide on a screen that has the width; stacked where it does
not, so a heading stays beside the value it belongs to instead
of scrolling away from it. */}
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
</tr>)}</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{group.columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
</div>
))}
</div>)}</div>
</section>)}</div>
}
+760
View File
@@ -0,0 +1,760 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import {
Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive,
Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
import { subscriptionLabel } from "../lib/audit-presentation"
interface UplinkHop { kind: string; id: string; mode?: string; role?: string }
interface GuestDisk {
slot: string; storage: string | null; volume: string
size: string; passthrough?: boolean
}
interface GuestNic {
slot: string; name: string; bridge: string; mac: string; vlan: string
uplink: UplinkHop[] | null
}
interface GuestBackup { job: string; storage: string; schedule: string; retention: string }
interface Guest {
vmid: number; type: string; name: string; cores: string; memory: string
ostype: string; onboot: boolean; tags: string; protected: boolean
unprivileged: boolean | null; features: string | null
agent: boolean | null; cpu: string | null
disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[]
}
interface Inventory {
collected_at: number
node: string
unavailable: Record<string, string>
sections: {
identity?: Record<string, string | null>
cluster?: any
hardware?: any
storages?: any[]
guests?: Guest[]
passthrough?: any[]
applications?: any[]
custom_links?: any[]
proxmenux?: { optimizations: any[]; pending_updates: any[] }
network?: { bridges: Record<string, any> } | null
latency?: { window: string; targets: any[] } | null
}
}
const GiB = 1024 ** 3
function bytes(value: number | null | undefined): string {
if (!value || value <= 0) return "—"
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
let n = value, i = 0
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}`
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm text-foreground break-words">{value}</p>
</div>
)
}
/**
* One table shape for the whole view.
*
* The inventory is read across as much as down — a disk's model beside
* its bus beside its wear — so the sections that enumerate things share
* a single table rather than each inventing its own row layout.
*
* On a narrow screen the same rows are stacked instead. A disk table is
* eight columns wide; sideways scrolling technically fits it on a phone,
* but reading a row then means dragging back and forth to pair each
* value with its heading. Stacked, the heading travels with the value.
*/
function DataTable({ columns, rows }: {
columns: string[]
rows: React.ReactNode[][]
}) {
if (rows.length === 0) return null
return (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
{columns.map((c, i) => (
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-t border-border">
{row.map((cell, j) => (
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
{row.map((cell, j) => {
// A cell with nothing in it would leave a heading standing
// alone, which reads as missing data rather than as absent.
if (cell === null || cell === undefined || cell === "" || cell === "—") return null
return (
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
{cell}
</span>
</div>
)
})}
</div>
))}
</div>
</>
)
}
function Mono({ children }: { children: React.ReactNode }) {
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
}
function Section({
icon, title, count, children, note,
}: {
icon: React.ReactNode; title: string; count?: number
children: React.ReactNode; note?: string
}) {
const [open, setOpen] = useState(true)
return (
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
{icon}<span className="min-w-0 break-words">{title}</span>
</CardTitle>
{count !== undefined && (
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
)}
</button>
</CardHeader>
{open && (
<CardContent className="pt-0 space-y-3">
{note && <p className="text-xs text-muted-foreground">{note}</p>}
{children}
</CardContent>
)}
</Card>
)
}
/** A heading inside a section, matching the printed document's. */
function Sub({ icon, title, note }: {
icon: React.ReactNode; title: string; note?: string
}) {
return (
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
{icon}{title}
{note && <span className="font-normal text-muted-foreground">— {note}</span>}
</p>
)
}
// The uplink is the point of the network section: a bridge on its own
// says nothing, the path from it to the wire is what an operator needs.
function Uplink({ hops }: { hops: UplinkHop[] | null }) {
const t = useT()
if (hops === null) {
return <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
}
if (hops.length === 0) {
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
}
return (
<span className="flex flex-wrap items-center gap-1 text-xs">
{hops.map((h, i) => (
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground">→</span>}
<Badge variant="outline" className="font-mono text-xs">
{h.id}{h.mode ? ` · ${h.mode}` : ""}
</Badge>
</span>
))}
</span>
)
}
export function AuditInventory({ profile = "full" }: { profile?: string }) {
const t = useT()
const { language } = useI18n()
const [data, setData] = useState<Inventory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openGuest, setOpenGuest] = useState<Set<number>>(new Set())
const load = useCallback(async () => {
try {
const res: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
if (res?.success) { setData(res.inventory); setError(null) }
else setError(res?.message || t("audit.inventory.failed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally { setLoading(false) }
}, [t, profile])
useEffect(() => { load() }, [load])
const apps = useMemo(() => {
const byGuest = new Map<number, any[]>()
for (const a of data?.sections.applications || []) {
if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, [])
byGuest.get(a.vmid)!.push(a)
}
return byGuest
}, [data])
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.inventory.loading")}
</div>
)
}
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
if (!data) return null
const s = data.sections
const hw = s.hardware || {}
const mem = hw.memory || {}
const when = (value: number | string | null | undefined) => {
if (!value) return "—"
const date = typeof value === "number"
? new Date(value * 1000)
: new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language)
}
const toggle = (vmid: number) => setOpenGuest((prev) => {
const next = new Set(prev)
next.has(vmid) ? next.delete(vmid) : next.add(vmid)
return next
})
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground px-1">
{t("audit.inventory.collectedAt", {
when: new Date(data.collected_at * 1000).toLocaleString(language),
})}
</p>
{/* Sections that could not be read are named, so an empty list is
never mistaken for a section that was read and found nothing. */}
{Object.keys(data.unavailable || {}).length > 0 && (
<Card className="bg-card border-border">
<CardContent className="py-3 space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{t("audit.inventory.unavailable")}
</p>
{Object.entries(data.unavailable).map(([k, v]) => (
<p key={k} className="text-xs text-muted-foreground">
<span className="font-mono">{k}</span> — {v}
</p>
))}
</CardContent>
</Card>
)}
{s.identity && (
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
<Field label={t("audit.inventory.cluster")}
value={s.identity?.cluster || t("audit.inventory.standalone")} />
<Field label={t("audit.document.system")}
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
</div>
</Section>
)}
{"cluster" in s && (
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
{s.cluster && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
<Field label={t("audit.document.quorum")}
value={s.cluster.quorate == null ? "—" : (
<Badge variant="outline" className={s.cluster.quorate
? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"}>
{t(s.cluster.quorate ? "audit.document.quorate"
: "audit.document.inquorate")}
</Badge>
)} />
<Field label={t("audit.document.votes")}
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
</div>
<DataTable
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
t("audit.document.state")]}
rows={(s.cluster.nodes || []).map((n: any) => [
<span className="font-medium text-foreground">
{n.name}
{n.local && <span className="text-muted-foreground">
{" "}({t("audit.document.thisNode")})</span>}
</span>,
<Mono>{n.nodeid || "—"}</Mono>,
<Mono>{n.ring0_addr || "—"}</Mono>,
<Mono>{n.ring1_addr || "—"}</Mono>,
n.online === false
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{t("audit.document.unreachable")}</Badge>
: n.online === true
? <Badge variant="outline">{t("audit.document.member")}</Badge>
: "—",
])}
/>
</>
)}
</Section>
)}
{s.hardware && (
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
<Field label={t("audit.inventory.topology")}
value={hw.cpu ? t("audit.inventory.cpuLayout", {
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
threads: String(hw.cpu.threads),
}) : ""} />
<Field label={t("audit.inventory.memory")}
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
<Field label={t("audit.document.board")}
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
<Field label={t("audit.inventory.bios")}
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
</div>
{(mem.modules || []).length > 0 && (
<>
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.memoryModules")}
note={t("audit.document.slotsFilled", {
used: String(mem.populated ?? 0),
total: String(mem.slots ?? mem.populated ?? 0),
})} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.size"),
t("audit.document.type"), t("audit.document.formFactor"),
t("audit.document.speed"), t("audit.document.manufacturer")]}
rows={(mem.modules || []).map((m: any) => [
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
m.form_factor || "—", m.speed || "—",
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
])}
/>
</>
)}
{(hw.controllers || []).length > 0 && (
<>
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.controllers")} />
<DataTable
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
rows={(hw.controllers || []).map((c: any) => [
<Mono>{c.slot}</Mono>, c.class, c.name,
])}
/>
</>
)}
</Section>
)}
{(hw.disks || []).length > 0 && (
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
count={(hw.disks || []).length}>
<DataTable
columns={[t("audit.document.device"), t("audit.document.model"),
t("audit.document.serial"), t("audit.document.size"),
t("audit.document.bus"), "SMART", t("audit.document.serviceLife"),
t("audit.document.events")]}
rows={(hw.disks || []).map((d: any) => {
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
return [
<span className="font-medium text-foreground">{d.name}</span>,
d.model || "—",
<Mono>{d.serial || "—"}</Mono>,
bytes(d.size_bytes),
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
ok
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("audit.document.healthy")}</Badge>
: d.health && d.health !== "unknown"
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{d.health}</Badge>
: "—",
typeof d.power_on_hours === "number" && d.power_on_hours > 0
? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) })
: "—",
(d.observations || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{d.observations.length}</Badge>
: <span className="text-muted-foreground">—</span>,
]
})}
/>
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
<>
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.observations")}
note={t("audit.document.observationsNote")} />
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
<div key={d.name} className="mt-2">
<p className="text-xs font-medium text-foreground mb-1">
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
</p>
<DataTable
columns={[t("audit.document.event"), t("audit.document.severity"),
t("audit.document.occurrences"), t("audit.document.firstSeen"),
t("audit.document.lastSeen"), t("audit.document.detail")]}
rows={d.observations.map((o: any) => [
o.type || "—",
<Badge variant="outline" className={o.severity === "critical"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
{o.severity
? t(`audit.classifications.${
o.severity === "critical" ? "critical" : "warning"}`)
: "—"}</Badge>,
String(o.count ?? "—"),
when(o.first_seen), when(o.last_seen),
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
])}
/>
</div>
))}
</>
)}
</Section>
)}
{(s.network || (hw.adapters || []).length > 0) && (
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
count={Object.keys(s.network?.bridges || {}).length || undefined}>
{(hw.adapters || []).length > 0 && (
<>
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.physicalAdapters")} />
<DataTable
columns={[t("audit.document.interface"), t("audit.document.state"),
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
rows={(hw.adapters || []).map((a: any) => [
<span className="font-medium text-foreground">{a.name}</span>,
a.state === "up"
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{a.state}</Badge>
: <Badge variant="outline">{a.state || "—"}</Badge>,
a.speed_mbps
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
: "—",
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
])}
/>
</>
)}
{s.network && (
<>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.document.bridges")} />
<div className="space-y-2">
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant="outline" className="font-mono">{id}</Badge>
<span className="text-muted-foreground">→</span>
<Uplink hops={b.uplink} />
{b.vlan_interface && (
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{(s.guests || []).filter((g) =>
(g.interfaces || []).some((n) => n.bridge === id)).length}
{" "}{t("audit.inventory.guests").toLowerCase()}
</span>
</div>
))}
</div>
</>
)}
</Section>
)}
{s.latency?.targets?.length ? (
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
note={t("audit.document.latencyNote")}>
<DataTable
columns={[t("audit.document.target.label"), t("audit.document.minimum"),
t("audit.document.average"), t("audit.document.maximum"),
t("audit.document.packetLoss"), t("audit.document.samples")]}
rows={s.latency.targets.map((target: any) => [
<span className="font-medium text-foreground">
{t(`audit.document.target.${target.target}`)}</span>,
target.min_ms != null ? `${target.min_ms} ms` : "—",
target.avg_ms != null ? `${target.avg_ms} ms` : "—",
target.max_ms != null ? `${target.max_ms} ms` : "—",
target.packet_loss != null ? `${target.packet_loss} %` : "—",
String(target.samples),
])}
/>
</Section>
) : null}
{s.storages && (
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
count={(s.storages || []).length}>
<DataTable
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
t("audit.inventory.content"), t("audit.inventory.shared"),
t("audit.document.location"), t("audit.inventory.guests")]}
rows={(s.storages || []).map((st: any) => [
<Mono>{st.id}</Mono>, st.type,
<span className="text-muted-foreground text-xs">{st.content}</span>,
st.shared
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
: <span className="text-muted-foreground">—</span>,
<span className="text-muted-foreground text-xs break-all">
{st.server || st.path || "—"}</span>,
String((s.guests || []).filter((g) =>
(g.disks || []).some((d) => d.storage === st.id)).length),
])}
/>
</Section>
)}
{s.guests && (
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
count={(s.guests || []).length}>
<div className="space-y-2">
{(s.guests || []).map((g) => {
const open = openGuest.has(g.vmid)
const guestApps = apps.get(g.vmid) || []
return (
<div key={g.vmid} className="rounded-md border border-border">
<button
type="button"
onClick={() => toggle(g.vmid)}
aria-expanded={open}
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
>
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
<span className="font-medium text-foreground">{g.name || "—"}</span>
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
{/* Whether a guest needs a backup is not visible from
here, so its absence is stated, not flagged. */}
{g.backups.length === 0 && (
<Badge variant="outline" className="text-xs">
{t("audit.inventory.noBackup")}
</Badge>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
{g.cores}c · {g.memory}MB
</span>
</button>
{open && (
<div className="border-t border-border p-3 space-y-4">
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
<Field label={t("audit.inventory.onboot")}
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
<Field label={t("audit.inventory.tags")} value={g.tags} />
{g.type === "lxc" && (
<Field label={t("audit.inventory.privilege")}
value={g.unprivileged ? t("audit.inventory.unprivileged")
: t("audit.inventory.privileged")} />
)}
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
{g.type === "qemu" && (
<Field label={t("audit.inventory.agent")}
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
)}
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
</div>
{g.disks.length > 0 && (
<div>
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.disks")} />
<DataTable
columns={[t("audit.document.slot"), t("audit.document.storage"),
t("audit.inventory.name"), t("audit.document.size")]}
rows={g.disks.map((d) => [
<Mono>{d.slot}</Mono>,
d.storage
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
: <Badge variant="outline" className="text-xs">
{t("audit.inventory.passthrough")}</Badge>,
<span className="font-mono text-xs break-all">{d.volume}</span>,
d.size || "—",
])}
/>
</div>
)}
{g.interfaces.length > 0 && (
<div>
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.interfaces")} />
<div className="space-y-1">
{g.interfaces.map((n) => (
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
<span className="text-muted-foreground">→</span>
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
<span className="text-muted-foreground">→</span>
<Uplink hops={n.uplink} />
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
{n.mac && <Mono>{n.mac}</Mono>}
</div>
))}
</div>
</div>
)}
<div>
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.protection")} />
{g.backups.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("audit.inventory.noBackupDetail")}</p>
) : (
<DataTable
columns={[t("audit.document.backup"), t("audit.document.storage"),
t("audit.inventory.type"), t("audit.document.content")]}
rows={g.backups.map((b) => [
<Mono>{b.job}</Mono>,
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
b.schedule || "—", b.retention || "—",
])}
/>
)}
</div>
{guestApps.length > 0 && (
<div>
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
title={t("audit.inventory.applications")} />
<div className="space-y-1">
{guestApps.map((a: any, i: number) => (
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-foreground">{a.name}</span>
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
{a.update_available && (
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{a.available}
</Badge>
)}
{(a.ports || []).map((p: any, j: number) => (
<Badge key={j} variant="outline" className="font-mono">
{p.scheme}:{p.port}{p.path}
</Badge>
))}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</Section>
)}
{(s.passthrough || []).length > 0 && (
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
count={(s.passthrough || []).length}>
<DataTable
columns={[t("audit.document.vmid"), t("audit.document.name"),
t("audit.document.slot"), t("audit.document.device"),
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
rows={(s.passthrough || []).map((p: any) => [
<Mono>{p.vmid}</Mono>,
p.guest || "—",
<Mono>{p.slot || "—"}</Mono>,
<Mono>{p.address || "—"}</Mono>,
(p.iommu_groups || []).join(", ") || "—",
// Everything in a group moves together, so a shared group is
// what decides whether the passthrough is possible.
(p.shared_group_devices || []).length
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
{p.shared_group_devices.length}</Badge>
: <span className="text-muted-foreground">—</span>,
])}
/>
</Section>
)}
{s.proxmenux && (
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
count={(s.proxmenux.optimizations || []).length}>
<DataTable
columns={[t("audit.document.name"), t("audit.document.version"),
t("audit.document.state")]}
rows={(s.proxmenux.optimizations || []).map((o: any) => {
const pending = (s.proxmenux!.pending_updates || [])
.find((u: any) => u.key === o.key)
return [
<Mono>{o.key}</Mono>,
o.version || "—",
pending
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
{t("audit.document.updateAvailable", { version: String(pending.available) })}
</Badge>
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
]
})}
/>
</Section>
)}
</div>
)
}
+419
View File
@@ -0,0 +1,419 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
/**
* Declares what is expected of this host.
*
* An assessment can see what the host does; it cannot see what it is
* for. Everything on this page answers a question the host has no way of
* answering itself — does this guest need a backup, must this one come
* back by itself, is this storage essential — and each answer is what
* turns an observation in the report into a warning, or takes it out of
* the count entirely.
*
* Nothing here is required. A host with no declaration produces a
* complete report; it just describes rather than judges.
*/
interface Guest { vmid: number; name: string; type: string }
interface Storage { id: string; type: string }
interface GuestRule {
backup?: string; autostart?: string
recovery_objective_hours?: number; note?: string
}
interface Policy {
guests: Record<string, GuestRule>
storages: Record<string, { role?: string }>
defaults: Record<string, unknown>
thresholds: Record<string, number>
}
interface Vocabulary {
expectations: string[]
roles: string[]
thresholds: Record<string, number>
}
const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} }
function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: {
value: string; options: string[]; prefix: string
onChange: (value: string) => void; inherited: string; inheritedKey?: string
label: (key: string) => string; disabled?: boolean
}) {
// One component behind every dropdown on this tab, so it is also the
// one place that decides they look like the rest of the interface.
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">{inherited}</SelectItem>
{/* Declaring here what the default already says would be the same
entry twice, reading the same. */}
{options.filter((option) => option !== inheritedKey).map((option) => (
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function AuditPolicy() {
const t = useT()
const [policy, setPolicy] = useState<Policy>(EMPTY)
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
const [guests, setGuests] = useState<Guest[]>([])
const [storages, setStorages] = useState<Storage[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState<string | null>(null)
const [conflict, setConflict] = useState(false)
// The declaration is what turns an observation into a warning, so the
// form stays locked until the reader says they are changing it.
const [editing, setEditing] = useState(false)
const locked = !editing || saving || !revision || conflict
const load = useCallback(async () => {
setLoading(true)
setRevision(null)
try {
const [current, inventory]: any[] = await Promise.all([
fetchApi("/api/audit/policy"),
// The declaration is about this host's own guests and storages,
// so they are listed rather than typed in by identifier.
fetchApi("/api/audit/inventory?profile=inventory"),
])
if (current?.success) {
setPolicy({ ...EMPTY, ...current.policy })
setVocabulary(current.vocabulary)
setRevision(current.summary.revision)
setDirty(false); setSaved(false); setConflict(false)
setError(null)
} else {
setError(current?.message || t("audit.policy.failed"))
}
const sections = inventory?.inventory?.sections
setGuests((sections?.guests || []).map((g: any) => ({
vmid: g.vmid, name: g.name, type: g.type,
})))
setStorages((sections?.storages || []).map((s: any) => ({
id: s.id, type: s.type,
})))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => { load() }, [load])
const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => {
setPolicy((prev) => {
const guests = { ...prev.guests }
const rule: GuestRule = { ...(guests[String(vmid)] || {}) }
// Absence inherits; explicit "unspecified" overrides the site default.
if (value === "inherit" || value === "" || value === undefined) {
delete rule[field]
} else {
;(rule as Record<string, unknown>)[field] = value
}
if (Object.keys(rule).length === 0) delete guests[String(vmid)]
else guests[String(vmid)] = rule
return { ...prev, guests }
})
setDirty(true); setSaved(false)
}
const setStorageRole = (id: string, role: string) => {
setPolicy((prev) => {
const storages = { ...prev.storages }
if (role === "inherit" || !role) delete storages[id]
else storages[id] = { role }
return { ...prev, storages }
})
setDirty(true); setSaved(false)
}
const setThreshold = (name: string, raw: string) => {
setPolicy((prev) => {
const thresholds = { ...prev.thresholds }
const value = Number(raw)
if (!raw.trim()) delete thresholds[name]
else thresholds[name] = value
return { ...prev, thresholds }
})
setDirty(true); setSaved(false)
}
const save = async () => {
if (!revision || saving || conflict) return
setSaving(true)
try {
const res: any = await fetchApi("/api/audit/policy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...policy, expected_revision: revision }),
})
if (res?.success) {
setRevision(res.summary.revision)
setDirty(false); setSaved(true); setError(null); setEditing(false)
}
else setError(res?.message || t("audit.policy.failed"))
} catch (e) {
if ((e as { status?: number }).status === 409) {
setConflict(true)
setError(t("audit.policy.conflict"))
} else setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const declared = useMemo(
() => Object.keys(policy.guests).length + Object.keys(policy.storages).length
+ Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length,
[policy],
)
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.policy.loading")}
</div>
)
}
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
return (
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
{t("audit.policy.reload")}
</Button>}
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardContent className="py-4 space-y-3">
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline" className="tabular-nums">
{t("audit.policy.declaredCount", { count: String(declared) })}
</Badge>
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
{editing ? (
<div className="ml-auto flex items-center gap-2">
<button
type="button"
className="h-7 px-3 text-xs rounded-md border border-border bg-background
hover:bg-muted transition-colors text-muted-foreground"
onClick={() => { setEditing(false); void load() }}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
type="submit"
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white
transition-colors disabled:opacity-50 flex items-center gap-1.5"
disabled={!dirty || saving}
>
{saving
? <Loader2 className="h-3 w-3 animate-spin" />
: <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</div>
) : (
<button
type="button"
className="ml-auto h-7 px-3 text-xs rounded-md border border-border bg-background
hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => { setEditing(true); setSaved(false) }}
disabled={!revision || conflict}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</CardContent>
</Card>
<fieldset disabled={locked} className="space-y-4 min-w-0">
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
{guests.map((guest) => {
const rule = policy.guests[String(guest.vmid)] || {}
return (
<div key={guest.vmid}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{guest.vmid}
</Badge>
<span className="truncate font-medium text-foreground">
{guest.name || "—"}
</span>
<Badge variant="outline" className="text-xs uppercase shrink-0">
{guest.type}
</Badge>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.backup
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
value={rule.backup || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.autostart
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
value={rule.autostart || "inherit"}
options={expectations}
prefix="audit.policy.expectation"
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
<input
type="number"
min={0.000001}
step="any"
inputMode="numeric"
value={rule.recovery_objective_hours ?? ""}
placeholder={policy.defaults.recovery_objective_hours
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
onChange={(e) => setGuestRule(
guest.vmid, "recovery_objective_hours",
e.target.value ? Number(e.target.value) : undefined)}
className="w-full min-w-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground focus:outline-none
focus:ring-1 focus:ring-ring sm:w-24"
/>
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
)}
</label>
</div>
)
})}
{guests.length === 0 && (
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
)}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
{storages.map((storage) => (
<div key={storage.id}
className="rounded-md border border-border p-3 space-y-2
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{storage.id}
</Badge>
<span className="text-sm text-muted-foreground">{storage.type}</span>
</div>
<PolicySelect label={t} disabled={locked}
inherited={policy.defaults.storage_role
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
: t("audit.policy.inheritUnset")}
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
value={policy.storages[storage.id]?.role || "inherit"}
options={roles}
prefix="audit.policy.role"
onChange={(v) => setStorageRole(storage.id, v)}
/>
</div>
))}
</CardContent>
</Card>
<Card className={editing
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
: "bg-card border-border"}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
<div className="grid gap-2 sm:grid-cols-2">
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
<label key={name}
className="flex flex-col items-end gap-1.5 rounded-md border
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
{t(`audit.policy.threshold.${name}`)}
</span>
<input
type="number"
min={0.000001}
max={name.endsWith("_percent") ? 100 : undefined}
step="any"
inputMode="decimal"
value={policy.thresholds[name] ?? ""}
placeholder={String(shipped)}
onChange={(e) => setThreshold(name, e.target.value)}
className="w-24 shrink-0 rounded-md border border-border bg-background
px-2 py-1.5 text-sm text-foreground tabular-nums
focus:outline-none focus:ring-1 focus:ring-ring"
/>
</label>
))}
</div>
</CardContent>
</Card>
</fieldset>
</form>
)
}
+938
View File
@@ -0,0 +1,938 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
DialogTitle,
} from "./ui/dialog"
import {
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
ShieldOff, XCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { AuditPolicy } from "./audit-policy"
import { AuditChanges } from "./audit-changes"
import { AuditComparison } from "./audit-comparison"
import { Label } from "./ui/label"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"
import { unreadSources } from "../lib/audit-presentation"
import { openAuditDocument, openReportWindow } from "../lib/audit-document"
import { AuditEvidence } from "./audit-evidence"
import { AuditFindingData } from "./audit-finding-data"
import { affectedDescription, resultBreakdown } from "../lib/audit-presentation"
import { useI18n } from "../lib/i18n/provider"
interface Finding {
check_id: string
area: string
severity: string
classification: string
decision?: string
summary_key: string | null
summary_params: Record<string, string | number>
affected: Array<Record<string, unknown>>
evidence: string | null
remediable_by: string | null
incomplete?: boolean
collected_at?: number
check_version?: number
sources?: Array<{ source: string; collected_at: number; error?: string }>
exception?: {
reason: string; accepted_by: string; accepted_at: number
expires_at?: number | null
// Asks for the decision to be looked at again on this date. It does
// not withdraw it: an acceptance can stand indefinitely and still
// come back for review.
review_at?: number | null
} | null
}
interface Run {
run_id: string
profile: string
started_at: number
finished_at: number | null
status: string
checks_total: number
checks_expected: number
error?: string | null
is_baseline?: number | boolean
// Recorded by the engine: the sources it read and the declaration it
// judged against. The document states the latter in its scope.
metadata?: { policy?: {
declared?: boolean; guests_declared?: number
storages_declared?: number; thresholds_declared?: string[]
} } | null
}
// Findings are ordered by how much they demand attention, not by area.
// Someone triaging wants the worst thing first regardless of where it
// lives; grouping by area is the reading order of the printed document.
//
// One scale, worst first. There is no second ordering by severity any
// more: gravity is the classification, so a finding cannot be a critical
// observation or an informational failure.
const CLASS_RANK: Record<string, number> = {
critical: 0, warning: 1, observation: 2, unverified: 3,
accepted: 4, conformant: 5, not_applicable: 6,
}
// Only the first two are problems. An observation is drawn in a neutral
// tone on purpose: colouring planning information like a fault is what
// made ordinary configurations read as defects.
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info },
unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle },
accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff },
conformant: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
}
/** What a finding reads as once the reader's decision is applied. */
function shownAs(f: { classification: string; decision?: string }): string {
return f.decision === "accepted" ? "accepted" : f.classification
}
// An assessment older than this stops describing the current system, so
// the age is surfaced before any count rather than as a footnote.
const STALE_AFTER_DAYS = 30
const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs"
export function AuditReport() {
const t = useT()
const { language } = useI18n()
// Assessment and inventory answer different questions and are
// read differently: one is triaged, the other is read through.
const [view, setView] = useState<"assessment" | "changes" | "policy">("assessment")
const [running, setRunning] = useState(false)
const [latest, setLatest] = useState<Run | null>(null)
const [findings, setFindings] = useState<Finding[]>([])
const [summary, setSummary] = useState<Record<string, number>>({})
const [areaFilter, setAreaFilter] = useState<string>("all")
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState<Finding | null>(null)
const [reason, setReason] = useState("")
const [expiryDays, setExpiryDays] = useState<string>("")
const [reviewDays, setReviewDays] = useState<string>("")
const [saving, setSaving] = useState(false)
const [progress, setProgress] = useState({ completed: 0, total: 0 })
// The profile decides which question the page answers, so it governs
// both what an assessment runs and what the inventory documents.
const [profile, setProfile] = useState("full")
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean; areas: string[] | null; include: string[] }>>([])
// Set when a Lynis-bearing assessment is about to run and the stored
// report is missing or stale: the user decides whether to run Lynis now.
const [lynisPrompt, setLynisPrompt] = useState<null | { ageDays: number | null; stale: boolean }>(null)
const [building, setBuilding] = useState(false)
const loadRun = useCallback(async (runId: string) => {
try {
const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`)
if (data?.success) setFindings(data.findings || [])
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const refresh = useCallback(async () => {
try {
const data: any = await fetchApi("/api/audit/status")
if (!data?.success) return
setRunning(Boolean(data.running))
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
setSummary(data.summary || {})
setLatest(data.latest || null)
if (data.latest?.run_id) await loadRun(data.latest.run_id)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [loadRun])
useEffect(() => {
fetchApi("/api/audit/profiles")
.then((d: any) => { if (d?.success) setProfiles(d.profiles || []) })
.catch(() => { /* the page works on the default profile */ })
}, [])
useEffect(() => { refresh() }, [refresh])
// Expiry changes a decision, not the assessment. One local timer and
// a focus refresh keep it current without periodic scans or idle polling.
useEffect(() => {
const now = Date.now() / 1000
const due = findings.flatMap((f) => [f.exception?.expires_at, f.exception?.review_at]
.filter((t): t is number => !!t && t > now))
if (!due.length) return
const delay = Math.max(100, Math.min(2147483647, Math.min(...due) * 1000 - Date.now() + 100))
const id = setTimeout(refresh, delay)
return () => clearTimeout(id)
}, [findings, refresh])
useEffect(() => {
const onFocus = () => { void refresh() }
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [refresh])
// While an assessment is in flight the page polls; once it settles the
// interval is dropped so an idle tab does not keep waking the backend.
useEffect(() => {
if (!running) return
const id = setInterval(refresh, 2000)
return () => clearInterval(id)
}, [running, refresh])
const doRun = async (runLynis: boolean) => {
setLynisPrompt(null)
setError(null)
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile, run_lynis: runLynis }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
// A profile that includes the Lynis check asks the user before running,
// since producing a fresh Lynis report takes a few minutes. When a
// recent report already exists — or Lynis is not installed — the run
// starts straight away and reuses it.
const startRun = async () => {
setError(null)
const spec = profiles.find((p) => p.id === profile)
const runsLynis = !!spec && (spec.areas === null || spec.areas.includes("security"))
if (runsLynis) {
try {
const r: any = await fetchApi("/api/audit/lynis-readiness")
if (r?.success && r.installed && (!r.has_report || r.stale)) {
setLynisPrompt({ ageDays: r.age_days ?? null, stale: !!r.stale })
return
}
} catch {
/* Readiness is advisory; on failure run without Lynis rather than block. */
}
}
doRun(false)
}
// Accepting or revoking changes which findings are active, so the run
// is re-read afterwards rather than patched in place: the stored
// finding is what the next report will show.
const submitAcceptance = async () => {
if (!accepting || !reason.trim()) return
setSaving(true)
try {
const body: Record<string, unknown> = {
check_id: accepting.check_id,
run_id: latest?.run_id,
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
if (reviewDays) body.review_in_days = Number(reviewDays)
const data: any = await fetchApi("/api/audit/exceptions", {
method: "POST",
body: JSON.stringify(body),
})
if (!data?.success) throw new Error(data?.message || "")
setAccepting(null)
setReason("")
setExpiryDays("")
setReviewDays("")
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const revokeAcceptance = async (checkId: string) => {
try {
await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" })
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const areas = useMemo(
() => Array.from(new Set(findings.map((f) => f.area))).sort(),
[findings],
)
// Every check is listed, worst first. Hiding what passed made the
// reader guess whether a check was clean or had not run, which is
// exactly the distinction this page exists to keep.
const visible = useMemo(() =>
findings
.filter((f) => areaFilter === "all" || f.area === areaFilter)
.sort((a, b) =>
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
a.check_id.localeCompare(b.check_id)),
[findings, areaFilter])
const acceptedCount = summary.accepted || 0
const reviewDueCount = findings.filter(
(f) => f.exception?.review_at && f.exception.review_at * 1000 <= Date.now(),
).length
const unverifiedChecks = findings.filter(
(f) => f.classification === "unverified" || f.incomplete)
const ageDays = latest?.finished_at
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
: null
const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS
// The backend stores which sentence applies and its numbers, not the
// sentence itself, so a finding recorded under one language still reads
// correctly under another. A check that failed to evaluate has no
// per-check entry, hence the shared fallback.
const summaryOf = (f: Finding) => {
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
if (!f.summary_key) return ""
const params = Object.fromEntries(
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
)
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
const text = t(key, params)
return text === key ? t("audit.summaryFallback") : text
}
// A check's plain-language texts are optional. Those that do not carry
// them yet render nothing, rather than the raw key a missing lookup
// returns, so the section can gain them one area at a time.
const optional = (key: string) => {
const text = t(key)
return text === key ? null : text
}
// What to do about a finding depends on what was found, not on what was
// checked: an outcome that is not a problem has no next step, and one
// that could not be evaluated has nothing to act on either.
const nextStepOf = (f: Finding) => {
if (f.classification === "not_applicable") return null
if (f.classification === "unverified" || f.incomplete) return t("audit.couldNotEvaluate")
if (f.classification !== "critical" && f.classification !== "warning") {
return t("audit.noActionNeeded")
}
return f.summary_key ? optional(`audit.checks.${f.check_id}.nextStep.${f.summary_key}`) : null
}
const notApplicableText = (f: Finding) => {
if (f.summary_key) return ""
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
}
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t("audit.loading")}
</div>
)
}
// The document carries both halves, so the inventory is fetched at the
// moment it is produced rather than kept in memory for a button that
// may never be pressed.
const generateDocument = async () => {
// The window is opened on the click itself, before the inventory is
// fetched, so the popup blocker sees the user gesture. It shows a
// spinner while the document is composed.
const target = openReportWindow(t("audit.document.building"))
setBuilding(true)
try {
const inv: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
// A focused report shows only the checks its profile runs, even
// when the findings on screen came from a full assessment: the
// report is scoped to its question, not to whichever run produced
// the data. `areas: null` (full, diagnostic) keeps everything.
const spec = profiles.find((p) => p.id === profile)
const scopedFindings = !spec || spec.areas === null
? findings
: findings.filter((f) => spec.areas!.includes(f.area) || spec.include.includes(f.check_id))
openAuditDocument({
profile,
run: latest,
findings: scopedFindings,
inventory: inv?.success ? inv.inventory : null,
t,
locale: language,
}, target)
} catch (e) {
target?.close()
setError(e instanceof Error ? e.message : String(e))
} finally {
setBuilding(false)
}
}
const documentButton = (
<Button
variant="outline"
size="sm"
onClick={generateDocument}
disabled={building}
// Related to running an assessment, and secondary to it: the same
// hue as that button, at the translucent weight the chips use.
// Green is taken — here it means a conformant result, and this
// report may be full of critical ones.
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
hover:bg-blue-500/20 hover:text-blue-500"
>
{building
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
: <FileText className="h-4 w-4 mr-2" />}
{/* The icon carries the meaning where the width is short. */}
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
<span className="hidden sm:inline">{t("audit.document.action")}</span>
</Button>
)
const profilePicker = profiles.length > 0 ? (
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
{t("audit.profile.label")}
</Label>
<Select value={profile} onValueChange={setProfile}>
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null
const viewTabs = (
<div
role="group"
aria-label={t("audit.viewSwitch.ariaLabel")}
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["assessment", "changes", "policy"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={view === key}
onClick={() => setView(key)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
view === key
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.viewSwitch.${key}`)}
</button>
))}
</div>
)
// Changes and policy carry no profile: one is what was done to this
// host, the other what is expected of it, and neither narrows by report.
if (view === "changes") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditChanges />
</div>
)
}
if (view === "policy") {
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
{viewTabs}
</div>
</div>
<AuditPolicy />
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
<div className="flex items-center gap-2 sm:contents">
{profilePicker}{documentButton}
</div>
{viewTabs}
</div>
</div>
<Card className="bg-card border-border">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
{/* Stated before any count: an assessment nobody has run, or
one run months ago, does not describe this host today. */}
{!latest ? (
<p className="text-sm text-muted-foreground">{t("audit.neverRun")}</p>
) : (
<p className={`text-sm ${stale ? "text-amber-500" : "text-muted-foreground"}`}>
{t("audit.lastRun", {
when: new Date((latest.finished_at || latest.started_at) * 1000)
.toLocaleString(),
})}
{stale && ` — ${t("audit.stale", { days: String(ageDays) })}`}
</p>
)}
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
{running && <p role="status" className="text-sm text-muted-foreground">
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
</p>}
{/* A reading that could not be taken is information, not an
alarm: it says the report is narrower than usual, and
colouring it like a finding puts it above warnings the
reader has to act on. A run that failed outright is the
one thing here that does interrupt. */}
{latest && (latest.status === "partial" || latest.status === "failed") && (
<div
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
className={`space-y-1 text-sm ${
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
}`}
>
<p>{t(`audit.runStates.${latest.status}`)}</p>
{unverifiedChecks.length > 0 && <p className="text-xs">
{t("audit.unverifiedChecks", { checks: unverifiedChecks
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
</p>}
</div>
)}
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
</div>
<Button
onClick={startRun}
disabled={running}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{running
? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t("audit.running")}</>
: <><Play className="h-4 w-4 mr-2" />{t("audit.run")}</>}
</Button>
</CardHeader>
{latest && (
<CardContent className="pt-0 space-y-3">
{/* One row of counters on one scale. Gravity is the
classification itself, so there is nothing left to
reconcile between two sets of numbers. */}
<div role="group" aria-label={t("audit.results")}
className="flex max-w-full flex-wrap items-center gap-2">
{(["critical", "warning", "observation", "unverified",
"accepted", "conformant", "not_applicable"] as const)
.filter((c) => summary[c])
.map((c) => {
const { chip, Icon } = CLASS_STYLE[c]
return (
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.classifications.${c}`)}
<span className="tabular-nums font-semibold">{summary[c]}</span>
</Badge>
)
})}
</div>
{/* What changed since a reference run: context for the
assessment being read, not a place of its own. */}
<AuditComparison
runId={latest.run_id}
isBaseline={Boolean(latest.is_baseline)}
onBaselineSet={refresh}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setAreaFilter("all")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === "all"
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t("audit.areas.all")}
</button>
{areas.map((a) => (
<button
key={a}
type="button"
onClick={() => setAreaFilter(a)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === a
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.areas.${a}`)}
</button>
))}
</div>
{/* The count of accepted risks stays visible even when the
findings themselves are filtered out of the list, so a
decision to live with something is never silently lost. */}
{acceptedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
</p>
)}
{/* A decision that asked to be revisited says so here, where it
is read without going to look for it. The acceptance still
stands; this is a reminder, not a lapse. */}
{reviewDueCount > 0 && (
<p className="text-xs text-amber-500">
{t("audit.reviewDueNotice", { count: String(reviewDueCount) })}
</p>
)}
</CardContent>
)}
</Card>
{error && (
<p className="text-sm text-red-400 px-1">{error}</p>
)}
{latest && visible.length === 0 && (
<Card className="bg-card border-border">
<CardContent className="py-10 text-center text-muted-foreground">
{t("audit.noFindings")}
</CardContent>
</Card>
)}
<div className="space-y-2">
{visible.map((f) => {
const shown = shownAs(f)
const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable
const open = expanded.has(f.check_id)
const muted = shown === "accepted" || shown === "not_applicable"
return (
<Card
key={f.check_id}
className={`bg-card border-border ${muted ? "opacity-70" : ""}`}
>
<button
type="button"
onClick={() => toggle(f.check_id)}
aria-expanded={open}
className="w-full text-left p-4 flex max-[359px]:flex-wrap items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
{open
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.classifications.${shown}`)}
</Badge>
<div className="min-w-0 flex-1 max-[359px]:basis-full">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{t(`audit.checks.${f.check_id}.title`)}
</span>
<Badge variant="outline" className="text-xs">
{t(`audit.areas.${f.area}`)}
</Badge>
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
{t("audit.incomplete")}
</Badge>}
{f.affected.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
{affectedDescription(f, t)}
</Badge>
)}
</div>
{(f.summary_key || notApplicableText(f)) && (
<p className="text-sm text-muted-foreground mt-1">
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
</p>
)}
{/* "Could not be evaluated" describes the assessment, not
the host. What could not be read is recorded against
each source, and belongs here rather than two
collapsed panels below. */}
{unreadSources(f.sources, t) && (
<p className="text-xs text-muted-foreground mt-1">
{unreadSources(f.sources, t)}
</p>
)}
</div>
</button>
{open && (
<CardContent className="pt-0 pl-11 space-y-4">
{optional(`audit.checks.${f.check_id}.explanation`) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatItMeans")}
</p>
<p className="text-sm text-foreground">
{optional(`audit.checks.${f.check_id}.explanation`)}
</p>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.why")}
</p>
<p className="text-sm text-foreground">
{t(`audit.checks.${f.check_id}.rationale`)}
</p>
</div>
{nextStepOf(f) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatToDo")}
</p>
<p className="text-sm text-foreground">{nextStepOf(f)}</p>
</div>
)}
{f.exception && (
<div className="rounded-md border border-border bg-background p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.acceptedRisk")}
</p>
<p className="text-sm text-foreground">{f.exception.reason}</p>
<p className="text-xs text-muted-foreground mt-1">
{f.exception.accepted_by} ·{" "}
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
{f.exception.expires_at && <> · {t("audit.expires", {
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
})}</>}
{f.exception.review_at && <> · {t("audit.reviewOn", {
when: new Date(f.exception.review_at * 1000).toLocaleDateString(),
})}</>}
</p>
{!!f.exception.review_at && f.exception.review_at * 1000 <= Date.now() && (
<p className="text-xs text-amber-500 mt-1">{t("audit.reviewDue")}</p>
)}
</div>
)}
{/* Some checks carry a useful, structured positive reading
in their evidence even when nothing is affected. The
presenter returns no groups for checks without such a
view, so rendering it unconditionally does not create
empty space. */}
<AuditFindingData finding={f} t={t} locale={language} />
{f.evidence && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground mb-2">
{t("audit.presentation.technical")}
</summary>
<AuditEvidence evidence={f.evidence} locale={language} />
</details>
)}
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
<ul className="mt-2 space-y-1">
{f.sources.map((source) => <li key={source.source} className="break-all">
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
{source.error && <span className="text-amber-500"> · {source.error}</span>}
</li>)}
</ul>
</details>}
{/* Only an active finding can be accepted, and only an
accepted one can be returned to the active set. */}
{(f.classification === "critical" || f.classification === "warning")
&& !f.decision && !f.incomplete && (
<Button
variant="outline"
size="sm"
onClick={() => { setAccepting(f); setReason(""); setExpiryDays(""); setReviewDays("") }}
>
<ShieldOff className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.action")}
</Button>
)}
{f.decision === "accepted" && (
<Button
variant="outline"
size="sm"
onClick={() => revokeAcceptance(f.check_id)}
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.revoke")}
</Button>
)}
</CardContent>
)}
</Card>
)
})}
</div>
<Dialog open={lynisPrompt !== null} onOpenChange={(o) => !o && setLynisPrompt(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.lynis.title")}</DialogTitle>
<DialogDescription>
{lynisPrompt?.stale
? t("audit.lynis.bodyStale", { days: String(lynisPrompt?.ageDays ?? "") })
: t("audit.lynis.bodyNotRun")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setLynisPrompt(null)}>
{t("audit.lynis.cancel")}
</Button>
<Button variant="outline" onClick={() => doRun(false)}>
{t("audit.lynis.withoutLynis")}
</Button>
<Button onClick={() => doRun(true)}>
{t("audit.lynis.withLynis")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.acceptRisk.title")}</DialogTitle>
<DialogDescription>
{accepting && t(`audit.checks.${accepting.check_id}.title`)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label htmlFor="audit-reason" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reasonLabel")}
</label>
{/* The reason is required, not encouraged. An acceptance
without one cannot be told apart later from having
silenced the check. */}
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reasonHelp")}
</p>
<textarea
id="audit-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("audit.acceptRisk.reasonPlaceholder")}
/>
</div>
<div>
<label htmlFor="audit-expiry" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.expiryLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.expiryHelp")}
</p>
<Select value={expiryDays || "none"}
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Separate from the expiry on purpose: this one asks to look at
the decision again without withdrawing it, so "remind me in a
year" no longer has to be spelled as "stop accepting this in a
year". */}
<div>
<label htmlFor="audit-review" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reviewLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reviewHelp")}
</p>
<Select value={reviewDays || "none"}
onValueChange={(v) => setReviewDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-review" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.reviewNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAccepting(null)}>
{t("audit.acceptRisk.cancel")}
</Button>
<Button
onClick={submitAcceptance}
disabled={!reason.trim() || saving}
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("audit.acceptRisk.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+450
View File
@@ -0,0 +1,450 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthSetupProps {
onComplete: () => void
}
export function AuthSetup({ onComplete }: AuthSetupProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
// Profile (Fase 2 — v1.2.2). Both optional decorations on top of the
// mandatory username + password. Persisted via PUT /api/auth/profile
// and POST /api/auth/profile/avatar after the user lands a successful
// /api/auth/setup so we don't change the setup endpoint's contract.
const [displayName, setDisplayName] = useState("")
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const checkOnboardingStatus = async () => {
try {
const response = await fetch(getApiUrl("/api/auth/status"))
// Check if response is valid JSON before parsing
if (!response.ok) {
// API not available - don't show modal in preview
return
}
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
return
}
const data = await response.json()
// Show modal if auth is not configured and not declined
if (!data.auth_configured) {
setTimeout(() => setOpen(true), 500)
}
} catch {
// API not available (preview environment) - don't show modal
}
}
checkOnboardingStatus()
}, [])
const handleSkipAuth = async () => {
setLoading(true)
setError("")
try {
const response = await fetch(getApiUrl("/api/auth/skip"), {
method: "POST",
headers: { "Content-Type": "application/json" },
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || t("authSetup.skipFailed"))
}
if (data.auth_declined) {
}
localStorage.setItem("proxmenux-auth-declined", "true")
localStorage.removeItem("proxmenux-auth-token") // Remove any old token
setOpen(false)
onComplete()
} catch (err) {
console.error("Auth skip error:", err)
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
} finally {
setLoading(false)
}
}
const handleAvatarPick = () => fileInputRef.current?.click()
const handleAvatarChange = (file: File | null) => {
// Revoke the previous local preview so we don't leak blob URLs while
// the user picks another file before submitting.
if (avatarPreviewUrl) {
URL.revokeObjectURL(avatarPreviewUrl)
}
setAvatarFile(file)
setAvatarPreviewUrl(file ? URL.createObjectURL(file) : null)
}
const handleSetupAuth = async () => {
setError("")
if (!username || !password) {
setError(t("authSetup.fillFields"))
return
}
if (password !== confirmPassword) {
setError(t("authSetup.passwordMismatch"))
return
}
if (password.length < 6) {
setError(t("authSetup.passwordTooShort"))
return
}
setLoading(true)
try {
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
password,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || t("authSetup.setupFailed"))
}
if (data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
localStorage.removeItem("proxmenux-auth-declined")
}
// Profile decorations (Fase 2). Sent as a follow-up to the setup
// call so the /api/auth/setup endpoint stays minimal (username +
// password only) — these calls reuse the existing profile
// endpoints and the JWT we just received. Failures here are
// non-fatal: the user is already authenticated and can finish
// configuring the profile from the /profile page.
const token = data.token
if (token) {
const trimmedDisplayName = displayName.trim()
if (trimmedDisplayName) {
try {
await fetch(getApiUrl("/api/auth/profile"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ display_name: trimmedDisplayName }),
})
} catch (e) {
console.warn("[auth-setup] failed to save display_name:", e)
}
}
if (avatarFile) {
try {
await fetch(getApiUrl("/api/auth/profile/avatar"), {
method: "POST",
headers: {
"Content-Type": avatarFile.type,
Authorization: `Bearer ${token}`,
},
body: avatarFile,
})
} catch (e) {
console.warn("[auth-setup] failed to upload avatar:", e)
}
}
}
// Release the local preview blob now that the file has been
// uploaded (or skipped). The header avatar pulls a fresh copy
// from the backend.
if (avatarPreviewUrl) {
URL.revokeObjectURL(avatarPreviewUrl)
setAvatarPreviewUrl(null)
}
// Notify the header AvatarMenu (mounted on dashboard load with
// auth_enabled=false) to re-fetch its status + profile so the
// avatar appears immediately after first-time setup instead of
// requiring a page refresh.
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("proxmenux:profile-changed"))
}
setOpen(false)
onComplete()
} catch (err) {
console.error("Auth setup error:", err)
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
</DialogTitle>
{step === "choice" ? (
<div className="space-y-6 py-2">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
<p className="text-muted-foreground text-sm">
{t("authSetup.protectDescription")}
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
{t("authSetup.setupPassword")}
</Button>
<Button
onClick={handleSkipAuth}
variant="outline"
className="w-full bg-transparent"
size="lg"
disabled={loading}
>
{t("authSetup.skipProtection")}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
</div>
) : (
<div className="space-y-6 py-2">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-sm">
{t("authSetup.username")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder={t("authSetup.usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 text-base"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm">
{t("authSetup.password")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t("authSetup.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 text-base"
disabled={loading}
autoComplete="new-password"
/>
<Button
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
disabled={loading}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm">
{t("authSetup.confirmPassword")}
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder={t("authSetup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 text-base"
disabled={loading}
autoComplete="new-password"
/>
<Button
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
disabled={loading}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Optional profile decorations (Fase 2). Visually
separated from the mandatory credential fields by a
divider + a small heading so the operator understands
they can skip everything below and still complete the
setup. Both are saved with follow-up calls after the
setup endpoint returns the JWT. */}
<div className="pt-3 border-t border-border/60 space-y-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
{t("authSetup.profileOptional")}
</p>
<div className="space-y-2">
<Label htmlFor="display-name" className="text-sm">
{t("authSetup.displayName")}
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="display-name"
type="text"
placeholder={t("authSetup.displayNamePlaceholder")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={64}
className="pl-10 text-base"
disabled={loading}
/>
</div>
<p className="text-[11px] text-muted-foreground">
{t("authSetup.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">{t("authSetup.avatar")}</Label>
<div className="flex items-center gap-3">
{avatarPreviewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarPreviewUrl}
alt=""
className="w-14 h-14 rounded-full object-cover border border-border bg-cyan-500/5 shrink-0"
/>
) : (
<span className="w-14 h-14 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-xl font-semibold border border-border shrink-0">
{(displayName || username || "U").trim().charAt(0).toUpperCase() || "U"}
</span>
)}
<div className="flex flex-col gap-1.5 min-w-0">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0] || null
handleAvatarChange(file)
if (fileInputRef.current) fileInputRef.current.value = ""
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAvatarPick}
disabled={loading}
className="h-7 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
</Button>
{avatarFile && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAvatarChange(null)}
disabled={loading}
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3 mr-1.5" />
{t("authSetup.clear")}
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{t("authSetup.avatarHint")}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
{t("authSetup.back")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
+284
View File
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useState } from "react"
import { User, Shield, LogOut } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface AuthStatus {
auth_enabled?: boolean
username?: string | null
}
interface ProfileData {
success: boolean
username?: string | null
display_name?: string | null
has_avatar?: boolean
avatar_mtime?: number | null
}
interface AvatarMenuProps {
/** Size of the avatar circle in the header trigger. */
size?: "md" | "lg"
/**
* Callback used by the Security menu item. The Monitor renders its
* Settings/Security panels inside the same dashboard route, not on
* a separate URL, so navigation is handled by the parent that knows
* how to switch tabs. Optional — when omitted the menu item is hidden.
*/
onOpenSecurity?: () => void
/**
* Callback for "View profile". Same rationale: the parent decides how
* to route there (modal, page, tab switch). Until Fase 2 lands the
* caller typically passes an alert/toast that the page is coming.
*/
onOpenProfile?: () => void
}
/**
* AvatarMenu — user/account dropdown for the header.
*
* Self-fetches the current auth status to derive the username and the
* initial that fills the avatar circle. Stays silent (renders nothing)
* when authentication is disabled on this install — no point showing
* an account menu for a "Sign out" that doesn't apply.
*
* Sign out clears the token from localStorage and reloads, mirroring
* the existing `handleLogout` in `security.tsx`. That keeps a single
* source of truth for the logout flow until Fase 2 introduces a
* proper /api/auth/logout that revokes the JWT server-side too.
*/
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
const t = useT()
// IMPORTANT — all hooks must run unconditionally on every render. The
// previous version short-circuited with `if (!auth_enabled) return null`
// BEFORE the avatar blob hooks, so the hook count changed between
// renders the moment auth status loaded → React error #310 ("rendered
// more hooks than during the previous render"). All `useState` and
// `useEffect` calls now live above any early return; the null branch
// is at the very end after the hooks.
const [status, setStatus] = useState<AuthStatus | null>(null)
const [profile, setProfile] = useState<ProfileData | null>(null)
const [open, setOpen] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
// Load both auth_status (to decide whether to render at all) and the
// profile (to render display_name + avatar). Profile is fetched only
// when auth is enabled — saves one roundtrip on installs without
// auth where the menu won't show anyway.
useEffect(() => {
let cancelled = false
fetchApi<AuthStatus>("/api/auth/status")
.then(data => {
if (cancelled) return
setStatus(data)
if (data?.auth_enabled && data?.username) {
fetchApi<ProfileData>("/api/auth/profile")
.then(p => {
if (!cancelled) setProfile(p)
})
.catch(() => {
// Profile fetch is best-effort. Falls back to username + initials.
})
}
})
.catch(() => {
if (!cancelled) setStatus(null)
})
// Reload status + profile when the user updates the profile from
// the /profile page OR completes first-time auth setup. Refreshing
// status is what flips the menu visible after setup (when the
// initial mount saw auth_enabled=false); refreshing profile is
// what makes a new avatar/display name appear without a full
// browser refresh.
const handler = () => {
fetchApi<AuthStatus>("/api/auth/status")
.then(s => {
if (cancelled) return
setStatus(s)
if (s?.auth_enabled && s?.username) {
fetchApi<ProfileData>("/api/auth/profile")
.then(p => {
if (!cancelled) setProfile(p)
})
.catch(() => {})
}
})
.catch(() => {})
}
if (typeof window !== "undefined") {
window.addEventListener("proxmenux:profile-changed", handler)
}
return () => {
cancelled = true
if (typeof window !== "undefined") {
window.removeEventListener("proxmenux:profile-changed", handler)
}
}
}, [])
// Avatar fetch — the endpoint requires the Bearer header, which
// <img src=…> can't send, so we fetch as a blob and convert it to a
// local object URL for rendering. The blob URL is revoked on cleanup
// and on every refetch to avoid leaking memory.
useEffect(() => {
let cancelled = false
let currentBlobUrl: string | null = null
if (profile?.has_avatar) {
const token = getAuthToken()
const url = `${getApiUrl("/api/auth/profile/avatar")}?v=${profile.avatar_mtime || ""}`
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
.then(r => (r.ok ? r.blob() : null))
.then(blob => {
if (cancelled || !blob) return
currentBlobUrl = URL.createObjectURL(blob)
setAvatarBlobUrl(currentBlobUrl)
})
.catch(() => {
if (!cancelled) setAvatarBlobUrl(null)
})
} else {
setAvatarBlobUrl(null)
}
return () => {
cancelled = true
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl)
}
}, [profile?.has_avatar, profile?.avatar_mtime])
// ── Hooks finished. Safe to early-return now. ──
// Hide the avatar entirely when auth isn't enabled on this install —
// there's no user identity to surface and no Sign out to offer.
if (!status?.auth_enabled || !status?.username) return null
const username = status.username
const displayName = profile?.display_name || username
const initial = displayName.trim().charAt(0).toUpperCase() || "U"
const handleSignOut = () => {
try {
localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete")
} catch {
// localStorage may be unavailable (private mode); fall through.
}
window.location.reload()
}
// Avatar size in the header trigger. The trigger has no chevron now —
// removing it freed enough horizontal space to bump the avatar a
// notch up (40 → 44 / 32 → 36) without nudging the Refresh / Theme
// buttons sitting to its left.
const avatarSize = size === "lg" ? "w-11 h-11 text-lg" : "w-9 h-9 text-sm"
return (
<>
{/* Backdrop overlay — dim only (no blur). Mounted while the
dropdown is open. `bg-black/40` dims the page enough to focus
attention on the dropdown without distorting the content
behind, which testers found annoying when full backdrop blur
was used (especially on wider desktop viewports). `z-40`
places it above the dashboard content but below the dropdown
portal (`DropdownMenuContent` lands on z-[60]) and below the
header (which stays on z-50 so the avatar trigger remains
clickable). Clicking the backdrop closes the menu — the
explicit `onClick` mirrors Radix's outside-click handler. */}
{open && (
<div
aria-hidden="true"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 bg-black/40 animate-in fade-in-0 duration-150"
/>
)}
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
aria-label={t("actions.openUserMenu")}
// WebKit ignores `outline` for the tap-highlight overlay
// shown on iOS / Android Chrome after a touch. That overlay
// was the white border that lingered on the avatar after
// dismissing the dropdown without picking anything. Setting
// `-webkit-tap-highlight-color` to transparent suppresses
// it without affecting keyboard focus visibility (handled
// separately by `focus-visible:outline-none` above).
style={{ WebkitTapHighlightColor: "transparent" }}
>
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className={`${avatarSize} rounded-full object-cover bg-cyan-500/10`}
/>
) : (
<span
className={`${avatarSize} rounded-full flex items-center justify-center font-semibold bg-cyan-500/15 text-cyan-600 dark:text-cyan-300`}
>
{initial}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 z-[60]">
<DropdownMenuLabel>
<div className="flex items-center gap-3 py-1">
{avatarBlobUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarBlobUrl}
alt=""
className="w-20 h-20 rounded-full object-cover bg-cyan-500/10 shrink-0"
/>
) : (
<span className="w-20 h-20 rounded-full bg-cyan-500/15 text-cyan-600 dark:text-cyan-300 flex items-center justify-center text-3xl font-semibold shrink-0">
{initial}
</span>
)}
<div className="min-w-0">
<div className="text-base font-semibold truncate">{displayName}</div>
{profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">{username}</div>
)}
{!profile?.display_name && (
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{onOpenProfile && (
<DropdownMenuItem onClick={onOpenProfile}>
<User className="h-4 w-4 mr-2" />
{t("account.viewProfile")}
</DropdownMenuItem>
)}
{onOpenSecurity && (
<DropdownMenuItem onClick={onOpenSecurity}>
<Shield className="h-4 w-4 mr-2" />
{t("account.security")}
</DropdownMenuItem>
)}
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={handleSignOut}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<LogOut className="h-4 w-4 mr-2" />
{t("account.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
"use client"
// The Backup tab: the backups of the host and the backup jobs of the VMs
// and LXCs, each in its own view.
import { useState } from "react"
import { DatabaseBackup } from "lucide-react"
import { HostBackup } from "./host-backup"
import { VmBackupJobs } from "./vm-backup-jobs"
import { useT } from "../lib/i18n/provider"
type BackupView = "host" | "guests"
export function BackupTab() {
const t = useT()
const [view, setView] = useState<BackupView>("host")
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<DatabaseBackup className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("navigation.backup")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div
role="group"
aria-label={t("backup.viewSwitch.ariaLabel")}
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["host", "guests"] as const).map((key) => (
<button
key={key}
type="button"
aria-pressed={view === key}
onClick={() => setView(key)}
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
view === key
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`backup.viewSwitch.${key}`)}
</button>
))}
</div>
</div>
</div>
{/* The host view stays mounted: it follows a running backup or restore. */}
<div className={view === "host" ? "" : "hidden"}>
<HostBackup />
</div>
{view === "guests" && <VmBackupJobs />}
</div>
)
}
+308
View File
@@ -0,0 +1,308 @@
"use client"
import { useEffect, useState } from "react"
import { cancelButtonClass } from "../lib/utils"
import { Trash2 } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { getCategoryLabel } from "../lib/category-label"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
// Minimal shape we need from the /api/vms poll. Kept narrow so this
// component stays independent of the fuller VMData type used in
// virtual-machines.tsx.
export interface GuestOption {
vmid: number
name: string
type: "lxc" | "qemu"
}
export interface CustomLink {
id: string
name: string
url: string
logo_url: string
category: string
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
created_at?: number
updated_at?: number
}
export interface DraftCustomLink {
name: string
url: string
logo_url: string
category: string
bindingKey: string
}
const UNBOUND_KEY = "__none__"
function buildKey(binding: CustomLink["binding"]): string {
if (!binding) return UNBOUND_KEY
return `${binding.guest_type}:${binding.vmid}`
}
function parseKey(key: string): CustomLink["binding"] {
if (!key || key === UNBOUND_KEY) return null
const [type, vmid] = key.split(":")
if (type !== "lxc" && type !== "qemu") return null
const n = Number(vmid)
if (!Number.isFinite(n)) return null
return { guest_type: type, vmid: n }
}
export function CustomLinkEditor({
open,
onOpenChange,
editing,
guests,
categoryPresets,
onSaved,
}: {
open: boolean
onOpenChange: (v: boolean) => void
/** null = create; existing link = edit */
editing: CustomLink | null
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
guests: GuestOption[]
/** Populated from /api/apps/categories */
categoryPresets: string[]
/** Called on successful save/delete so the parent can refresh */
onSaved: () => void
}) {
const t = useT()
const [draft, setDraft] = useState<DraftCustomLink>({
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
})
const [customCategoryMode, setCustomCategoryMode] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset the draft whenever the modal opens with a new target.
useEffect(() => {
if (!open) return
setError(null)
if (editing) {
setDraft({
name: editing.name,
url: editing.url,
logo_url: editing.logo_url || "",
category: editing.category || "",
bindingKey: buildKey(editing.binding),
})
setCustomCategoryMode(
!!editing.category && !categoryPresets.includes(editing.category),
)
} else {
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
setCustomCategoryMode(false)
}
}, [open, editing, categoryPresets])
const canSave = draft.name.trim() && draft.url.trim() && !saving
const handleSave = async () => {
setError(null)
setSaving(true)
try {
const payload = {
name: draft.name.trim(),
url: draft.url.trim(),
logo_url: draft.logo_url.trim(),
category: draft.category.trim(),
binding: parseKey(draft.bindingKey),
}
if (editing) {
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
method: "PUT",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
} else {
await fetchApi("/api/apps/custom-links", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
})
}
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkSaveError"))
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!editing) return
setError(null)
setDeleting(true)
try {
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
onSaved()
onOpenChange(false)
} catch (e: any) {
setError((e && e.message) || t("apps.customLinkDeleteError"))
} finally {
setDeleting(false)
}
}
// Sort guests by vmid so the dropdown is easy to scan
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<DialogHeader>
<DialogTitle>
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
<Input
id="cl-name"
autoFocus
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder={t("apps.customLinkNamePlaceholder")}
maxLength={80}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
<Input
id="cl-url"
type="url"
value={draft.url}
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
placeholder="https://example.com"
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
<Input
id="cl-logo"
type="url"
value={draft.logo_url}
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
placeholder={t("apps.customLinkLogoPlaceholder")}
maxLength={512}
className="text-sm font-mono"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
{customCategoryMode ? (
<Input
autoFocus
value={draft.category}
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
maxLength={60}
className="text-sm"
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
/>
) : (
<Select
value={draft.category || "__none__"}
onValueChange={(v) => {
if (v === "__add__") {
setCustomCategoryMode(true)
setDraft((d) => ({ ...d, category: "" }))
} else if (v === "__none__") {
setDraft((d) => ({ ...d, category: "" }))
} else {
setDraft((d) => ({ ...d, category: v }))
}
}}
>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
{categoryPresets.map((c) => (
<SelectItem key={c} value={c}>{getCategoryLabel(t, c)}</SelectItem>
))}
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="grid gap-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
<Select
value={draft.bindingKey}
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
>
<SelectTrigger className="text-sm h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
{sortedGuests.map((g) => (
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
{g.type === "qemu" ? "VM" : "LXC"} {g.vmid} · {g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("apps.customLinkBindingHelp")}
</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
{error}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{editing ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleting || saving}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{t("apps.customLinkDelete")}
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="outline" className={cancelButtonClass} onClick={() => onOpenChange(false)} disabled={saving}>
{t("apps.customLinkCancel")}
</Button>
<Button
onClick={handleSave}
disabled={!canSave}
className="bg-blue-500 hover:bg-blue-600 !text-white"
>
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+264
View File
@@ -0,0 +1,264 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Check, HardDrive, Info, Loader2, Settings2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Switch } from "./ui/switch"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DiskEntry {
name: string
key: string
model: string
serial: string
size_bytes: number
transport: string
rotational: boolean
excluded: boolean
excluded_at: string | null
idle: boolean
present: boolean
}
function formatSize(bytes: number): string {
if (!bytes) return ""
const units = ["B", "KB", "MB", "GB", "TB", "PB"]
let value = bytes
let i = 0
while (value >= 1000 && i < units.length - 1) {
value /= 1000
i++
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
// Disks the user wants left alone. An excluded disk is never read on a
// schedule — no temperature, no SMART refresh, not even a power-mode query —
// so it can spin down on its own timer. Rotational disks with no I/O are
// already left alone automatically; this is for the ones that should never
// be touched at all, such as a drive handed whole to a VM.
export function DiskExclusions() {
const t = useT()
const [disks, setDisks] = useState<DiskEntry[]>([])
const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false)
const [pending, setPending] = useState<Map<string, boolean>>(new Map())
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState("")
const load = useCallback(async () => {
try {
const data = await fetchApi<{ disks: DiskEntry[] }>("/api/health/disks")
setDisks(data.disks || [])
} catch {
setDisks([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const cancel = () => {
setPending(new Map())
setError("")
setEditMode(false)
}
const save = async () => {
if (pending.size === 0) {
setEditMode(false)
return
}
setSaving(true)
setError("")
try {
for (const [key, excluded] of pending.entries()) {
const disk = disks.find((d) => d.key === key)
if (!disk) continue
if (excluded) {
await fetchApi("/api/health/disk-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
disk_key: disk.key,
disk_name: disk.name,
model: disk.model,
serial: disk.serial,
}),
})
} else {
await fetchApi(`/api/health/disk-exclusions/${encodeURIComponent(disk.key)}`, {
method: "DELETE",
})
}
}
setPending(new Map())
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
await load()
} catch {
setError(t("settings.diskExclusions.saveFailed"))
} finally {
setSaving(false)
}
}
const transportLabel = (disk: DiskEntry) => {
const tr = disk.transport.toLowerCase()
if (tr === "usb") return "USB"
if (tr === "nvme") return "NVMe"
if (tr === "sata" || tr === "ata") return disk.rotational ? "HDD" : "SSD"
if (tr) return tr.toUpperCase()
return disk.rotational ? "HDD" : "SSD"
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-500" />
<CardTitle>{t("settings.diskExclusions.title")}</CardTitle>
</div>
{!loading && disks.length > 0 && (
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={cancel}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={save}
disabled={saving || pending.size === 0}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
)}
</div>
<CardDescription>{t("settings.diskExclusions.description")}</CardDescription>
</CardHeader>
<CardContent className={editMode ? "bg-accent" : undefined}>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
) : disks.length === 0 ? (
<div className="text-center py-8">
<HardDrive className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">{t("settings.diskExclusions.empty")}</p>
</div>
) : (
<div className="space-y-0">
<div className="grid grid-cols-[1fr_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">
{t("settings.diskExclusions.disk")}
</span>
<span className="text-xs font-medium text-muted-foreground text-center w-24">
{t("settings.diskExclusions.periodicReads")}
</span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border/50">
{disks.map((disk) => {
const excluded = pending.has(disk.key) ? pending.get(disk.key)! : disk.excluded
return (
<div key={disk.key} className="grid grid-cols-[1fr_auto] gap-4 py-3 items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`font-medium ${excluded ? "text-muted-foreground" : ""}`}>
{disk.name || "—"}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{transportLabel(disk)}
</Badge>
{excluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
{t("settings.diskExclusions.excluded")}
</Badge>
)}
{!excluded && disk.idle && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
title={t("storage.idleTitle")}
>
{t("storage.idle")}
</Badge>
)}
{!disk.present && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
{t("settings.diskExclusions.notConnected")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground break-all">
{[disk.model, formatSize(disk.size_bytes), disk.serial].filter(Boolean).join(" · ")}
</span>
</div>
<div className="flex justify-center w-24">
<Switch
checked={!excluded}
disabled={!editMode || saving}
onCheckedChange={(checked) => {
setPending((m) => {
const next = new Map(m)
if (!checked === disk.excluded) next.delete(disk.key)
else next.set(disk.key, !checked)
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
/>
</div>
</div>
)
})}
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("settings.diskExclusions.help")}
<br />
{t("settings.diskExclusions.idleHelp")}
</p>
</div>
</div>
)}
</CardContent>
</Card>
)
}

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