diff --git a/AppImage/components/secure-gateway-setup.tsx b/AppImage/components/secure-gateway-setup.tsx
index cef8e1e6..001b1f1b 100644
--- a/AppImage/components/secure-gateway-setup.tsx
+++ b/AppImage/components/secure-gateway-setup.tsx
@@ -162,6 +162,19 @@ export function SecureGatewaySetup() {
loadInitialData()
}, [])
+ // The gateway was updated from the Updates tab of its container: read the
+ // update state and the status again so this card agrees with it.
+ useEffect(() => {
+ const refresh = (event: Event) => {
+ const detail = (event as CustomEvent).detail || {}
+ if (detail.appId !== "secure-gateway" || detail.source === "card") return
+ void loadUpdateInfo(true)
+ void loadStatus()
+ }
+ window.addEventListener("proxmenuxManagedAppUpdated", refresh)
+ return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
+ }, [])
+
const loadInitialData = async () => {
setLoading(true)
setLoadError(null)
@@ -288,6 +301,8 @@ export function SecureGatewaySetup() {
// Status may briefly show "stopped" if tailscale was restarted —
// refresh that too so the action buttons render the right state.
await loadStatus()
+ // The Updates tab of the gateway container reads it again.
+ window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId: "secure-gateway", source: "card" } }))
} else {
setUpdateError(res?.message || sg("errors.updateFailed"))
}
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx
index 356ac46b..14152cd6 100644
--- a/AppImage/components/virtual-machines.tsx
+++ b/AppImage/components/virtual-machines.tsx
@@ -1102,6 +1102,14 @@ export function VirtualMachines() {
return () => window.removeEventListener("openLxcAppModal", handler as EventListener)
}, [vmData])
+ // An application ProxMenux manages (Secure Gateway) was updated or checked
+ // from its own card: read the guests again so its Updates tab agrees.
+ useEffect(() => {
+ const refresh = () => { mutate() }
+ window.addEventListener("proxmenuxManagedAppUpdated", refresh)
+ return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
+ }, [mutate])
+
// Same deep-link but for QEMU guests. VMs don't have the App tab,
// so we land on Status (which is what handleVMClick already
// defaults to — no override needed).
@@ -5057,6 +5065,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
ctIp={ctIp}
onChange={() => mutate()}
initialData={getLxcAppsCached(selectedVM.vmid) ?? null}
+ oci={ociInstance?.oci_instance ? {
+ instance: true,
+ memberOf: ociInstance.stack && ociInstance.primary_vmid !== selectedVM.vmid
+ ? (() => {
+ const primaryVM = (vmData || []).find((v) => v.vmid === ociInstance.primary_vmid)
+ return {
+ vmid: ociInstance.primary_vmid,
+ label: primaryVM
+ ? `${primaryVM.name} (CT ${ociInstance.primary_vmid})`
+ : `CT ${ociInstance.primary_vmid}`,
+ }
+ })()
+ : null,
+ } : null}
managed={
managedEntry
? {
@@ -5182,6 +5204,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
onClick={async () => {
try {
await fetchApi(`/api/oci/installed/${appId}/update`, { method: "POST" })
+ // The card of this application on the Security page reads it again.
+ window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId } }))
mutate()
} catch { /* opening the App tab surfaces the error */ }
}}
diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json
index f2539122..de5276c9 100644
--- a/AppImage/messages/de/common.json
+++ b/AppImage/messages/de/common.json
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway-Update verfügbar",
"nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar",
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar",
+ "oci_update_completed": "OCI-Anwendung aktualisiert",
+ "oci_update_failed": "Aktualisierung der OCI-Anwendung fehlgeschlagen",
+ "oci_recreate_completed": "OCI-Anwendung neu erstellt",
+ "oci_recreate_failed": "Neuerstellung der OCI-Anwendung fehlgeschlagen",
"app_update_available": "App-Update verfügbar",
"lxc_update_applied": "LXC Update angewendet",
"docker_stack_update_available": "Docker Updates verfügbar"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC Update angewendet"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} aktualisiert",
+ "body": "{app_name} wurde auf das neue Image aktualisiert, die Daten wurden beibehalten.\nContainer: {containers}",
+ "label": "OCI-Anwendung aktualisiert"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: Aktualisierung von {app_name} nicht abgeschlossen",
+ "body": "Die Aktualisierung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
+ "label": "Aktualisierung der OCI-Anwendung fehlgeschlagen"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} neu erstellt",
+ "body": "{app_name} wurde mit den neuen Optionen neu erstellt, die Daten wurden beibehalten.\nContainer: {containers}",
+ "label": "OCI-Anwendung neu erstellt"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: Neuerstellung von {app_name} nicht abgeschlossen",
+ "body": "Die Neuerstellung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
+ "label": "Neuerstellung der OCI-Anwendung fehlgeschlagen"
+ },
"app_update_available": {
"title": "{hostname}: {app_name} Update verfügbar auf CT {vmid}",
"body": "{app_name} auf CT {vmid} ({ct_name}) hat eine neue Version:\n {installed} → {latest}",
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index 28b9724e..1b45e840 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -1618,6 +1618,8 @@
"hiddenDetectionsHelpPlural": "You have {count} hidden detections. Restore one to bring it back, or register a custom app manually.",
"registerCustom": "Register a custom app",
"noAppsTitle": "No applications registered",
+ "stackMemberTitle": "Part of a multi-container application",
+ "stackMemberBody": "This container supports an application whose main container is {primary}. The application, its web links and its version are shown there.",
"noAppsBody": "Register each application running in this container. Get clickable web links, and — optionally — upstream version tracking + notifications for new releases.",
"registerApplication": "Register application",
"searchApplications": "Find applications",
@@ -1984,6 +1986,10 @@
"update_summary": "Host package updates",
"pve_update": "Proxmox VE update available",
"update_complete": "Host update completed",
+ "oci_update_completed": "OCI application updated",
+ "oci_update_failed": "OCI application update failed",
+ "oci_recreate_completed": "OCI application recreated",
+ "oci_recreate_failed": "OCI application recreation failed",
"app_update_available": "App update available",
"ai_model_migrated": "AI model updated automatically",
"proxmenux_update": "ProxMenux update available",
@@ -6251,5 +6257,5 @@
"cancel": "Cancel"
}
},
- "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}\nThe node is now fully ready to use.","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or run the post-install menu (option 2) → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Settings > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}}
+ "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}\nThe node is now fully ready to use.","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or run the post-install menu (option 2) → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Settings > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}}
}
diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json
index f11f208f..65f2a19c 100644
--- a/AppImage/messages/es/common.json
+++ b/AppImage/messages/es/common.json
@@ -1601,6 +1601,8 @@
"hiddenDetectionsHelpPlural": "Tienes {count} detecciones ocultas. Restaure una para recuperarla o registre una aplicación personalizada manualmente.",
"registerCustom": "Registrar una aplicación personalizada",
"noAppsTitle": "No hay aplicaciones registradas",
+ "stackMemberTitle": "Forma parte de una aplicación multicontenedor",
+ "stackMemberBody": "Este contenedor da servicio a una aplicación cuyo contenedor principal es {primary}. La aplicación, sus enlaces web y su versión se muestran allí.",
"noAppsBody": "Registre las aplicaciones que se ejecutan en este contenedor. Obtendrá enlaces web y, opcionalmente, seguimiento de versiones disponibles y notificaciones de nuevos lanzamientos.",
"registerApplication": "Registrar aplicación",
"searchApplications": "Buscar aplicaciones",
@@ -1983,6 +1985,10 @@
"update_summary": "Actualizaciones de paquetes del host",
"pve_update": "Actualización de Proxmox VE disponible",
"update_complete": "Actualización del host completada",
+ "oci_update_completed": "Aplicación OCI actualizada",
+ "oci_update_failed": "Actualización de aplicación OCI fallida",
+ "oci_recreate_completed": "Aplicación OCI recreada",
+ "oci_recreate_failed": "Recreación de aplicación OCI fallida",
"app_update_available": "Actualización de app disponible",
"ai_model_migrated": "Modelo de IA actualizado automáticamente",
"proxmenux_update": "Actualización de ProxMenux disponible",
@@ -6290,6 +6296,26 @@
"body": "{details}",
"label": "Actualización LXC aplicada"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} actualizado",
+ "body": "{app_name} se ha actualizado a su nueva imagen y sus datos se han conservado.\nContenedores: {containers}",
+ "label": "Aplicación OCI actualizada"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: la actualización de {app_name} no se completó",
+ "body": "La actualización de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
+ "label": "Actualización de aplicación OCI fallida"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} recreado",
+ "body": "{app_name} se ha recreado con sus nuevas opciones y sus datos se han conservado.\nContenedores: {containers}",
+ "label": "Aplicación OCI recreada"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: la recreación de {app_name} no se completó",
+ "body": "La recreación de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
+ "label": "Recreación de aplicación OCI fallida"
+ },
"app_update_available": {
"title": "{hostname}: actualización de {app_name} disponible en CT {vmid}",
"body": "{app_name} en CT {vmid} ({ct_name}) tiene una nueva versión:\n {installed} → {latest}",
diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json
index 0cce8b90..68cc95f3 100644
--- a/AppImage/messages/fr/common.json
+++ b/AppImage/messages/fr/common.json
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Mise à jour de Secure Gateway disponible",
"nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible",
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible",
+ "oci_update_completed": "Application OCI mise à jour",
+ "oci_update_failed": "Échec de la mise à jour de l'application OCI",
+ "oci_recreate_completed": "Application OCI recréée",
+ "oci_recreate_failed": "Échec de la recréation de l'application OCI",
"app_update_available": "mise à jour de l'application disponible",
"lxc_update_applied": "Mise à jour LXC appliquée",
"docker_stack_update_available": "Docker mises à jour disponibles"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Mise à jour LXC appliquée"
},
+ "oci_update_completed": {
+ "title": "{hostname} : {app_name} mis à jour",
+ "body": "{app_name} a été mis à jour vers sa nouvelle image et ses données ont été conservées.\nConteneurs : {containers}",
+ "label": "Application OCI mise à jour"
+ },
+ "oci_update_failed": {
+ "title": "{hostname} : la mise à jour de {app_name} n'a pas abouti",
+ "body": "La mise à jour de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
+ "label": "Échec de la mise à jour de l'application OCI"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname} : {app_name} recréé",
+ "body": "{app_name} a été recréé avec ses nouvelles options et ses données ont été conservées.\nConteneurs : {containers}",
+ "label": "Application OCI recréée"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname} : la recréation de {app_name} n'a pas abouti",
+ "body": "La recréation de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
+ "label": "Échec de la recréation de l'application OCI"
+ },
"app_update_available": {
"title": "{hostname} : mise à jour {app_name} disponible sur CT {vmid}",
"body": "{app_name} sur CT {vmid} ({ct_name}) a une nouvelle version :\n {installed} → {latest}",
diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json
index 30b699b8..02da91c1 100644
--- a/AppImage/messages/it/common.json
+++ b/AppImage/messages/it/common.json
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile",
"nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile",
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU",
+ "oci_update_completed": "Applicazione OCI aggiornata",
+ "oci_update_failed": "Aggiornamento dell'applicazione OCI non riuscito",
+ "oci_recreate_completed": "Applicazione OCI ricreata",
+ "oci_recreate_failed": "Ricreazione dell'applicazione OCI non riuscita",
"app_update_available": "aggiornamento dell'app disponibile",
"lxc_update_applied": "Aggiornamento LXC applicato",
"docker_stack_update_available": "Aggiornamenti Docker disponibili"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Aggiornamento LXC applicato"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} aggiornato",
+ "body": "{app_name} è stato aggiornato alla nuova immagine e i dati sono stati conservati.\nContenitori: {containers}",
+ "label": "Applicazione OCI aggiornata"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: aggiornamento di {app_name} non completato",
+ "body": "L'aggiornamento di {app_name} non è stato completato.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
+ "label": "Aggiornamento dell'applicazione OCI non riuscito"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} ricreato",
+ "body": "{app_name} è stato ricreato con le nuove opzioni e i dati sono stati conservati.\nContenitori: {containers}",
+ "label": "Applicazione OCI ricreata"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: ricreazione di {app_name} non completata",
+ "body": "La ricreazione di {app_name} non è stata completata.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
+ "label": "Ricreazione dell'applicazione OCI non riuscita"
+ },
"app_update_available": {
"title": "{hostname}: aggiornamento {app_name} disponibile su CT {vmid}",
"body": "{app_name} su CT {vmid} ({ct_name}) ha una nuova versione:\n {installed} → {latest}",
diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json
index 63c395b0..93d3bd74 100644
--- a/AppImage/messages/pt/common.json
+++ b/AppImage/messages/pt/common.json
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Atualização do Secure Gateway disponível",
"nvidia_driver_update_available": "Atualização de driver NVIDIA disponível",
"coral_driver_update_available": "Atualização do driver Coral TPU disponível",
+ "oci_update_completed": "Aplicação OCI atualizada",
+ "oci_update_failed": "Falha na atualização da aplicação OCI",
+ "oci_recreate_completed": "Aplicação OCI recriada",
+ "oci_recreate_failed": "Falha na recriação da aplicação OCI",
"app_update_available": "atualização de aplicativo disponível",
"lxc_update_applied": "Atualização LXC aplicada",
"docker_stack_update_available": "Docker atualizações disponíveis"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Atualização LXC aplicada"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} atualizado",
+ "body": "{app_name} foi atualizado para a nova imagem e os dados foram mantidos.\nContêineres: {containers}",
+ "label": "Aplicação OCI atualizada"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: a atualização de {app_name} não foi concluída",
+ "body": "A atualização de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
+ "label": "Falha na atualização da aplicação OCI"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} recriado",
+ "body": "{app_name} foi recriado com as novas opções e os dados foram mantidos.\nContêineres: {containers}",
+ "label": "Aplicação OCI recriada"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: a recriação de {app_name} não foi concluída",
+ "body": "A recriação de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
+ "label": "Falha na recriação da aplicação OCI"
+ },
"app_update_available": {
"title": "{hostname}: atualização {app_name} disponível no CT {vmid}",
"body": "{app_name} no CT {vmid} ({ct_name}) tem uma nova versão:\n {installed} → {latest}",
diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json
index 1df90c62..5bc6105d 100644
--- a/AppImage/messages/sk/common.json
+++ b/AppImage/messages/sk/common.json
@@ -1997,6 +1997,10 @@
"secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway",
"nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA",
"coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU",
+ "oci_update_completed": "OCI aplikácia aktualizovaná",
+ "oci_update_failed": "Aktualizácia OCI aplikácie zlyhala",
+ "oci_recreate_completed": "OCI aplikácia znovu vytvorená",
+ "oci_recreate_failed": "Opätovné vytvorenie OCI aplikácie zlyhalo",
"app_update_available": "K dispozícii je aktualizácia aplikácie"
},
"ui": {
@@ -6289,6 +6293,26 @@
"body": "{details}",
"label": "Aktualizácia LXC použitá"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} aktualizovaná",
+ "body": "Aplikácia {app_name} bola aktualizovaná na nový obraz a jej dáta zostali zachované.\nKontajnery: {containers}",
+ "label": "OCI aplikácia aktualizovaná"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: aktualizácia {app_name} sa nedokončila",
+ "body": "Aktualizácia aplikácie {app_name} sa nedokončila.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
+ "label": "Aktualizácia OCI aplikácie zlyhala"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} znovu vytvorená",
+ "body": "Aplikácia {app_name} bola znovu vytvorená s novými možnosťami a jej dáta zostali zachované.\nKontajnery: {containers}",
+ "label": "OCI aplikácia znovu vytvorená"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: opätovné vytvorenie {app_name} sa nedokončilo",
+ "body": "Opätovné vytvorenie aplikácie {app_name} sa nedokončilo.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
+ "label": "Opätovné vytvorenie OCI aplikácie zlyhalo"
+ },
"app_update_available": {
"title": "{hostname}: Pre {app_name} je na CT {vmid} dostupná aktualizácia",
"body": "Aplikácia {app_name} na CT {vmid} ({ct_name}) má novú verziu:\n {installed} → {latest}",
diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json
index 3f0f4aa2..50fb767d 100644
--- a/AppImage/messages/sv/common.json
+++ b/AppImage/messages/sv/common.json
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig",
"nvidia_driver_update_available": "NVIDIA drivrutinsuppdatering tillgänglig",
"coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig",
+ "oci_update_completed": "OCI-applikation uppdaterad",
+ "oci_update_failed": "Uppdatering av OCI-applikation misslyckades",
+ "oci_recreate_completed": "OCI-applikation återskapad",
+ "oci_recreate_failed": "Återskapande av OCI-applikation misslyckades",
"app_update_available": "Appuppdatering tillgänglig",
"lxc_update_applied": "LXC uppdatering tillämpad",
"docker_stack_update_available": "Docker uppdateringar tillgängliga"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC uppdatering tillämpad"
},
+ "oci_update_completed": {
+ "title": "{hostname}: {app_name} uppdaterad",
+ "body": "{app_name} uppdaterades till sin nya avbild och dess data behölls.\nContainrar: {containers}",
+ "label": "OCI-applikation uppdaterad"
+ },
+ "oci_update_failed": {
+ "title": "{hostname}: uppdateringen av {app_name} slutfördes inte",
+ "body": "Uppdateringen av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
+ "label": "Uppdatering av OCI-applikation misslyckades"
+ },
+ "oci_recreate_completed": {
+ "title": "{hostname}: {app_name} återskapad",
+ "body": "{app_name} återskapades med sina nya alternativ och dess data behölls.\nContainrar: {containers}",
+ "label": "OCI-applikation återskapad"
+ },
+ "oci_recreate_failed": {
+ "title": "{hostname}: återskapandet av {app_name} slutfördes inte",
+ "body": "Återskapandet av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
+ "label": "Återskapande av OCI-applikation misslyckades"
+ },
"app_update_available": {
"title": "{hostname}: {app_name} uppdatering tillgänglig på CT {vmid}",
"body": "{app_name} på CT {vmid} ({ct_name}) har en ny version:\n {installed} → {latest}",
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index ccbeff2b..016f61aa 100755
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_console_logs.py not found"
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
+cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py
index 8b2ab255..61eb61b3 100644
--- a/AppImage/scripts/flask_notification_routes.py
+++ b/AppImage/scripts/flask_notification_routes.py
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
+# ─── Internal OCI Event Endpoint ─────────────────────────────────
+
+_OCI_EVENTS = {
+ 'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
+ 'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
+}
+
+
+@notification_bp.route('/api/internal/oci-event', methods=['POST'])
+def internal_oci_event():
+ """Called by the OCI engine when an update or a recreation ends, with its
+ result. Only accepts requests from this host."""
+ remote_addr = request.remote_addr or ''
+ try:
+ import ipaddress
+ addr = ipaddress.ip_address(remote_addr.split('%')[0])
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
+ addr = addr.ipv4_mapped
+ is_loopback = addr.is_loopback
+ except (ValueError, TypeError):
+ is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
+ if not is_loopback:
+ return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
+ try:
+ data = request.get_json(silent=True) or {}
+ event_type = str(data.get('event') or '')
+ if event_type not in _OCI_EVENTS:
+ return jsonify({'error': 'invalid_event_type'}), 400
+ vmid = str(data.get('vmid') or '')
+ notification_manager.emit_event(
+ event_type=event_type,
+ severity=_OCI_EVENTS[event_type],
+ data={
+ 'hostname': str(data.get('hostname') or 'unknown'),
+ 'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
+ 'vmid': vmid,
+ 'containers': str(data.get('containers') or '')[:400],
+ 'reason': str(data.get('reason') or '')[:600],
+ },
+ source='proxmenux',
+ entity='ct',
+ entity_id=vmid,
+ )
+ return jsonify({'success': True, 'event_type': event_type}), 200
+ except Exception as e:
+ return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
+
+
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
diff --git a/AppImage/scripts/flask_oci_routes.py b/AppImage/scripts/flask_oci_routes.py
index d27bf052..90aad9b9 100644
--- a/AppImage/scripts/flask_oci_routes.py
+++ b/AppImage/scripts/flask_oci_routes.py
@@ -545,6 +545,15 @@ def update_auth_key(app_id: str):
}), 500
+def _sync_managed_registry(app_id: str) -> None:
+ """Keep the Updates tab of the container in step with this page."""
+ try:
+ import managed_installs
+ managed_installs.refresh_oci_app(app_id)
+ except Exception as e:
+ logger.warning(f"Could not refresh the managed registry for {app_id}: {e}")
+
+
@oci_bp.route("/installed/
/update-check", methods=["GET"])
@require_auth
def installed_update_check(app_id: str):
@@ -558,6 +567,8 @@ def installed_update_check(app_id: str):
try:
force = request.args.get("force", "").lower() in ("1", "true", "yes")
result = oci_manager.check_app_update_available(app_id, force=force)
+ if force:
+ _sync_managed_registry(app_id)
return jsonify({"success": True, **result})
except Exception as e:
logger.error(f"Failed to check app update for {app_id}: {e}")
@@ -572,6 +583,8 @@ def installed_update_apply(app_id: str):
would cause an unnecessary brief disconnect."""
try:
result = oci_manager.update_app(app_id)
+ if result.get("success"):
+ _sync_managed_registry(app_id)
status_code = 200 if result.get("success") else 500
return jsonify(result), status_code
except Exception as e:
diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py
index 26d38470..23b6d7f1 100644
--- a/AppImage/scripts/health_monitor.py
+++ b/AppImage/scripts/health_monitor.py
@@ -3158,6 +3158,20 @@ class HealthMonitor:
print(f"[HealthMonitor] Disk/IO check failed: {e}")
return {'status': 'UNKNOWN', 'reason': f'Disk check unavailable: {str(e)}', 'checks': {}, 'dismissable': True}
+ @staticmethod
+ def _bridge_is_idle(interface: str, root: str = '/sys/class/net') -> bool:
+ """Whether a bridge is administratively up with no port attached.
+
+ Such a bridge reports no carrier, which is its normal state and not a
+ failure. A bridge that is set down, or one that has ports and still no
+ carrier, is not idle."""
+ try:
+ with open(f'{root}/{interface}/flags', encoding='ascii') as handle:
+ administratively_up = bool(int(handle.read().strip(), 16) & 0x1)
+ return administratively_up and not os.listdir(f'{root}/{interface}/brif')
+ except (OSError, ValueError):
+ return False
+
def _check_network_optimized(self) -> Dict[str, Any]:
"""
Optimized network check - only alerts for interfaces that are actually in use.
@@ -3206,6 +3220,18 @@ class HealthMonitor:
# Check if it's a bridge interface (always important for VMs/LXCs)
if interface.startswith('vmbr'):
+ if self._bridge_is_idle(interface):
+ # A bridge with no port attached has no carrier: the
+ # private network of an application whose containers
+ # are stopped, during an update for example. Nothing
+ # is down; nothing is connected to it.
+ interface_details[interface] = {
+ 'status': 'OK',
+ 'reason': 'Bridge without attached ports',
+ 'is_up': False,
+ }
+ health_persistence.resolve_error(interface, 'Bridge without attached ports')
+ continue
should_alert = True
alert_reason = 'Bridge interface DOWN (VMs/LXCs may be affected)'
diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py
index caed7219..ff82d334 100644
--- a/AppImage/scripts/lxc_apps.py
+++ b/AppImage/scripts/lxc_apps.py
@@ -4332,7 +4332,10 @@ def check_app(
pass
if app.get("installed_via") == "oci_image":
- result = _oci_image_versions(vmid, known=state)
+ # A secondary container of a stack shows the version it runs; the
+ # registry is not asked, because it is not updated on its own.
+ result = _oci_image_versions(
+ vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
@@ -5458,30 +5461,50 @@ def _dismiss_oci_registration(vmid) -> None:
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
+def _oci_secondary_member(record) -> bool:
+ """Whether the record belongs to a container of a stack other than its main one."""
+ if not isinstance(record, dict):
+ return False
+ member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
+ return member.get("primary_vmid") not in (None, record.get("vmid"))
+
+
def ensure_oci_registration(vmid) -> bool:
"""Register the application ProxMenux installed from an OCI image the
first time the Monitor sees its container, with version tracking by the
- image. The auxiliary members of a stack are not registered, nor is a
- container that already has applications, nor one whose registration the
- user removed."""
+ image. A secondary container of a stack, its database or its cache, is
+ registered with the version it runs and no tracking: the stack is updated
+ as a whole from its main container. A container that already has
+ applications is left alone, and so is one whose registration the user
+ removed."""
record = _read_oci_record(vmid)
if not record or record.get("status") != "installed":
return False
- member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
- if member.get("primary_vmid") not in (None, record.get("vmid")):
- return False
+ secondary = _oci_secondary_member(record)
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
return False
with _cache_lock:
sidecar = _read_sidecar(vmid)
if sidecar and sidecar.get("apps"):
+ # An application registered before its logo could be resolved
+ # takes it now; nothing else of what is registered is touched.
+ missing = [app for app in sidecar["apps"]
+ if app.get("installed_via") == "oci_image" and not app.get("logo_url")]
+ logo = (_oci_instance_meta(vmid) or {}).get("logo") if missing else ""
+ if logo:
+ for app in missing:
+ app["logo_url"] = logo
+ _write_sidecar(vmid, sidecar)
return False
meta = _oci_instance_meta(vmid)
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
return False
category = meta.get("category_label") or meta.get("category") or ""
endpoints = meta.get("endpoints") or []
- if not endpoints:
+ if secondary:
+ # A database or a cache is reached by the application, not by the user.
+ endpoints = []
+ elif not endpoints:
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"),
"description": "", "logo_url": ""}] if isinstance(port, int) else []
@@ -5543,6 +5566,15 @@ def _recheck_after_oci_operation(vmid, app_id: str) -> None:
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
+_OCI_ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
+# The services a stack runs beside its application, by the name of their image.
+_OCI_SERVICE_ICONS = {
+ name: f"{_OCI_ICON_BASE}/{icon}.webp"
+ for name, icon in (("postgres", "postgresql"), ("valkey", "valkey"), ("redis", "redis"),
+ ("mariadb", "mariadb"), ("mongo", "mongodb"), ("meilisearch", "meilisearch"))
+}
+
+
def _oci_instance_meta(vmid) -> Optional[dict]:
"""What ProxMenux itself recorded when it installed this container.
@@ -5623,12 +5655,27 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
endpoints.append(detail)
catalog_icons = _oci_catalog_icons()
logo = catalog_icons.get(template_id) or ""
+ repository = str(image.get("reference") or "").split("@", 1)[0]
+ basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
+ # A container of a stack records its own template id, `image-immich-server`,
+ # which the catalog does not list: the application it belongs to does.
+ stack_id = str(stack_template.get("id") or "").strip()
+ secondary = _oci_secondary_member(record)
+ if not stack_id and secondary:
+ primary = _read_oci_record(member.get("primary_vmid")) or {}
+ stack_id = str(((primary.get("stack") or {}).get("template") or {}).get("id") or "").strip()
+ application = stack_id.removeprefix("image-").removesuffix("-stack")
+ if not logo and stack_id and (not secondary or (application and basename.startswith(application))):
+ # The main container, or one that carries the application in its own
+ # name, such as Immich's machine learning.
+ logo = catalog_icons.get(stack_id) or ""
if not logo:
# A stack or a one-off image has no catalog entry of its own, but the
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
- repository = str(image.get("reference") or "").split("@", 1)[0]
- basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
logo = catalog_icons.get(basename) or ""
+ if not logo:
+ # The database or the cache beside an application.
+ logo = _OCI_SERVICE_ICONS.get(basename, "")
if not logo:
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py
index 1e39a3a5..6dd161f3 100644
--- a/AppImage/scripts/managed_installs.py
+++ b/AppImage/scripts/managed_installs.py
@@ -1716,6 +1716,27 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key]
+def refresh_oci_app(app_id: str) -> Optional[dict]:
+ """Read one OCI-managed application again and store what it reports.
+
+ Its card on the Security page and the Updates tab of its container read
+ different stores; after an update or a forced check from either one, the
+ registry is brought to the same state the application reports now."""
+ with _lock:
+ reg = _read_registry()
+ for it in reg.get("items", []):
+ if (it.get("type") != "oci_app" or it.get("removed_at")
+ or it.get("_oci_app_id") != app_id):
+ continue
+ result = _check_oci_app(it)
+ _store_update_result(it, result)
+ if result.get("current"):
+ it["current_version"] = result["current"]
+ _write_registry(reg)
+ return it
+ return None
+
+
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py
index 178bd087..9a19bf63 100644
--- a/AppImage/scripts/notification_manager.py
+++ b/AppImage/scripts/notification_manager.py
@@ -1275,6 +1275,16 @@ class NotificationManager:
if self._is_backup_running():
return
+ # The stop, the backup and the start of a container the OCI manager is
+ # updating or recreating are steps of that operation, which reports
+ # its own result when it ends.
+ try:
+ import oci_operations
+ if oci_operations.quiet(event):
+ return
+ except Exception:
+ pass
+
# Check storage exclusions for storage-related events.
# If the storage is excluded from notifications, suppress the event entirely.
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error',
diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py
index 2192c50f..20bdadfd 100644
--- a/AppImage/scripts/notification_templates.py
+++ b/AppImage/scripts/notification_templates.py
@@ -870,6 +870,44 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
+ 'oci_update_completed': {
+ 'title': '{hostname}: {app_name} updated',
+ 'body': '{app_name} was updated to its new image and its data was kept.\nContainers: {containers}',
+ 'label': 'OCI application updated',
+ 'group': 'vm_ct',
+ 'default_enabled': True,
+ },
+ 'oci_update_failed': {
+ 'title': '{hostname}: {app_name} update did not complete',
+ 'body': (
+ 'The update of {app_name} did not complete.\n'
+ 'Reason: {reason}\n'
+ 'Containers: {containers}\n'
+ 'Open Manage installed OCI applications to check its state.'
+ ),
+ 'label': 'OCI application update failed',
+ 'group': 'vm_ct',
+ 'default_enabled': True,
+ },
+ 'oci_recreate_completed': {
+ 'title': '{hostname}: {app_name} recreated',
+ 'body': '{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}',
+ 'label': 'OCI application recreated',
+ 'group': 'vm_ct',
+ 'default_enabled': True,
+ },
+ 'oci_recreate_failed': {
+ 'title': '{hostname}: {app_name} recreation did not complete',
+ 'body': (
+ 'The recreation of {app_name} did not complete.\n'
+ 'Reason: {reason}\n'
+ 'Containers: {containers}\n'
+ 'Open Manage installed OCI applications to check its state.'
+ ),
+ 'label': 'OCI application recreation failed',
+ 'group': 'vm_ct',
+ 'default_enabled': True,
+ },
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
@@ -2314,6 +2352,10 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
+ 'oci_update_completed': '\u2705',
+ 'oci_update_failed': '\u26A0\uFE0F',
+ 'oci_recreate_completed': '\u2705',
+ 'oci_recreate_failed': '\u26A0\uFE0F',
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
diff --git a/AppImage/scripts/oci_operations.py b/AppImage/scripts/oci_operations.py
new file mode 100644
index 00000000..97ff1c3d
--- /dev/null
+++ b/AppImage/scripts/oci_operations.py
@@ -0,0 +1,62 @@
+"""Containers an OCI manager operation is working on.
+
+An update or a recreation stops, backs up and starts its containers. The OCI
+engine marks them while it works and reports the result itself, so their
+stop, start and backup notices are part of the operation, not news.
+"""
+import json
+import os
+import re
+import time
+
+MARKERS = '/run/proxmenux/oci-operations'
+# The last start of an operation is noticed a little after it returned.
+GRACE_SECONDS = 180
+# A mark left behind by an operation that died is not trusted for ever.
+STALE_SECONDS = 6 * 3600
+QUIET_EVENTS = frozenset({
+ 'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
+ 'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart',
+ 'backup_start', 'backup_complete',
+})
+_OCI_BACKUP_PATH = '/proxmenux/oci/instances/'
+
+
+def active(vmid, now=None, root=MARKERS) -> bool:
+ try:
+ with open(os.path.join(root, str(int(vmid))), encoding='utf-8') as handle:
+ mark = json.load(handle)
+ started = float(mark.get('started') or 0)
+ ended = mark.get('ended')
+ except (OSError, ValueError, TypeError):
+ return False
+ now = time.time() if now is None else now
+ if ended is None:
+ return now - started < STALE_SECONDS
+ return now - float(ended) < GRACE_SECONDS
+
+
+def _vmids(event) -> set:
+ data = event.data or {}
+ found = set()
+ for value in (data.get('vmid'), getattr(event, 'entity_id', '')):
+ if str(value or '').isdigit():
+ found.add(int(value))
+ if event.event_type.startswith('backup_'):
+ text = ' '.join(str(data.get(key) or '') for key in ('reason', 'pve_message', 'vmname', 'guests'))
+ found.update(int(value) for value in re.findall(r'\((\d{3,})\)|vzdump-(?:lxc|qemu)-(\d+)-', text)
+ for value in value if value)
+ return found
+
+
+def quiet(event, root=MARKERS) -> bool:
+ """Whether the event is a step of a running OCI operation."""
+ if event.event_type not in QUIET_EVENTS or event.severity in ('CRITICAL', 'WARNING'):
+ return False
+ data = event.data or {}
+ if event.event_type.startswith('backup_') and any(
+ _OCI_BACKUP_PATH in str(data.get(key) or '') for key in ('reason', 'pve_message', 'filename')):
+ # The working copy of an update lives in the OCI registry of the host.
+ return True
+ vmids = _vmids(event)
+ return bool(vmids) and all(active(vmid, root=root) for vmid in vmids)
diff --git a/AppImage/scripts/tests/test_idle_bridge.py b/AppImage/scripts/tests/test_idle_bridge.py
new file mode 100644
index 00000000..9fedb568
--- /dev/null
+++ b/AppImage/scripts/tests/test_idle_bridge.py
@@ -0,0 +1,39 @@
+"""A bridge with no port attached has no carrier and is not a failure: the
+private network of an application whose containers are stopped."""
+import sys
+import tempfile
+from pathlib import Path
+import unittest
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS))
+from health_monitor import HealthMonitor
+
+
+class IdleBridgeTests(unittest.TestCase):
+ def bridge(self, flags, ports=()):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ folder = Path(tmp.name) / 'vmbr10'
+ (folder / 'brif').mkdir(parents=True)
+ (folder / 'flags').write_text(flags + '\n')
+ for port in ports:
+ (folder / 'brif' / port).mkdir()
+ return HealthMonitor._bridge_is_idle('vmbr10', tmp.name)
+
+ def test_an_up_bridge_with_no_port_is_idle(self):
+ self.assertTrue(self.bridge('0x1003'))
+
+ def test_a_bridge_with_ports_and_no_carrier_is_not_idle(self):
+ self.assertFalse(self.bridge('0x1003', ['enp3s0']))
+ self.assertFalse(self.bridge('0x1003', ['veth115i1']))
+
+ def test_a_bridge_set_down_is_not_idle(self):
+ self.assertFalse(self.bridge('0x1002'))
+
+ def test_an_interface_that_is_not_a_bridge_is_not_idle(self):
+ self.assertFalse(HealthMonitor._bridge_is_idle('vmbr10', '/nonexistent'))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/AppImage/scripts/tests/test_oci_operation_quiet.py b/AppImage/scripts/tests/test_oci_operation_quiet.py
new file mode 100644
index 00000000..325f0a43
--- /dev/null
+++ b/AppImage/scripts/tests/test_oci_operation_quiet.py
@@ -0,0 +1,75 @@
+"""While the OCI manager updates or recreates an application, the stop, the
+backup and the start of its containers are steps of that operation."""
+import json
+import sys
+import tempfile
+import time
+from pathlib import Path
+from types import SimpleNamespace
+import unittest
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS))
+import oci_operations
+
+
+def event(kind, vmid=None, severity='INFO', **data):
+ if vmid is not None:
+ data['vmid'] = str(vmid)
+ return SimpleNamespace(event_type=kind, severity=severity, data=data, entity_id=str(vmid or ''))
+
+
+class OperationQuietTests(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = tmp.name
+
+ def mark(self, vmid, started, ended=None):
+ Path(self.root, str(vmid)).write_text(json.dumps({'started': started, 'ended': ended}))
+
+ def test_the_steps_of_a_running_operation_are_quiet(self):
+ self.mark(115, time.time())
+ for kind in ('ct_shutdown', 'ct_stop', 'ct_start', 'ct_restart', 'backup_start', 'backup_complete'):
+ self.assertTrue(oci_operations.quiet(event(kind, 115), self.root), kind)
+
+ def test_another_container_is_still_reported(self):
+ self.mark(115, time.time())
+ self.assertFalse(oci_operations.quiet(event('ct_stop', 200), self.root))
+ self.assertFalse(oci_operations.quiet(event('ct_stop'), self.root))
+
+ def test_a_problem_is_never_silenced(self):
+ self.mark(115, time.time())
+ self.assertFalse(oci_operations.quiet(event('ct_stop', 115, severity='WARNING'), self.root))
+ self.assertFalse(oci_operations.quiet(event('ct_fail', 115), self.root))
+ self.assertFalse(oci_operations.quiet(event('backup_fail', 115), self.root))
+
+ def test_the_last_start_is_still_quiet_just_after_the_operation(self):
+ now = time.time()
+ self.mark(115, now - 60, ended=now - 60)
+ self.assertTrue(oci_operations.quiet(event('ct_start', 115), self.root))
+ self.mark(115, now - 3600, ended=now - 3600)
+ self.assertFalse(oci_operations.quiet(event('ct_start', 115), self.root))
+
+ def test_a_mark_left_by_a_dead_operation_expires(self):
+ self.mark(115, time.time() - 7 * 3600)
+ self.assertFalse(oci_operations.quiet(event('ct_stop', 115), self.root))
+
+ def test_the_working_copy_of_an_update_is_recognised_by_its_path(self):
+ archive = ('/usr/local/share/proxmenux/oci/instances/115/stack-transactions/abc/backup-117/'
+ 'vzdump-lxc-117-2026_10_02-20_45_41.tar.zst')
+ self.assertTrue(oci_operations.quiet(event('backup_complete', pve_message=archive), self.root))
+ self.assertFalse(oci_operations.quiet(
+ event('backup_complete', pve_message='/var/lib/vz/dump/vzdump-lxc-117-2026.tar.zst'), self.root))
+
+ def test_a_backup_of_several_guests_is_quiet_only_when_all_belong_to_the_operation(self):
+ self.mark(115, time.time())
+ self.mark(117, time.time())
+ both = event('backup_start', reason='VM/CT:\n CT immich-server (115)\n CT immich-db (117)')
+ mixed = event('backup_start', reason='VM/CT:\n CT immich-db (117)\n CT other (200)')
+ self.assertTrue(oci_operations.quiet(both, self.root))
+ self.assertFalse(oci_operations.quiet(mixed, self.root))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/AppImage/scripts/tests/test_oci_stack_member_registration.py b/AppImage/scripts/tests/test_oci_stack_member_registration.py
new file mode 100644
index 00000000..a432bbed
--- /dev/null
+++ b/AppImage/scripts/tests/test_oci_stack_member_registration.py
@@ -0,0 +1,123 @@
+"""A secondary container of an OCI stack is registered with the version it
+runs and no version tracking; its application is updated with the stack."""
+import json
+import sys
+import tempfile
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS))
+import lxc_apps
+
+DIGEST = 'sha256:' + 'ab' * 32
+
+
+class StackMemberRegistrationTests(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = Path(tmp.name)
+ (self.root / 'catalog').mkdir()
+ (self.root / 'catalog/index.json').write_text(json.dumps({'applications': []}))
+ patches = [patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'instances')),
+ patch.object(lxc_apps, '_OCI_CATALOG_INDEX', str(self.root / 'catalog/index.json')),
+ patch.object(lxc_apps, '_oci_catalog_cache', None),
+ patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')),
+ patch.object(lxc_apps, '_OCI_DISMISSED_FILE', str(self.root / 'apps/.oci-dismissed.json'))]
+ for item in patches:
+ item.start()
+ self.addCleanup(item.stop)
+
+ def record(self, vmid, primary, reference):
+ folder = self.root / f'instances/{vmid}'
+ folder.mkdir(parents=True)
+ (folder / 'oci-compose.json').write_text(json.dumps({
+ 'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
+ 'stack_member': {'name': 'database', 'primary_vmid': primary},
+ 'stack': {'template': {'id': 'stack-nextcloud', 'catalog_ui': {'title': 'Nextcloud'},
+ 'first_run': {'endpoints': [{'port': 80, 'scheme': 'http', 'path': '/'}]}}},
+ 'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
+ 'template': {'id': f'stack-nextcloud-{vmid}',
+ 'container_contract': {'image': {'reference': reference},
+ 'ports': [{'container_port': 5432}]}}}))
+
+ def test_the_database_of_a_stack_is_registered_without_a_web_port(self):
+ self.record(131, 129, 'docker.io/library/postgres:16-alpine')
+ with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
+ self.assertTrue(lxc_apps.ensure_oci_registration(131))
+ payload = add.call_args.args[1]
+ self.assertEqual(payload['name'], 'Postgres')
+ self.assertEqual(payload['installed_via'], 'oci_image')
+ self.assertEqual(payload['ports'], [])
+
+ def test_a_secondary_container_is_told_apart_from_the_main_one(self):
+ self.assertTrue(lxc_apps._oci_secondary_member({'vmid': 131, 'stack_member': {'primary_vmid': 129}}))
+ self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 129, 'stack_member': {'primary_vmid': 129}}))
+ self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 120}))
+ self.assertFalse(lxc_apps._oci_secondary_member(None))
+
+ def test_the_registry_is_not_asked_for_a_secondary_container(self):
+ self.record(131, 129, 'docker.io/library/postgres:16-alpine')
+
+ class Engine:
+ @staticmethod
+ def resolve_candidate(reference, architecture):
+ assert '@' in reference, 'only the installed digest is read'
+ return {'version': '16.15', 'created': '2026-09-01T00:00:00Z', 'manifest_digest': DIGEST}
+
+ with patch.object(lxc_apps, '_oci_state_module', return_value=Engine):
+ result = lxc_apps._oci_image_versions(
+ 131, with_latest=not lxc_apps._oci_secondary_member(lxc_apps._read_oci_record(131)))
+ self.assertEqual(result['installed_version'], '16.15')
+ self.assertNotIn('update_available', result)
+ self.assertNotIn('latest_version', result)
+
+
+if __name__ == '__main__':
+ unittest.main()
+
+
+class StackLogoTests(StackMemberRegistrationTests):
+ ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/immich.webp'
+
+ def stack(self):
+ (self.root / 'catalog/index.json').write_text(json.dumps({'applications': [
+ {'id': 'immich', 'template_id': 'image-immich', 'template': 'apps/immich.json', 'icon': self.ICON}]}))
+ members = {115: ('server', 'ghcr.io/immich-app/immich-server:release'),
+ 116: ('machine-learning', 'ghcr.io/immich-app/immich-machine-learning:release'),
+ 117: ('database', 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3'),
+ 118: ('valkey', 'docker.io/valkey/valkey:9')}
+ for vmid, (role, reference) in members.items():
+ folder = self.root / f'instances/{vmid}'
+ folder.mkdir(parents=True)
+ record = {'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
+ 'stack_member': {'name': role, 'primary_vmid': 115},
+ 'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
+ 'template': {'id': f'image-immich-{role}',
+ 'container_contract': {'image': {'reference': reference}, 'ports': []}}}
+ if vmid == 115:
+ record['stack'] = {'template': {'id': 'image-immich', 'catalog_ui': {'title': 'Immich', 'icon': None}}}
+ (folder / 'oci-compose.json').write_text(json.dumps(record))
+
+ def test_the_main_container_and_machine_learning_wear_the_application_logo(self):
+ self.stack()
+ self.assertEqual(lxc_apps._oci_instance_meta(115)['logo'], self.ICON)
+ self.assertEqual(lxc_apps._oci_instance_meta(116)['logo'], self.ICON)
+
+ def test_the_database_and_the_cache_wear_their_own(self):
+ self.stack()
+ self.assertTrue(lxc_apps._oci_instance_meta(117)['logo'].endswith('/postgresql.webp'))
+ self.assertTrue(lxc_apps._oci_instance_meta(118)['logo'].endswith('/valkey.webp'))
+
+ def test_an_application_registered_without_logo_takes_it_later(self):
+ self.stack()
+ sidecar = {'vmid': 115, 'apps': [{'id': 'a', 'name': 'Immich', 'installed_via': 'oci_image', 'logo_url': ''},
+ {'id': 'b', 'name': 'Other', 'installed_via': '', 'logo_url': ''}]}
+ written = {}
+ with patch.object(lxc_apps, '_read_sidecar', return_value=sidecar), \
+ patch.object(lxc_apps, '_write_sidecar', side_effect=lambda vmid, data: written.update(data)):
+ self.assertFalse(lxc_apps.ensure_oci_registration(115))
+ self.assertEqual(written['apps'][0]['logo_url'], self.ICON)
+ self.assertEqual(written['apps'][1]['logo_url'], '')
diff --git a/lang/es.json b/lang/es.json
index 5ec956f1..5f32989b 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -132,6 +132,8 @@
"ALL Utilities": "TODAS las utilidades",
"ALLOWED_HOSTS cannot contain line breaks": "ALLOWED_HOSTS no puede contener saltos de línea",
"AList initial login": "Primera sesión de AList",
+ "AMD (ROCm)": "AMD (ROCm)",
+ "AMD (VA-API and ROCm detection)": "AMD (VA-API y detección con ROCm)",
"AMD CPU detected": "CPU AMD detectada",
"AMD CPU fixes applied successfully": "Las correcciones de CPU AMD se aplicaron con éxito",
"AMD GPU Tools installation completed!": "¡Se completó la instalación de las herramientas AMD GPU!",
@@ -139,6 +141,7 @@
"AMD GPU(s) detected:": "GPU AMD detectadas:",
"AMD KFD device": "dispositivo AMD KFD",
"AMD VA-API + OpenCL (official mod)": "AMD VA-API + OpenCL (modal oficial)",
+ "AMD compute device": "Dispositivo de cómputo AMD",
"AMD fixes have been successfully reverted": "Las correcciones de AMD se han revertido con éxito",
"AMD mesa drivers installed.": "Controladores Mesa de AMD instalados.",
"AMD softdep configured": "AMD softdep configurado",
@@ -168,8 +171,11 @@
"Acceleration": "Aceleración",
"Acceleration configuration cancelled": "Configuración de aceleración cancelada",
"Acceleration for CodeProject.AI": "Aceleración para CodeProject. AI",
+ "Acceleration for Faster Whisper": "Aceleración para Faster Whisper",
"Acceleration for Immich smart recognition": "Aceleración para el reconocimiento inteligente Immich",
"Acceleration for Ollama": "Aceleración para Ollama",
+ "Acceleration for Piper": "Aceleración para Piper",
+ "Acceleration for llama.cpp": "Aceleración para llama.cpp",
"Accept routes from other nodes?": "¿Aceptar rutas de otros nodos?",
"Accept this host monitoring profile?": "¿Aceptar este perfil de monitorización del host?",
"Access Scope:": "Ámbito de acceso:",
@@ -776,7 +782,7 @@
"Checking remaining interfaces": "Comprobando las interfaces restantes",
"Checking that the container keeps running...": "Comprobando que el contenedor sigue funcionando...",
"Checking that this version builds against the running kernel...": "Comprobando que esta versión se compila con el kernel en ejecución...",
- "Checking the GPU of the machine learning container...": "Comprobando la GPU del contenedor de aprendizaje automático...",
+ "Checking the GPU of the machine learning container...": "Comprobando la GPU del contenedor Machine learning...",
"Checking the container before recreating it...": "Revisando el contenedor antes de recrearlo...",
"Checking the container before the update...": "Revisando el contenedor antes de la actualización...",
"Checking the device permissions for the application user...": "Verificación de los permisos del dispositivo para el usuario de la aplicación...",
@@ -1997,6 +2003,7 @@
"Enter the password for Samba user:": "Ingrese la contraseña para el usuario de Samba:",
"Enter the recovery passphrase set when the keyfile was created:": "ingrese la frase de contraseña de recuperación establecida cuando se creó el archivo de claves:",
"Enter the size in whole GB, for example": "Introduzca el tamaño en GB enteros, por ejemplo",
+ "Enter the value again.": "Introduce el valor de nuevo.",
"Enter username for Samba server:": "Ingrese el nombre de usuario para el servidor Samba:",
"Enter username:": "Introduzca nombre de usuario:",
"Enterprise Proxmox Ceph repository disabled": "Repositorio Enterprise Proxmox Ceph deshabilitado",
@@ -2383,6 +2390,7 @@
"French": "Francés",
"Freshrss is a free, self-hostable aggregator for rss feeds.": "Freshrss es un agregador libre y auto-hostable para las fuentes de rss.",
"Frigate WebUI": "Frigate WebUI",
+ "Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx.": "Frigate usa la GPU AMD para la detección cuando /config/config.yaml define un detector con type: onnx.",
"Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci.": "Frigate usa el Coral cuando /config/config.yaml define un detector con type: edgetpu y device: pci.",
"Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU.": "Frigate usa la NPU de Intel cuando /config/config.yaml define un detector con type: openvino y device: NPU.",
"Full SMART Report": "Informe SMART completo",
@@ -2423,7 +2431,7 @@
"GPU already present in target VM — existing hostpci entry reused": "GPU ya presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente",
"GPU audio added": "Audio GPU agregado",
"GPU audio already present in target VM — existing hostpci entry reused": "El audio de la GPU ya está presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente",
- "GPU available for machine learning:": "GPU disponible para el aprendizaje automático:",
+ "GPU available for machine learning:": "GPU disponible para Machine learning:",
"GPU driver blacklisted": "Controlador de GPU en lista negra",
"GPU guard hook will block concurrent start when another VM is already using this GPU": "El gancho de protección de GPU bloqueará el inicio simultáneo cuando otra VM ya esté usando esta GPU",
"GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf": "Controlador de host de GPU incluido en la lista negra en /etc/modprobe.d/blacklist.conf",
@@ -2541,6 +2549,7 @@
"Hardware acceleration for Emby": "aceleración de hardware para Emby",
"Hardware acceleration for FileFlows": "aceleración de hardware para FileFlows",
"Hardware acceleration for Frigate": "aceleración de hardware para Frigate",
+ "Hardware acceleration for Immich": "Aceleración por hardware para Immich",
"Hardware acceleration for Jellyfin": "aceleración de hardware para Jellyfin",
"Hardware acceleration for Plex": "aceleración de hardware para Plex",
"Hardware acceleration for Roon Server": "aceleración de hardware para Roon Server",
@@ -3025,6 +3034,7 @@
"Insufficient memory. Skipping LXC": "Memoria insuficiente. Saltarse LXC",
"Insufficient space:": "Espacio insuficiente:",
"Integrity check failed on": "La verificación de integridad falló",
+ "Intel (SYCL)": "Intel (SYCL)",
"Intel CPU detected": "CPU Intel detectada",
"Intel GPU Tools installation completed!": "¡Se completó la instalación de las herramientas Intel GPU!",
"Intel GPU(s) detected:": "GPU Intel detectadas:",
@@ -3124,8 +3134,8 @@
"Invalid input": "Entrada no válida",
"Invalid internal volume": "Volumen interno inválido",
"Invalid list:": "Lista inválida:",
- "Invalid machine learning CPU allocation:": "Aprendizaje de máquina inválida CPU:",
- "Invalid machine learning resources": "Recursos de aprendizaje automático inválidos",
+ "Invalid machine learning CPU allocation:": "Asignación de CPU de Machine learning no válida:",
+ "Invalid machine learning resources": "Recursos de Machine learning no válidos",
"Invalid main member or duplicated members": "Miembros principales inválidos o miembros duplicados",
"Invalid media path": "Camino de los medios inválidos",
"Invalid media volume": "Volumen de medios inválidos",
@@ -3441,7 +3451,7 @@
"MOTD configuration was already up to date": "la configuración de MOTD ya estaba actualizada",
"Machine Type": "Tipo de máquina",
"Machine learning": "Aprendizaje automático",
- "Machine learning profile not implemented; it is not replaced by CPU:": "Perfil de aprendizaje automático no implementado; no es reemplazado por CPU:",
+ "Machine learning profile not implemented; it is not replaced by CPU:": "Perfil de Machine learning no implementado; no se sustituye por CPU:",
"Machine type: q35": "Tipo de máquina: q35",
"Machine: q35": "Máquina: q35",
"Main endpoint not yet defined": "Punto final principal aún no definido",
@@ -4097,6 +4107,8 @@
"No unprivileged containers available in Proxmox.": "No hay contenedores sin privilegios disponibles en Proxmox.",
"No updates available — run a scan first or wait for the Monitor to refresh.": "No hay actualizaciones disponibles: primero ejecute un análisis o espere a que se actualice el monitor.",
"No updates or failed to fetch templates": "No hay actualizaciones o no se pudieron recuperar las plantillas",
+ "No usable GPU was found on this host. Immich will be installed on the CPU.": "No se ha encontrado ninguna GPU utilizable en este host. Immich se instalará en la CPU.",
+ "No usable GPU was found on this host. The application will be installed without hardware acceleration.": "No se ha encontrado ninguna GPU utilizable en este host. La aplicación se instalará sin aceleración por hardware.",
"No user groups found.": "No se encontraron grupos de usuarios.",
"No user-created disk storage or fstab mount found.": "No se encontró ningún almacenamiento en disco creado por el usuario ni montaje fstab.",
"No username provided.": "No se proporcionó ningún nombre de usuario.",
@@ -4678,6 +4690,8 @@
"RAM in MiB": "RAM en MiB",
"REPAIR SUMMARY": "RESUMEN DE REPARACIÓN",
"REQUIREMENTS:": "REQUISITOS:",
+ "ROCm requires /dev/kfd on the host": "ROCm requiere /dev/kfd en el host",
+ "ROCm requires the render device of an AMD GPU": "ROCm requiere el dispositivo de render de una GPU AMD",
"ROM dump not available — configuring without romfile.": "Volcado de ROM no disponible: configuración sin archivo rom.",
"ROM file used": "archivo ROM utilizado",
"RPC Bind Service: RUNNING": "Servicio de enlace RPC: EN EJECUCIÓN",
@@ -4743,7 +4757,9 @@
"Recent Samba server": "Servidor Samba reciente",
"Recent logs:": "Registros recientes:",
"Recent test results:": "Resultados de pruebas recientes:",
+ "Recognition on AMD uses ROCm. Its image is several times larger than the others, so the first installation takes longer, and whether a GPU works with it depends on its model.": "El reconocimiento en AMD usa ROCm. Su imagen es varias veces mayor que las demás, por lo que la primera instalación tarda más, y que una GPU funcione con ella depende de su modelo.",
"Recognition profile not implemented": "Perfil de reconocimiento no implementado",
+ "Recognition runs on the CPU.": "El reconocimiento se ejecuta en la CPU.",
"Recommendation: reformat the disk to ext4 for a robust setup — see docs.": "Recomendación: vuelva a formatear el disco a ext4 para una configuración sólida; consulte los documentos.",
"Recommendation: start with Complete restore.": "Recomendación: comience con la restauración completa.",
"Recommendation: use 'Export to file' for these paths and apply manually during a maintenance window.": "Recomendación: use 'Exportar a archivo' para estas rutas y aplíquelo manualmente durante una ventana de mantenimiento.",
@@ -5868,6 +5884,7 @@
"That VM is currently stopped, so the GPU can be reassigned now.": "Esa VM está actualmente detenida, por lo que la GPU se puede reasignar ahora.",
"That doesn't look like an SSH private key. Pick the private key file (no .pub extension, parseable by ssh-keygen).": "Eso no parece una clave privada SSH.Elige el archivo de clave privada (sin extensión .pub, analizable mediante ssh-keygen).",
"The .conf files under /config/fail2ban are rewritten on every start. Keep customizations in the matching .local file, for example jail.local for jail.conf.": "Los archivos .conf bajo /config/fail2ban son reescritos en cada inicio. Mantenga las personalizaciones en el archivo .local coincidente, por ejemplo la cárcel.local para jail.conf.",
+ "The AMD driver does not offer its compute interface (/dev/kfd) on this host.": "El driver de AMD no ofrece su interfaz de cómputo (/dev/kfd) en este host.",
"The AppArmor/seccomp relaxation does not include the required consent": "La relajación AppArmor/seccomp no incluye el consentimiento necesario",
"The Bookmark Everything App": "La aplicación Todo Marcador",
"The Brave browser is a fast, private and secure web browser for PC, Mac and mobile.": "El navegador Brave es un navegador web rápido, privado y seguro para PC, Mac y móvil.",
@@ -6247,7 +6264,7 @@
"The removal could not be prepared:": "No se pudo preparar la eliminación:",
"The repair must preserve the image dependencies:": "La reparación debe preservar las dependencias de la imagen:",
"The requested VMID block is already in use": "El bloque VMID solicitado ya está en uso",
- "The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil GPU de aprendizaje automático solicitado no funciona; no es reemplazado por CPU",
+ "The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil de GPU solicitado para Machine learning no funciona; no se sustituye por CPU",
"The restored service did not pass its health check": "El servicio restaurado no aprobó su cheque de salud",
"The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma",
"The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no requiere un LXC privilegiado.",
@@ -6267,6 +6284,7 @@
"The script will preconfigure the selected GPU now and finalize hardware binding after reboot.": "El script preconfigurará la GPU seleccionada ahora y finalizará el enlace del hardware después del reinicio.",
"The selected AMD GPU does not report FLR reset support": "La GPU AMD seleccionada no informa compatibilidad con el restablecimiento de FLR",
"The selected AMD GPU is currently in power state D3cold": "La GPU AMD seleccionada se encuentra actualmente en estado de energía D3cold",
+ "The selected AMD render device does not exist:": "El dispositivo de render AMD seleccionado no existe:",
"The selected CT does not match its OCI record. Its configuration will not be modified or deleted.": "El CT seleccionado no coincide con su registro OCI. Su configuración no se modificará ni se eliminará.",
"The selected GPU changed": "La GPU seleccionada cambió",
"The selected GPU configuration already exists in this container.": "La configuración de GPU seleccionada ya existe en este contenedor.",
@@ -6340,6 +6358,7 @@
"The staticfiles volume needs at least 1 GB": "El volumen de los ficheros estáticos necesita al menos 1 GB",
"The storage does not accept backups:": "El almacenamiento no admite backups:",
"The storage has been removed and the disk unmounted.": "Se eliminó el almacenamiento y se desmontó el disco.",
+ "The storage has less than 40 GB free for the ROCm image.": "El almacenamiento tiene menos de 40 GB libres para la imagen de ROCm.",
"The sysctl content was modified outside the saved record": "El contenido de sysctl fue modificado fuera del registro guardado",
"The sysctl include is a link:": "El include de sysctl es un enlace:",
"The sysctl include is not a safe host file": "El include de sysctl no es un archivo seguro del host",
@@ -6351,6 +6370,7 @@
"The tmpfs path or size is outside the supported profile": "El camino o tamaño de tmpfs está fuera del perfil soportado",
"The translated recipe changed during the preparation": "La receta traducida cambió durante la preparación",
"The value contains an unsupported character": "El valor contiene un carácter no admitido",
+ "The value must be a number:": "El valor debe ser un número:",
"The values do not match. Enter them again.": "Los valores no coinciden. Vuelve a introducirlos.",
"The variable contains control characters:": "La variable contiene caracteres de control:",
"The variable contains line breaks:": "La variable contiene roturas de línea:",
@@ -6933,6 +6953,7 @@
"Video": "Vídeo",
"Video acceleration and object detection are independent choices; the installer does not write camera or detector YAML.": "La aceleración de vídeo y la detección de objetos son opciones independientes; el instalador no escribe cámara o detector YAML.",
"Video transcoding acceleration": "Aceleración de transcodificación de vídeo",
+ "Video transcoding profile not implemented:": "Perfil de transcodificación de vídeo no implementado:",
"View CIFS Mounts (pvesm + fstab)": "Ver montajes CIFS (pvesm + fstab)",
"View Current Exports": "Ver exportaciones actuales",
"View Current Mounts": "Ver montajes actuales",
@@ -7498,6 +7519,8 @@
"read-only": "solo lectura",
"reboot-quick alias added": "alias de reinicio rápido agregado",
"reboot-quick alias is already configured": "el alias de reinicio rápido ya está configurado",
+ "recognition": "reconocimiento",
+ "recognition only": "solo reconocimiento",
"recommended": "recomendado",
"recovering": "recuperando",
"remapped users": "usuarios reasignados",
@@ -7589,6 +7612,9 @@
"vCPUs:": "vCPU:",
"vfio-pci IDs configured": "ID de vfio-pci configurados",
"vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID de vfio-pci en /etc/modprobe.d/vfio.conf",
+ "video": "vídeo",
+ "video + recognition": "vídeo + reconocimiento",
+ "video only": "solo vídeo",
"vzdump backup speed optimization completed": "Optimización de la velocidad de copia de seguridad de vzdump completada",
"wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/.conf with the container stopped, and start it again.": "wallabag construye sus enlaces desde la dirección dada durante la instalación. Si no coincide con la dirección del contenedor, edite lxc.environment. tiempo de ejecución: SYMFONY DOMAIN NAME en /etc/pve/lxc/ se hizo referencia aCTID ratio.conf con el contenedor parado, y comenzar de nuevo.",
"wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag escucha en el puerto 80 del contenedor y almacena sus datos en SQLite.",
diff --git a/oci/catalog/apps/faster-whisper.json b/oci/catalog/apps/faster-whisper.json
index 804788d0..b86f9cf2 100644
--- a/oci/catalog/apps/faster-whisper.json
+++ b/oci/catalog/apps/faster-whisper.json
@@ -246,7 +246,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "hardware_acceleration": {
+ "prompt": "Acceleration for Faster Whisper",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "lscr.io/linuxserver/faster-whisper:latest",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/faster-whisper",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "lscr.io/linuxserver/faster-whisper:gpu",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/faster-whisper",
+ "tag": "gpu",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ }
+ ]
+ }
}
},
"compatibility": {
diff --git a/oci/catalog/apps/frigate.json b/oci/catalog/apps/frigate.json
index 3fe7cce1..361a93ab 100644
--- a/oci/catalog/apps/frigate.json
+++ b/oci/catalog/apps/frigate.json
@@ -884,6 +884,49 @@
}
]
},
+ {
+ "id": "rocm",
+ "label": "AMD (VA-API and ROCm detection)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/blakeblackshear/frigate:stable-rocm",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/blakeblackshear/frigate",
+ "tag": "stable-rocm",
+ "digest": null,
+ "pull_policy": "resolve-rolling-stable-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ],
+ "completion_notes": [
+ "Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx."
+ ]
+ },
{
"id": "nvidia",
"label": "NVIDIA (NVDEC/CUDA)",
diff --git a/oci/catalog/apps/llamacpp.json b/oci/catalog/apps/llamacpp.json
index f9d2cad2..b92aaf27 100644
--- a/oci/catalog/apps/llamacpp.json
+++ b/oci/catalog/apps/llamacpp.json
@@ -254,7 +254,130 @@
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image"
},
- "installer_profile": {},
+ "installer_profile": {
+ "hardware_acceleration": {
+ "prompt": "Acceleration for llama.cpp",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-cuda",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-cuda",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ },
+ {
+ "id": "rocm",
+ "label": "AMD (ROCm)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-rocm",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-rocm",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ]
+ },
+ {
+ "id": "intel",
+ "label": "Intel (SYCL)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-intel",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-intel",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x8086"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
"adaptations": [
{
"id": "imported-compose-source",
diff --git a/oci/catalog/apps/ollama.json b/oci/catalog/apps/ollama.json
index c20a85dc..0d61df43 100644
--- a/oci/catalog/apps/ollama.json
+++ b/oci/catalog/apps/ollama.json
@@ -371,11 +371,30 @@
{
"id": "cpu",
"label": "CPU",
+ "image": {
+ "reference": "ollama/ollama:latest",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ollama/ollama:latest",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
"device_requests": [
{
"id": "nvidia-runtime",
@@ -390,6 +409,46 @@
"value": "all"
}
]
+ },
+ {
+ "id": "rocm",
+ "label": "AMD (ROCm)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ollama/ollama:rocm",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "rocm",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ]
}
]
},
diff --git a/oci/catalog/apps/piper.json b/oci/catalog/apps/piper.json
index 20d51eb0..3d31b703 100644
--- a/oci/catalog/apps/piper.json
+++ b/oci/catalog/apps/piper.json
@@ -260,7 +260,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "hardware_acceleration": {
+ "prompt": "Acceleration for Piper",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "lscr.io/linuxserver/piper:latest",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/piper",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "lscr.io/linuxserver/piper:gpu",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/piper",
+ "tag": "gpu",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ }
+ ]
+ }
}
},
"compatibility": {
diff --git a/oci/catalog/curated/frigate.json b/oci/catalog/curated/frigate.json
index 793d7c00..fd9b960d 100644
--- a/oci/catalog/curated/frigate.json
+++ b/oci/catalog/curated/frigate.json
@@ -884,6 +884,49 @@
}
]
},
+ {
+ "id": "rocm",
+ "label": "AMD (VA-API and ROCm detection)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/blakeblackshear/frigate:stable-rocm",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/blakeblackshear/frigate",
+ "tag": "stable-rocm",
+ "digest": null,
+ "pull_policy": "resolve-rolling-stable-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ],
+ "completion_notes": [
+ "Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx."
+ ]
+ },
{
"id": "nvidia",
"label": "NVIDIA (NVDEC/CUDA)",
diff --git a/oci/catalog/curated/llamacpp.json b/oci/catalog/curated/llamacpp.json
index 3ed8fc65..c67dbe87 100644
--- a/oci/catalog/curated/llamacpp.json
+++ b/oci/catalog/curated/llamacpp.json
@@ -254,7 +254,130 @@
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image"
},
- "installer_profile": {},
+ "installer_profile": {
+ "hardware_acceleration": {
+ "prompt": "Acceleration for llama.cpp",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-cuda",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-cuda",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ },
+ {
+ "id": "rocm",
+ "label": "AMD (ROCm)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-rocm",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-rocm",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ]
+ },
+ {
+ "id": "intel",
+ "label": "Intel (SYCL)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ghcr.io/ggml-org/llama.cpp:server-intel",
+ "registry": "ghcr.io",
+ "repository": "ghcr.io/ggml-org/llama.cpp",
+ "tag": "server-intel",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x8086"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
"adaptations": [
{
"id": "imported-compose-source",
diff --git a/oci/catalog/overlays/faster-whisper.json b/oci/catalog/overlays/faster-whisper.json
index b1cbf5eb..d37ea95e 100644
--- a/oci/catalog/overlays/faster-whisper.json
+++ b/oci/catalog/overlays/faster-whisper.json
@@ -8,7 +8,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "hardware_acceleration": {
+ "prompt": "Acceleration for Faster Whisper",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "lscr.io/linuxserver/faster-whisper:latest",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/faster-whisper",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "lscr.io/linuxserver/faster-whisper:gpu",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/faster-whisper",
+ "tag": "gpu",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ }
+ ]
+ }
}
}
}
diff --git a/oci/catalog/overlays/ollama.json b/oci/catalog/overlays/ollama.json
index 97abd62c..01ebbdb1 100644
--- a/oci/catalog/overlays/ollama.json
+++ b/oci/catalog/overlays/ollama.json
@@ -12,11 +12,30 @@
{
"id": "cpu",
"label": "CPU",
+ "image": {
+ "reference": "ollama/ollama:latest",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ollama/ollama:latest",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
"device_requests": [
{
"id": "nvidia-runtime",
@@ -31,6 +50,46 @@
"value": "all"
}
]
+ },
+ {
+ "id": "rocm",
+ "label": "AMD (ROCm)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "ollama/ollama:rocm",
+ "registry": "docker.io",
+ "repository": "ollama/ollama",
+ "tag": "rocm",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "gpu-render",
+ "path_prompt": "GPU render device",
+ "host_path_default": "/dev/dri/renderD128",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host",
+ "drm_vendor_ids": [
+ "0x1002"
+ ]
+ },
+ {
+ "id": "amd-kfd",
+ "path_prompt": "AMD compute device",
+ "host_path_default": "/dev/kfd",
+ "mode": "0660",
+ "deny_write": false,
+ "gid_strategy": "host-device-gid",
+ "kind": "character-device",
+ "container_path_strategy": "same-as-host"
+ }
+ ]
}
]
},
diff --git a/oci/catalog/overlays/piper.json b/oci/catalog/overlays/piper.json
index b1cbf5eb..b4c90637 100644
--- a/oci/catalog/overlays/piper.json
+++ b/oci/catalog/overlays/piper.json
@@ -8,7 +8,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "hardware_acceleration": {
+ "prompt": "Acceleration for Piper",
+ "default": "cpu",
+ "profiles": [
+ {
+ "id": "cpu",
+ "label": "CPU",
+ "image": {
+ "reference": "lscr.io/linuxserver/piper:latest",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/piper",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": []
+ },
+ {
+ "id": "nvidia",
+ "label": "NVIDIA (CUDA)",
+ "architectures": [
+ "amd64"
+ ],
+ "image": {
+ "reference": "lscr.io/linuxserver/piper:gpu",
+ "registry": "lscr.io",
+ "repository": "lscr.io/linuxserver/piper",
+ "tag": "gpu",
+ "digest": null,
+ "pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
+ },
+ "device_requests": [
+ {
+ "id": "nvidia-runtime",
+ "kind": "nvidia-runtime",
+ "purpose": "nvidia-cuda",
+ "device_selection": "all-requested-by-compose"
+ }
+ ],
+ "environment": [
+ {
+ "name": "NVIDIA_VISIBLE_DEVICES",
+ "value": "all"
+ },
+ {
+ "name": "NVIDIA_DRIVER_CAPABILITIES",
+ "value": "compute,utility"
+ }
+ ]
+ }
+ ]
+ }
}
}
}
diff --git a/oci/remote/install_immich_stack.sh b/oci/remote/install_immich_stack.sh
index 847cb930..6514cca4 100755
--- a/oci/remote/install_immich_stack.sh
+++ b/oci/remote/install_immich_stack.sh
@@ -121,7 +121,7 @@ MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 4')
-APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 3072')
+APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 4096')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
@@ -149,6 +149,8 @@ ML_IP=${ML_ADDRESS%/*}
DB_IP=${DB_ADDRESS%/*}
VALKEY_IP=${VALKEY_ADDRESS%/*}
VIDEO_ACCELERATION=$(jqr '.video_transcoding.acceleration')
+[[ $VIDEO_ACCELERATION == cpu || $VIDEO_ACCELERATION == vaapi || $VIDEO_ACCELERATION == nvenc ]] \
+ || die "$(translate "Video transcoding profile not implemented:") $VIDEO_ACCELERATION"
RENDER_DEVICE=$(jq -r '.video_transcoding.render_device // empty' "$DEPLOYMENT_FILE")
VAAPI_DRIVER=$(jqr '.video_transcoding.driver')
MODEL_CACHE_SIZE=$(jqr '.machine_learning.model_cache_size_gb')
@@ -417,7 +419,7 @@ set_lxc_directive "$VALKEY_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $VALKEY_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
-oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
+oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:${ML_ROOTFS_SIZE}" \
--mp0 "${ROOTFS_STORAGE}:${MODEL_CACHE_SIZE},mp=/cache,backup=1" \
--hostname "${STACK_NAME}-ml" "${ML_CPU_ARGS[@]}" --memory "$ML_MEMORY" --swap "$ML_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${ML_FRONTEND_NET},type=veth" \
@@ -442,7 +444,7 @@ rm -rf "$ML_ROOT/cache/lost+found"
chown 100000:100000 "$ML_ROOT/cache"
chmod 0755 "$ML_ROOT/cache"
oci_quiet pct unmount "$ML_ID"
-msg_ok "$(translate "Container created:") CT $ML_ID ($(translate "Machine learning"))"
+msg_ok "$(translate "Container created:") CT $ML_ID (Machine learning)"
SERVER_DEVICE_ARGS=()
if [[ $VIDEO_ACCELERATION == vaapi ]]; then
@@ -463,6 +465,8 @@ oci_create_container "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:
created_ids+=("$SERVER_ID")
oci_apply_extra_mounts "$SERVER_ID"
oci_apply_extra_devices "$SERVER_ID"
+# NVENC needs the video capability on top of what recognition uses.
+[[ $VIDEO_ACCELERATION != nvenc ]] || configure_immich_nvidia "$SERVER_ID" "compute,video,utility"
oci_quiet pct mount "$SERVER_ID"
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
@@ -555,7 +559,7 @@ if (( START_AFTER == 1 )); then
oci_quiet pct start "$VALKEY_ID"
wait_command Valkey 30 pct exec "$VALKEY_ID" -- valkey-cli -h "$VALKEY_IP" ping
msg_ok "$(translate "Service ready:") Valkey"
- ML_LABEL=$(translate "Machine learning")
+ ML_LABEL="Machine learning"
msg_info "$(translate "Starting the service:") $ML_LABEL"
oci_quiet pct start "$ML_ID"
wait_command "$ML_LABEL" 60 curl -fsS "http://${ML_IP}:3003/ping"
diff --git a/oci/remote/oci_immich_ml.sh b/oci/remote/oci_immich_ml.sh
index 20b48ca8..e6767d5a 100755
--- a/oci/remote/oci_immich_ml.sh
+++ b/oci/remote/oci_immich_ml.sh
@@ -1,9 +1,22 @@
# Immich ML prerequisites and native GPU setup; no host driver installation.
validate_immich_ml_profile() {
- ML_CPU_ARGS=(--cores 2)
- ML_MEMORY=2048
+ ML_CPU_ARGS=(--cores 4)
+ ML_MEMORY=4096
+ ML_ROOTFS_SIZE=12
case "$ML_ACCELERATION" in
cpu) ;;
+ rocm)
+ ML_RENDER_DEVICE=$(jq -er '.machine_learning.render_device' "$DEPLOYMENT_FILE")
+ [[ $ML_RENDER_DEVICE =~ ^/dev/dri/renderD[0-9]+$ && -c $ML_RENDER_DEVICE ]] \
+ || die "$(translate "The selected AMD render device does not exist:") $ML_RENDER_DEVICE"
+ [[ $(cat "/sys/class/drm/${ML_RENDER_DEVICE##*/}/device/vendor") == 0x1002 ]] \
+ || die "$(translate "ROCm requires the render device of an AMD GPU")"
+ [[ -c /dev/kfd ]] || die "$(translate "ROCm requires /dev/kfd on the host")"
+ ML_MEMORY=8192
+ # The ROCm image carries the whole AMD runtime and is several times
+ # larger than the others.
+ ML_ROOTFS_SIZE=40
+ ;;
openvino)
ML_RENDER_DEVICE=$(jq -er '.machine_learning.render_device' "$DEPLOYMENT_FILE")
[[ $ML_RENDER_DEVICE =~ ^/dev/dri/renderD[0-9]+$ && -c $ML_RENDER_DEVICE ]] \
@@ -57,34 +70,46 @@ validate_immich_ml_profile() {
esac
}
+# Gives one container of the stack the NVIDIA GPU through the dynamic hook.
+# Arguments: VMID CAPABILITIES
+configure_immich_nvidia() {
+ # Isolate the shared standalone installer's runtime context from the stack.
+ (
+ VMID=$1
+ CONF="/etc/pve/lxc/${VMID}.conf"
+ UNPRIVILEGED_FLAG=1
+ DEVICE='{"kind":"nvidia-runtime","runtime_mode":"dynamic"}'
+ NVIDIA_GID_ENV=""
+ DEVICE_INDEX=0
+ while grep -q "^dev${DEVICE_INDEX}:" "$CONF"; do DEVICE_INDEX=$((DEVICE_INDEX + 1)); done
+ fragment=$(mktemp)
+ trap 'rm -f "$fragment"' EXIT
+ jq -nc --arg capabilities "$2" \
+ '{environment:[{name:"NVIDIA_DRIVER_CAPABILITIES",value:$capabilities}]}' >"$fragment"
+ DEPLOYMENT_FILE=$fragment
+ add_character_device() {
+ local path=$1 mode gid
+ [[ -c $path && $path == /dev/nvidia* ]] || die "$(translate "Invalid NVIDIA device:") $path"
+ mode="0$(stat -c %a "$path")"
+ gid=$(stat -c %g "$path")
+ oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "path=${path},mode=${mode},gid=${gid},deny-write=0"
+ DEVICE_INDEX=$((DEVICE_INDEX + 1))
+ }
+ configure_nvidia_runtime
+ )
+}
+
configure_immich_ml_gpu() {
case "$ML_ACCELERATION" in
openvino)
oci_quiet pct set "$ML_ID" --dev0 "path=${ML_RENDER_DEVICE},gid=$(stat -c %g "$ML_RENDER_DEVICE"),mode=0660"
;;
+ rocm)
+ oci_quiet pct set "$ML_ID" --dev0 "path=${ML_RENDER_DEVICE},gid=$(stat -c %g "$ML_RENDER_DEVICE"),mode=0660"
+ oci_quiet pct set "$ML_ID" --dev1 "path=/dev/kfd,gid=$(stat -c %g /dev/kfd),mode=0660"
+ ;;
cuda)
- # Isolate the shared standalone installer's runtime context from the stack.
- (
- VMID=$ML_ID
- CONF="/etc/pve/lxc/${ML_ID}.conf"
- UNPRIVILEGED_FLAG=1
- DEVICE='{"kind":"nvidia-runtime","runtime_mode":"dynamic"}'
- NVIDIA_GID_ENV=""
- DEVICE_INDEX=0
- fragment=$(mktemp)
- trap 'rm -f "$fragment"' EXIT
- printf '%s\n' '{"environment":[{"name":"NVIDIA_DRIVER_CAPABILITIES","value":"compute,utility"}]}' >"$fragment"
- DEPLOYMENT_FILE=$fragment
- add_character_device() {
- local path=$1 mode gid
- [[ -c $path && $path == /dev/nvidia* ]] || die "$(translate "Invalid NVIDIA device:") $path"
- mode="0$(stat -c %a "$path")"
- gid=$(stat -c %g "$path")
- oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "path=${path},mode=${mode},gid=${gid},deny-write=0"
- DEVICE_INDEX=$((DEVICE_INDEX + 1))
- }
- configure_nvidia_runtime
- )
+ configure_immich_nvidia "$ML_ID" "compute,utility"
;;
esac
}
@@ -101,6 +126,8 @@ if profile == "openvino":
assert "OpenVINOExecutionProvider" in ort.get_available_providers()
devices = ort.capi._pybind_state.get_available_openvino_device_ids()
assert any(device.startswith("GPU") for device in devices), devices
+elif profile == "rocm":
+ assert "MIGraphXExecutionProvider" in ort.get_available_providers(), ort.get_available_providers()
else:
assert profile == "cuda"
assert "CUDAExecutionProvider" in ort.get_available_providers()
diff --git a/oci/remote/oci_native_stack.py b/oci/remote/oci_native_stack.py
index b57bbaab..b441704b 100644
--- a/oci/remote/oci_native_stack.py
+++ b/oci/remote/oci_native_stack.py
@@ -40,6 +40,9 @@ def begin(root, primary, template, deployment, members, adapter):
'mounts': []}
if Path(adapter).name == 'install_immich_stack.sh' and name == 'machine-learning':
plan['machine_learning'] = copy.deepcopy(deployment.get('machine_learning', {'acceleration': 'cpu'}))
+ if Path(adapter).name == 'install_immich_stack.sh' and name == 'server':
+ # An update must know that the server transcodes with NVIDIA.
+ plan['video_transcoding'] = copy.deepcopy(deployment.get('video_transcoding', {'acceleration': 'cpu'}))
if Path(adapter).name in oci_stack_replay.FILES:
plan['replay_profile'] = {'adapter': Path(adapter).name, 'role': name}
if not oci_stack_replay.FILES[Path(adapter).name][name]:
diff --git a/oci/remote/oci_operation_notice.py b/oci/remote/oci_operation_notice.py
new file mode 100644
index 00000000..42ad9add
--- /dev/null
+++ b/oci/remote/oci_operation_notice.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""What the Monitor is told about an update or a recreation.
+
+While the operation runs, every container it touches is stopped, backed up
+and started again. Those steps belong to the operation, so the containers are
+marked for the Monitor to keep their stop, start and backup notices to itself,
+and one notification with the result is sent when it ends.
+"""
+from __future__ import annotations
+
+import contextlib
+import json
+from pathlib import Path
+import socket
+import ssl
+import time
+import urllib.request
+
+MARKERS = Path('/run/proxmenux/oci-operations')
+ENDPOINTS = ('http://127.0.0.1:8008/api/internal/oci-event', 'https://127.0.0.1:8008/api/internal/oci-event')
+
+
+def _write(vmid, data):
+ try:
+ MARKERS.mkdir(parents=True, exist_ok=True)
+ path = MARKERS / str(int(vmid))
+ temporary = path.with_suffix('.tmp')
+ temporary.write_text(json.dumps(data))
+ temporary.replace(path)
+ except OSError:
+ pass
+
+
+def begin(vmids):
+ started = time.time()
+ for vmid in vmids:
+ _write(vmid, {'started': started, 'ended': None})
+
+
+def end(vmids):
+ # The notices of the last start can arrive after the operation returned;
+ # the Monitor keeps the mark for a short while after `ended`.
+ ended = time.time()
+ for vmid in vmids:
+ _write(vmid, {'started': ended, 'ended': ended})
+
+
+def notify(event, data):
+ """Best effort: the operation never depends on the Monitor answering."""
+ payload = json.dumps({'event': event, 'hostname': socket.gethostname(), **data}).encode()
+ context = ssl.create_default_context()
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ for url in ENDPOINTS:
+ request = urllib.request.Request(url, data=payload, headers={'Content-Type': 'application/json'})
+ try:
+ with urllib.request.urlopen(request, timeout=5, context=context if url.startswith('https') else None):
+ return True
+ except (OSError, ValueError):
+ continue
+ return False
+
+
+@contextlib.contextmanager
+def operation(vmids, kind, application, primary=None):
+ """Mark the containers for the length of an update or a recreation and
+ report how it ended. `kind` is 'update' or 'recreate'."""
+ vmids = [int(vmid) for vmid in vmids]
+ data = {'app_name': str(application), 'vmid': int(primary if primary is not None else vmids[0]),
+ 'containers': ', '.join(f'CT {vmid}' for vmid in vmids)}
+ begin(vmids)
+ try:
+ yield
+ except BaseException as error:
+ end(vmids)
+ notify(f'oci_{kind}_failed', {**data, 'reason': str(error) or type(error).__name__})
+ raise
+ end(vmids)
+ notify(f'oci_{kind}_completed', data)
diff --git a/oci/remote/oci_stack_modify.py b/oci/remote/oci_stack_modify.py
index ee17bc25..59913f3f 100644
--- a/oci/remote/oci_stack_modify.py
+++ b/oci/remote/oci_stack_modify.py
@@ -208,6 +208,15 @@ def modify(root, vmid, changes):
backup = instances.location(root, vmid).parent / f"config-before-recreate-{time.strftime('%Y%m%d-%H%M%S')}.conf"
backup.write_text(run('pct', 'config', str(vmid)))
backup.chmod(0o600)
+ import oci_operation_notice
+ import oci_update_current
+ primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
+ name = oci_update_current.application_name(instances.read(root, primary_id), primary_id)
+ with oci_operation_notice.operation([vmid], 'recreate', name, primary_id):
+ _modify(root, vmid, changes)
+
+
+def _modify(root, vmid, changes):
running = is_running(vmid)
if running:
msg_info(translate('Stopping the container...'))
diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py
index 9cbf297f..0b03d6b9 100644
--- a/oci/remote/oci_stack_native.py
+++ b/oci/remote/oci_stack_native.py
@@ -373,6 +373,10 @@ class NativeAdapter:
deployment = self.records[vmid]['deployment']
if deployment.get('replay_profile') == {'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}:
acceleration = deployment.get('machine_learning', {}).get('acceleration', 'cpu')
+ if acceleration == 'rocm':
+ member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
+ 'import onnxruntime as ort; '
+ 'assert "MIGraphXExecutionProvider" in ort.get_available_providers()')
if acceleration in ('openvino', 'cuda'):
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import sys,ctypes,onnxruntime as ort; p=sys.argv[1]; '
@@ -674,7 +678,11 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
msg_ok(f"{translate('Backup created in')} {keep_backup}")
else:
adapter.keep_backup = keep_backup
- result = stack_tx.execute(journal, adapter, plan)
+ import oci_operation_notice
+ import oci_update_current
+ with oci_operation_notice.operation([member['vmid'] for member in plan['members']], 'update',
+ oci_update_current.application_name(primary, primary_id), primary_id):
+ result = stack_tx.execute(journal, adapter, plan)
msg_ok(translate('Stack update completed. Data kept.'))
return result
diff --git a/oci/remote/oci_stack_replay.py b/oci/remote/oci_stack_replay.py
index 570b6c01..18202bf6 100644
--- a/oci/remote/oci_stack_replay.py
+++ b/oci/remote/oci_stack_replay.py
@@ -68,9 +68,13 @@ def immich_record(record):
if shlex.split(runtime.get('entrypoint', '')) != expected[role]:
raise ValueError(translate('The Immich startup was modified or cannot be reproduced'))
acceleration = record['deployment'].get('machine_learning', {}).get('acceleration', 'cpu')
- if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda'):
+ if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda', 'rocm'):
raise ValueError(translate('Immich GPU profile not validated'))
- cuda = role == 'machine-learning' and acceleration == 'cuda'
+ video = record['deployment'].get('video_transcoding', {}).get('acceleration', 'cpu')
+ # NVIDIA reaches Machine learning for recognition and the server for NVENC.
+ capabilities = ('compute,utility' if role == 'machine-learning' and acceleration == 'cuda'
+ else 'compute,video,utility' if role == 'server' and video == 'nvenc' else None)
+ cuda = capabilities is not None
devices = [{'kind': 'nvidia-runtime', 'runtime_mode': 'dynamic'}] if cuda else []
for item in projection['native_devices']:
fields = dict(p.split('=', 1) for p in item['value'].split(',') if '=' in p)
@@ -80,17 +84,24 @@ def immich_record(record):
if oci_gpu_devices.peripheral_path(path):
devices.append(peripheral_device(fields))
continue
+ rocm = role == 'machine-learning' and acceleration == 'rocm'
+ if rocm and path == '/dev/kfd':
+ # The compute interface ROCm needs beside the render node.
+ devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
+ 'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660')})
+ continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
+ vendors = (['0x1002'] if rocm else ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002'])
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660'),
- 'drm_vendor_ids': ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002']})
+ 'drm_vendor_ids': vendors})
if projection['preserved_raw_runtime'] and not cuda:
raise ValueError(translate('Immich runtime without a validated translation'))
if cuda:
import oci_accelerators
candidate = {'devices': devices, 'environment': [
- {'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'}]}
+ {'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': capabilities}]}
oci_accelerators.check(record['observed']['config'].encode(), candidate)
translated = {'compose_entrypoint': expected[role], 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'), ('lxc.signal.halt', 'halt_signal')):
@@ -101,8 +112,10 @@ def immich_record(record):
if cuda:
result['deployment']['environment'] = [e for e in result['deployment']['environment']
if e['name'] != 'NVIDIA_DRIVER_CAPABILITIES']
- result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'})
+ result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': capabilities})
result['deployment']['machine_learning'] = copy.deepcopy(record['deployment'].get('machine_learning', {}))
+ if 'video_transcoding' in record['deployment']:
+ result['deployment']['video_transcoding'] = copy.deepcopy(record['deployment']['video_transcoding'])
return result
diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py
index dd6b5b50..3d8c6da7 100644
--- a/oci/remote/oci_update_current.py
+++ b/oci/remote/oci_update_current.py
@@ -137,6 +137,17 @@ def kept_settings(changes, deployment):
return kept
+def application_name(record, vmid):
+ """The name the user knows the application by, for its notifications."""
+ for template in ((record.get('stack') or {}).get('template') or {}, record.get('template') or {}):
+ title = (template.get('catalog_ui') or {}).get('title')
+ if isinstance(title, dict):
+ title = title.get('en_US') or next(iter(title.values()), '')
+ if isinstance(title, str) and title.strip():
+ return title.strip()
+ return f'CT {vmid}'
+
+
def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=None):
operation = 'recreate' if proposal is not None else 'update'
msg_info(translate('Checking the container before the update...') if operation == 'update'
@@ -169,9 +180,11 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
kept = kept_settings(changes, desired['deployment'])
if kept:
msg_info2(f"{translate('Keeping the settings changed in Proxmox:')} {', '.join(kept)}")
- transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
- registry_digest=digest, acknowledge_external_data=acknowledge_external_data,
- keep_backup=file_storage)
+ import oci_operation_notice
+ with oci_operation_notice.operation([vmid], operation, application_name(record, vmid)):
+ transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
+ registry_digest=digest, acknowledge_external_data=acknowledge_external_data,
+ keep_backup=file_storage)
def main():
diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py
index 53c09e16..3689d150 100644
--- a/oci/src/proxmenux_oci/cli.py
+++ b/oci/src/proxmenux_oci/cli.py
@@ -35,7 +35,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = {
"server": N_("Server"),
"application": N_("Application"),
- "machine_learning": N_("Machine learning"),
+ "machine_learning": "Machine learning",
"database": "PostgreSQL",
"valkey": "Valkey",
"cache": "Redis",
@@ -129,7 +129,7 @@ def _service_kind(service: dict[str, Any]) -> str:
if service.get("is_main"):
return translate("Application")
if "machine-learning" in name or "machine-learning" in image:
- return translate("Machine learning")
+ return "Machine learning"
for key, label in SERVICE_KINDS:
if key in image:
return label
@@ -403,6 +403,11 @@ def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -
break
except RestartWizard:
wizard.restart()
+ except (ValueError, InstallError) as error:
+ # A mistyped value asks that question again instead of
+ # sending the user back to the start of the wizard.
+ if not wizard.retry_last(error):
+ raise
finally:
wizard.close()
if not approved:
diff --git a/oci/src/proxmenux_oci/host.py b/oci/src/proxmenux_oci/host.py
index 3faebb9e..2ce148d6 100644
--- a/oci/src/proxmenux_oci/host.py
+++ b/oci/src/proxmenux_oci/host.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import ipaddress
import json
import re
+import shutil
import subprocess
from pathlib import Path
from typing import Any
@@ -136,6 +137,36 @@ def _sysfs(path: Path) -> str:
return ""
+def gpus(root: Path = Path("/")) -> dict[str, Any]:
+ """The GPUs an installation can use: the render nodes of each Intel and
+ AMD GPU, and whether NVIDIA is usable on the host."""
+ vendors = {"0x8086": "intel", "0x1002": "amd"}
+ found: dict[str, Any] = {"intel": [], "amd": [], "nvidia": False}
+ for node in sorted((root / "sys/class/drm").glob("renderD*")):
+ vendor = vendors.get(_sysfs(node / "device/vendor").lower())
+ if vendor:
+ found[vendor].append(f"/dev/dri/{node.name}")
+ # NVIDIA is usable when its driver answers and the Container Toolkit is installed.
+ if shutil.which("nvidia-smi") and shutil.which("nvidia-container-cli"):
+ try:
+ found["nvidia"] = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True,
+ timeout=15, check=False).returncode == 0
+ except (OSError, subprocess.TimeoutExpired):
+ found["nvidia"] = False
+ return found
+
+
+def rocm_blocker(storage: str | None, needed_gb: int = 40) -> str | None:
+ """Why this host cannot run recognition on an AMD GPU with ROCm, or None.
+ ROCm needs the compute interface of the driver and room for its image."""
+ if not Path("/dev/kfd").is_char_device():
+ return "kfd"
+ row = next((item for item in storages("rootdir") if item.get("storage") == storage), None)
+ if row is not None and gib(row.get("avail")) < needed_gb:
+ return "space"
+ return None
+
+
def usb_devices(root: Path = Path("/"), lsusb: str | None = None) -> list[dict[str, str]]:
"""USB peripherals of this node an LXC can receive, named as the Monitor
names them: a serial adapter by its tty node, any other device by its bus
diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py
index e7df3244..f5588b09 100644
--- a/oci/src/proxmenux_oci/installer.py
+++ b/oci/src/proxmenux_oci/installer.py
@@ -532,6 +532,10 @@ def build_deployment(
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
devices, completion_notes = configure_detector(installer_profile, devices, ui)
+ # What the selected acceleration profile leaves for the user to set.
+ completion_notes = [*next((profile.get("completion_notes", [])
+ for profile in installer_profile.get("hardware_acceleration", {}).get("profiles", [])
+ if profile["id"] == selected_hardware_profile), []), *completion_notes]
if advanced:
from .extra_devices import ask_extra_devices
@@ -788,6 +792,84 @@ def build_rclone_mount_deployment(
}
+def _ask_immich_acceleration(ui, rootfs_storage: str | None = None) -> tuple[str, str | None, str, str, str | None]:
+ """What runs Immich's video transcoding (the server) and its recognition
+ (the Machine learning container), asked in one menu in both modes. Each
+ usable GPU of the host can take both, or only one of them, and the CPU is
+ always there."""
+ software = ("cpu", None, "auto", "cpu", None)
+ real = essential_ui(ui)
+ found = host.gpus()
+ names = {"intel": "Intel", "amd": "AMD", "nvidia": "NVIDIA"}
+ vendors = [vendor for vendor in names if found[vendor]]
+ if not vendors:
+ real.message(translate("No usable GPU was found on this host. Immich will be installed on the CPU."))
+ return software
+ options = [("cpu", translate("No acceleration (CPU)"))]
+ for vendor in vendors:
+ options += [(vendor, f"{names[vendor]}: {translate('video + recognition')}"),
+ (f"{vendor}-video", f"{names[vendor]}: {translate('video only')}"),
+ (f"{vendor}-ml", f"{names[vendor]}: {translate('recognition only')}")]
+ if found["nvidia"]:
+ options += [(f"{vendor}+nvidia", f"{names[vendor]}: {translate('video')} · NVIDIA: {translate('recognition')}")
+ for vendor in ("intel", "amd") if found[vendor]]
+ # The GPU matters for Immich, so the first one is proposed whole.
+ selected = real.choose(translate("Hardware acceleration for Immich"), options, vendors[0])
+ if selected is None:
+ raise UserCancelled(translate("Immich configuration cancelled"))
+ if selected == "cpu":
+ return software
+ if "+" in selected:
+ video_vendor, ml_vendor = selected.split("+", 1)
+ else:
+ vendor, _, use = selected.partition("-")
+ video_vendor = vendor if use in ("", "video") else None
+ ml_vendor = vendor if use in ("", "ml") else None
+
+ video_acceleration, render_device, vaapi_driver = "cpu", None, "auto"
+ if video_vendor == "nvidia":
+ video_acceleration = "nvenc"
+ elif video_vendor:
+ nodes = found[video_vendor]
+ render_device = nodes[0]
+ if len(nodes) > 1:
+ render_device = ui.choose(translate("VA-API render device"), [(node, node) for node in nodes], nodes[0])
+ drivers = ([("auto", translate("Automatic detection")), ("iHD", "Intel iHD"), ("i965", "Intel i965")]
+ if video_vendor == "intel" else
+ [("auto", translate("Automatic detection")), ("radeonsi", "AMD radeonsi")])
+ vaapi_driver = ui.choose(translate("VA-API driver"), drivers, "auto")
+ if render_device is None or vaapi_driver is None:
+ raise UserCancelled(translate("Immich configuration cancelled"))
+ video_acceleration = "vaapi"
+
+ ml_acceleration, ml_render = "cpu", None
+ if ml_vendor == "nvidia":
+ ml_acceleration = "cuda"
+ elif ml_vendor == "intel":
+ ml_acceleration, ml_render = "openvino", render_device or found["intel"][0]
+ elif ml_vendor == "amd":
+ # ROCm is checked before it is promised; when the host cannot run it,
+ # recognition stays on the CPU.
+ blocker = host.rocm_blocker(rootfs_storage)
+ if blocker:
+ reason = (translate("The AMD driver does not offer its compute interface (/dev/kfd) on this host.")
+ if blocker == "kfd" else
+ translate("The storage has less than 40 GB free for the ROCm image."))
+ real.message(f"{reason}\n\n{translate('Recognition runs on the CPU.')}")
+ else:
+ ml_acceleration, ml_render = "rocm", render_device or found["amd"][0]
+ if ml_acceleration == "rocm":
+ real.message(translate("Recognition on AMD uses ROCm. Its image is several times larger than the others, "
+ "so the first installation takes longer, and whether a GPU works with it depends "
+ "on its model."))
+ if ml_acceleration != "cpu":
+ ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
+ "were tested in the lab and are not a universal minimum. Compatibility depends on the "
+ "GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
+ "keeps the CPU topology."))
+ return video_acceleration, render_device, vaapi_driver, ml_acceleration, ml_render
+
+
def _build_immich_deployment(
template: dict[str, Any],
ui: TerminalUI | DialogUI,
@@ -798,7 +880,7 @@ def _build_immich_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
- resources = ask_application_resources(ui, 4, 3072, 1024)
+ resources = ask_application_resources(ui, 4, 4096, 1024)
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
@@ -833,49 +915,11 @@ def _build_immich_deployment(
extra_mounts = ask_application_extra_paths(ui, ["/data"], media_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Immich"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(
- essential_ui(ui), frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
+ essential_ui(ui), frontend_bridge, [translate("Immich server"), "Immich Machine learning"])
server_ipv4, ml_ipv4 = addresses.values()
timezone = ui.ask(translate("Timezone"), host.timezone())
- video_acceleration = ui.choose(
- translate("Video transcoding acceleration"),
- [("vaapi", "VA-API"), ("cpu", "CPU")],
- defaults["video_transcoding"]["acceleration"],
- )
- if video_acceleration is None:
- raise UserCancelled(translate("Immich configuration cancelled"))
- render_device = None
- vaapi_driver = "auto"
- if video_acceleration == "vaapi":
- render_device = ui.ask(
- translate("VA-API render device"), defaults["video_transcoding"]["render_device"]
- )
- vaapi_driver = ui.choose(
- translate("VA-API driver"),
- [("auto", translate("Automatic detection")), ("radeonsi", "AMD radeonsi"), ("iHD", "Intel iHD"), ("i965", "Intel i965")],
- defaults["video_transcoding"]["driver"],
- )
- if vaapi_driver is None:
- raise UserCancelled(translate("Immich configuration cancelled"))
- ml_acceleration = ui.choose(
- translate("Acceleration for Immich smart recognition"),
- [("cpu", "CPU"), ("openvino", "Intel GPU / OpenVINO"),
- ("cuda", translate("NVIDIA GPU / CUDA (Toolkit on the host)"))],
- "cpu",
- )
- if ml_acceleration is None:
- raise UserCancelled(translate("Immich configuration cancelled"))
- if ml_acceleration not in ("cpu", "openvino", "cuda"):
- raise InstallError(translate("Recognition profile not implemented"))
- ml_render = None
- if ml_acceleration == "openvino":
- ml_render = ui.ask(translate("Intel render device for recognition"), "/dev/dri/renderD128")
- if not re.fullmatch(r"/dev/dri/renderD[0-9]+", ml_render):
- raise InstallError(translate("Invalid Intel render path"))
- if ml_acceleration != "cpu":
- ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
- "were tested in the lab and are not a universal minimum. Compatibility depends on the "
- "GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
- "keeps the CPU topology."))
+ (video_acceleration, render_device, vaapi_driver,
+ ml_acceleration, ml_render) = _ask_immich_acceleration(ui, rootfs_storage)
extra_devices = ask_application_extra_devices(ui, ("usb",))
return {
"deployment_kind": "immich-four-lxc-stack",
@@ -919,8 +963,8 @@ def _build_immich_deployment(
},
"machine_learning": {"acceleration": ml_acceleration, "render_device": ml_render,
"model_cache_size_gb": 8,
- "resources": {"cores": 2 if ml_acceleration == "cpu" else 4,
- "memory_mb": 2048 if ml_acceleration == "cpu" else 8192,
+ "resources": {"cores": 4,
+ "memory_mb": 4096 if ml_acceleration == "cpu" else 8192,
"swap_mb": 1024,
"cpu_allocation": "quota" if ml_acceleration == "openvino" else "cpuset"}},
}
@@ -1630,6 +1674,26 @@ def configure_detector(installer_profile, devices, ui, root=Path("/")):
return [*devices, device], list(chosen.get("completion_notes", []))
+def _profile_usable(profile: dict[str, Any], found: dict[str, Any]) -> bool:
+ """Whether the host has what an acceleration profile needs: the NVIDIA
+ runtime, a GPU of the vendor it is written for, or ROCm's compute device."""
+ vendors = {"0x8086": "intel", "0x1002": "amd"}
+ for request in profile.get("device_requests", []):
+ if request.get("kind") == "nvidia-runtime":
+ if not found["nvidia"]:
+ return False
+ continue
+ path = str(request.get("host_path_default") or "")
+ if path == "/dev/kfd":
+ if not Path(path).is_char_device():
+ return False
+ elif path.startswith("/dev/dri/"):
+ wanted = [vendors[item] for item in request.get("drm_vendor_ids", []) if item in vendors] or list(vendors.values())
+ if not any(found[vendor] for vendor in wanted):
+ return False
+ return True
+
+
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
advanced = mode != DEFAULT_MODE
devices: list[dict[str, Any]] = []
@@ -1640,14 +1704,25 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
hardware = installer_profile.get("hardware_acceleration")
if hardware:
profiles = hardware.get("profiles", [])
- options = [(item["id"], item["label"]) for item in profiles]
default_profile = hardware.get("default", profiles[0]["id"] if profiles else None)
+ # Only what this host can run is offered; the profile already in use
+ # stays in the list so a recreation never loses it.
+ found = host.gpus()
+ usable = [item for item in profiles
+ if item["id"] == default_profile or _profile_usable(item, found)]
+ options = [(item["id"], item["label"]) for item in usable]
+ asked = advanced or not installer_profile.get("selkies")
+ if asked and len(usable) < len(profiles) and len(usable) == 1:
+ # Nothing but the CPU is left: say why there is nothing to choose.
+ ui.message(translate("No usable GPU was found on this host. The application will be installed "
+ "without hardware acceleration."))
+ asked = False
selected_hardware_profile = (
ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label)) for tag, label in options],
default_profile,
- ) if advanced or not installer_profile.get("selkies") else default_profile
+ ) if asked else default_profile
)
if selected_hardware_profile is None:
raise UserCancelled(translate("Acceleration configuration cancelled"))
@@ -1710,6 +1785,12 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
]
devices.append(device)
else:
+ # The render node proposed is one of the GPU the profile is for;
+ # renderD128 is not always it on a host with two GPUs.
+ nodes = [node for vendor_id, vendor in (("0x8086", "intel"), ("0x1002", "amd"))
+ if vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]]
+ if nodes and item["host_path_default"] not in nodes:
+ item = {**item, "host_path_default": nodes[0]}
if not advanced:
host_path = item["host_path_default"]
elif item.get("purpose") in ("serial", "user-selected-device"):
diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py
index 7d850908..a7432a32 100644
--- a/oci/src/proxmenux_oci/management.py
+++ b/oci/src/proxmenux_oci/management.py
@@ -241,6 +241,9 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
break
except RestartWizard:
wizard.restart()
+ except ValueError as error:
+ if not wizard.retry_last(error):
+ raise
finally:
wizard.close()
if not approved:
diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py
index 7e55b8c1..abda0aa2 100644
--- a/oci/src/proxmenux_oci/ui.py
+++ b/oci/src/proxmenux_oci/ui.py
@@ -43,6 +43,21 @@ class BacktrackUI:
def restart(self):
self.cursor = 0
+ def retry_last(self, error) -> bool:
+ """After an answer the wizard could not accept: say why and ask that
+ question again, keeping every answer given before it. False when no
+ answer was given yet, so there is nothing to ask again."""
+ if not self.answers:
+ return False
+ text = str(error)
+ # What Python says about a number it could not read is not for the user.
+ if text.startswith(("invalid literal for int()", "could not convert string to float")):
+ text = f"{translate('The value must be a number:')} {text.rsplit(':', 1)[-1].strip()}"
+ self.base.message(f"{text}\n\n{translate('Enter the value again.')}")
+ self.answers.pop()
+ self.cursor = 0
+ return True
+
def _call(self, name, *args, **kwargs):
if self.cursor < len(self.answers):
saved_name, value = self.answers[self.cursor]
diff --git a/oci/tests/test_acceleration_profiles_of_the_host.py b/oci/tests/test_acceleration_profiles_of_the_host.py
new file mode 100644
index 00000000..dc0ec3f5
--- /dev/null
+++ b/oci/tests/test_acceleration_profiles_of_the_host.py
@@ -0,0 +1,79 @@
+"""An application offers the acceleration profiles the host can run."""
+
+from pathlib import Path
+import sys
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
+from test_advanced_flow_order import RecordingUI, storages
+
+NODE = "/dev/dri/renderD128"
+
+
+class OptionsUI(RecordingUI):
+ def __init__(self, answers=None):
+ super().__init__(answers)
+ self.options, self.messages = {}, []
+
+ def choose(self, text, options, default=None):
+ self.options[text] = [tag for tag, _ in options]
+ return super().choose(text, options, default)
+
+ def message(self, text, title=None):
+ self.messages.append(text)
+
+
+@patch("proxmenux_oci.i18n.language", return_value="en")
+@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
+@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
+@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
+class HostProfileTests(unittest.TestCase):
+ catalog = Catalog(ROOT)
+
+ def offered(self, app, gpus, kfd=False, answer=None):
+ template = self.catalog.compose(app)
+ prompt = template["proxmox"]["installer_profile"]["hardware_acceleration"]["prompt"]
+ ui = OptionsUI({prompt: answer} if answer else None)
+ with patch("proxmenux_oci.installer.host.gpus", return_value=gpus), \
+ patch("pathlib.Path.is_char_device", return_value=kfd):
+ plan = build_deployment(template, ui, DEFAULT_MODE)
+ return ui.options.get(prompt), ui, plan
+
+ def test_an_amd_host_is_not_offered_nvidia(self, *_):
+ amd = {"intel": [], "amd": [NODE], "nvidia": False}
+ self.assertEqual(self.offered("frigate", amd, kfd=True)[0], ["none", "vaapi", "rocm"])
+ self.assertEqual(self.offered("ollama", amd, kfd=True)[0], ["cpu", "rocm"])
+ self.assertEqual(self.offered("llamacpp", amd, kfd=True)[0], ["cpu", "rocm"])
+
+ def test_rocm_is_not_offered_without_its_compute_device(self, *_):
+ amd = {"intel": [], "amd": [NODE], "nvidia": False}
+ self.assertEqual(self.offered("frigate", amd, kfd=False)[0], ["none", "vaapi"])
+
+ def test_an_intel_and_nvidia_host_is_not_offered_amd(self, *_):
+ both = {"intel": [NODE], "amd": [], "nvidia": True}
+ self.assertEqual(self.offered("frigate", both)[0], ["none", "vaapi", "nvidia"])
+ self.assertEqual(self.offered("llamacpp", both)[0], ["cpu", "nvidia", "intel"])
+
+ def test_a_host_without_gpu_is_told_and_not_asked(self, *_):
+ nothing = {"intel": [], "amd": [], "nvidia": False}
+ for app in ("faster-whisper", "ollama", "frigate"):
+ options, ui, plan = self.offered(app, nothing)
+ self.assertIsNone(options, app)
+ self.assertTrue(any("No usable GPU" in message for message in ui.messages), app)
+ self.assertEqual(plan["devices"], [], app)
+
+ def test_the_render_node_proposed_belongs_to_the_gpu_of_the_profile(self, *_):
+ # The first render node of this host is the NVIDIA one; Intel's is the second.
+ gpus = {"intel": ["/dev/dri/renderD129"], "amd": [], "nvidia": True}
+ _, _, plan = self.offered("llamacpp", gpus, answer="intel")
+ self.assertEqual([device["host_path"] for device in plan["devices"]], ["/dev/dri/renderD129"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_ai_gpu_profiles.py b/oci/tests/test_ai_gpu_profiles.py
new file mode 100644
index 00000000..548db08f
--- /dev/null
+++ b/oci/tests/test_ai_gpu_profiles.py
@@ -0,0 +1,59 @@
+"""The AI applications whose image depends on the GPU take the image and the
+devices of the profile that is chosen."""
+
+from pathlib import Path
+import json
+import sys
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
+from test_advanced_flow_order import RecordingUI, storages
+
+RENDER, KFD = "/dev/dri/renderD128", "/dev/kfd"
+EXPECTED = {
+ "ollama": {"cpu": (":latest", []), "nvidia": (":latest", ["nvidia-runtime"]), "rocm": (":rocm", [RENDER, KFD])},
+ "llamacpp": {"cpu": (":server", []), "nvidia": (":server-cuda", ["nvidia-runtime"]),
+ "rocm": (":server-rocm", [RENDER, KFD]), "intel": (":server-intel", [RENDER])},
+ "faster-whisper": {"cpu": (":latest", []), "nvidia": (":gpu", ["nvidia-runtime"])},
+ "piper": {"cpu": (":latest", []), "nvidia": (":gpu", ["nvidia-runtime"])},
+}
+SOURCES = {"ollama": "overlays", "llamacpp": "curated", "faster-whisper": "overlays", "piper": "overlays"}
+
+
+# Every profile is offered: what the host has is checked in its own test.
+@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": ["/dev/dri/renderD128"], "nvidia": True})
+@patch("proxmenux_oci.installer._profile_usable", return_value=True)
+@patch("proxmenux_oci.i18n.language", return_value="en")
+@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
+@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
+@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
+class AiGpuProfileTests(unittest.TestCase):
+ catalog = Catalog(ROOT)
+
+ def test_each_profile_installs_its_image_with_its_devices(self, *_):
+ for app, profiles in EXPECTED.items():
+ hardware = self.catalog.compose(app)["proxmox"]["installer_profile"]["hardware_acceleration"]
+ self.assertEqual([profile["id"] for profile in hardware["profiles"]], list(profiles), app)
+ self.assertEqual(hardware["default"], "cpu", app)
+ for profile, (tag, devices) in profiles.items():
+ template = self.catalog.compose(app)
+ plan = build_deployment(template, RecordingUI({hardware["prompt"]: profile}), DEFAULT_MODE)
+ self.assertTrue(template["container_contract"]["image"]["reference"].endswith(tag), (app, profile))
+ self.assertEqual([device.get("host_path") or device["kind"] for device in plan["devices"]],
+ devices, (app, profile))
+
+ def test_the_shipped_copy_matches_its_source(self, *_):
+ read = lambda place, app: json.loads((ROOT / f"catalog/{place}/{app}.json").read_text())[
+ "proxmox"]["installer_profile"]["hardware_acceleration"]
+ for app, source in SOURCES.items():
+ self.assertEqual(read(source, app), read("apps", app), app)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_default_install_essentials.py b/oci/tests/test_default_install_essentials.py
index 3cb995ae..391fb6f4 100644
--- a/oci/tests/test_default_install_essentials.py
+++ b/oci/tests/test_default_install_essentials.py
@@ -35,6 +35,8 @@ def storages_used(plan):
return used
+# The GPUs of the host the tests run on are not part of what they check.
+@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": [], "nvidia": False})
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@@ -61,7 +63,7 @@ class DefaultInstallEssentialsTests(unittest.TestCase):
"Nextcloud volume size in GB", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"immich": [STORAGE, "Where to store the Immich library", "Library size in GB", ADDRESS,
- "Start the stack with Proxmox", "Start when finished"],
+ "Hardware acceleration for Immich", "Start the stack with Proxmox", "Start when finished"],
"tandoor": [STORAGE, "Where to store the recipe images and files", "Files volume size in GB", ADDRESS,
"Start the stack with Proxmox"],
"paperless-ngx": [STORAGE, "Documents volume size in GB", "Where to store the consume and export folders",
diff --git a/oci/tests/test_frigate_amd_profile.py b/oci/tests/test_frigate_amd_profile.py
new file mode 100644
index 00000000..a1f4e8cc
--- /dev/null
+++ b/oci/tests/test_frigate_amd_profile.py
@@ -0,0 +1,55 @@
+"""Frigate's AMD profile takes the image built for ROCm and the two devices
+it needs, and says what is left for the user to set."""
+
+from pathlib import Path
+import sys
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
+from test_advanced_flow_order import RecordingUI, storages
+
+PROMPT = "Hardware acceleration for Frigate"
+
+
+# Every profile is offered: what the host has is checked in its own test.
+@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": ["/dev/dri/renderD128"], "nvidia": True})
+@patch("proxmenux_oci.installer._profile_usable", return_value=True)
+@patch("proxmenux_oci.i18n.language", return_value="en")
+@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
+@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
+@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
+class FrigateAmdProfileTests(unittest.TestCase):
+ def build(self, profile):
+ template = Catalog(ROOT).compose("frigate")
+ plan = build_deployment(template, RecordingUI({PROMPT: profile}), DEFAULT_MODE)
+ return template, plan
+
+ def test_the_amd_profile_uses_the_rocm_image_with_both_devices(self, *_):
+ template, plan = self.build("rocm")
+ self.assertEqual(template["container_contract"]["image"]["reference"],
+ "ghcr.io/blakeblackshear/frigate:stable-rocm")
+ self.assertEqual([device["host_path"] for device in plan["devices"]], ["/dev/dri/renderD128", "/dev/kfd"])
+ self.assertEqual(plan["devices"][0]["drm_vendor_ids"], ["0x1002"])
+ self.assertTrue(any("type: onnx" in note for note in plan["completion_notes"]))
+
+ def test_the_other_profiles_keep_their_image(self, *_):
+ for profile, tag in (("none", "stable"), ("vaapi", "stable"), ("nvidia", "stable-tensorrt")):
+ template, plan = self.build(profile)
+ self.assertTrue(template["container_contract"]["image"]["reference"].endswith(":" + tag), profile)
+ self.assertFalse(plan.get("completion_notes"), profile)
+
+ def test_the_shipped_copy_matches_the_curated_profile(self, *_):
+ import json
+ read = lambda name: json.loads((ROOT / f"catalog/{name}/frigate.json").read_text())[
+ "proxmox"]["installer_profile"]["hardware_acceleration"]
+ self.assertEqual(read("curated"), read("apps"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_immich_acceleration.py b/oci/tests/test_immich_acceleration.py
new file mode 100644
index 00000000..c333fa68
--- /dev/null
+++ b/oci/tests/test_immich_acceleration.py
@@ -0,0 +1,129 @@
+"""Immich asks in one menu, in both installation modes, what runs its video
+transcoding and its recognition: the CPU, or each usable GPU of the host for
+both or for only one of them."""
+
+from pathlib import Path
+import sys
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
+from test_advanced_flow_order import RecordingUI, addresses, storages
+
+PROMPT = "Hardware acceleration for Immich"
+NODE = "/dev/dri/renderD128"
+INTEL = {"intel": [NODE], "amd": [], "nvidia": False}
+AMD = {"intel": [], "amd": [NODE], "nvidia": False}
+BOTH = {"intel": [NODE], "amd": [], "nvidia": True}
+NOTHING = {"intel": [], "amd": [], "nvidia": False}
+
+
+class OptionsUI(RecordingUI):
+ def __init__(self, answers=None):
+ super().__init__(answers)
+ self.options = {}
+ self.defaults = {}
+ self.messages = []
+
+ def choose(self, text, options, default=None):
+ self.options[text] = [tag for tag, _ in options]
+ self.defaults[text] = default
+ return super().choose(text, options, default)
+
+ def message(self, text, title=None):
+ self.messages.append(text)
+
+
+@patch("proxmenux_oci.i18n.language", return_value="en")
+@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
+@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
+@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
+@patch("proxmenux_oci.installer.host.rocm_blocker", return_value=None)
+@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
+class ImmichAccelerationTests(unittest.TestCase):
+ template = Catalog(ROOT).compose("immich")
+
+ def plan(self, gpus, mode, answer=None):
+ ui = OptionsUI({PROMPT: answer} if answer else None)
+ with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
+ plan = build_deployment(self.template, ui, mode)
+ return ui, (plan["video_transcoding"]["acceleration"], plan["machine_learning"]["acceleration"]), plan
+
+ def test_the_menu_is_asked_in_both_modes_with_what_the_host_has(self, *_):
+ for mode in (DEFAULT_MODE, ADVANCED_MODE):
+ ui, _, _ = self.plan(INTEL, mode)
+ self.assertEqual(ui.options[PROMPT], ["cpu", "intel", "intel-video", "intel-ml"], mode)
+ ui, _, _ = self.plan(BOTH, DEFAULT_MODE)
+ self.assertEqual(ui.options[PROMPT], ["cpu", "intel", "intel-video", "intel-ml",
+ "nvidia", "nvidia-video", "nvidia-ml", "intel+nvidia"])
+
+ def test_the_first_gpu_is_proposed_whole(self, *_):
+ ui, result, _ = self.plan(BOTH, DEFAULT_MODE)
+ self.assertEqual(ui.defaults[PROMPT], "intel")
+ self.assertEqual(result, ("vaapi", "openvino"))
+
+ def test_every_option_gives_the_gpu_to_what_it_names(self, *_):
+ expected = {"cpu": ("cpu", "cpu"), "intel": ("vaapi", "openvino"), "intel-video": ("vaapi", "cpu"),
+ "intel-ml": ("cpu", "openvino"), "nvidia": ("nvenc", "cuda"), "nvidia-video": ("nvenc", "cpu"),
+ "nvidia-ml": ("cpu", "cuda"), "intel+nvidia": ("vaapi", "cuda")}
+ for answer, result in expected.items():
+ self.assertEqual(self.plan(BOTH, DEFAULT_MODE, answer)[1], result, answer)
+ for answer, result in {"amd": ("vaapi", "rocm"), "amd-video": ("vaapi", "cpu"),
+ "amd-ml": ("cpu", "rocm")}.items():
+ self.assertEqual(self.plan(AMD, DEFAULT_MODE, answer)[1], result, answer)
+
+ def test_recognition_alone_still_gets_its_render_device(self, *_):
+ for gpus, answer in ((INTEL, "intel-ml"), (AMD, "amd-ml")):
+ _, _, plan = self.plan(gpus, DEFAULT_MODE, answer)
+ self.assertEqual(plan["machine_learning"]["render_device"], NODE, answer)
+ self.assertIsNone(plan["video_transcoding"]["render_device"], answer)
+
+ def test_a_host_without_usable_gpu_says_so_and_installs_on_the_cpu(self, *_):
+ for mode in (DEFAULT_MODE, ADVANCED_MODE):
+ ui, result, _ = self.plan(NOTHING, mode)
+ self.assertNotIn(PROMPT, ui.options, mode)
+ self.assertEqual(len(ui.messages), 1, mode)
+ self.assertIn("No usable GPU", ui.messages[0])
+ self.assertEqual(result, ("cpu", "cpu"), mode)
+
+ def test_an_amd_host_that_cannot_run_rocm_says_so_and_recognises_on_the_cpu(self, *_):
+ for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB")):
+ with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker):
+ ui, result, _ = self.plan(AMD, DEFAULT_MODE, "amd")
+ self.assertEqual(result, ("vaapi", "cpu"), blocker)
+ self.assertTrue(any(text in message and "Recognition runs on the CPU." in message
+ for message in ui.messages), blocker)
+
+ def test_machine_learning_gets_four_cores_and_at_least_four_gigabytes(self, *_):
+ _, _, plan = self.plan(BOTH, DEFAULT_MODE, "cpu")
+ self.assertEqual(plan["machine_learning"]["resources"]["cores"], 4)
+ self.assertEqual(plan["machine_learning"]["resources"]["memory_mb"], 4096)
+ for gpus, answer in ((BOTH, "nvidia"), (INTEL, "intel"), (AMD, "amd")):
+ _, _, plan = self.plan(gpus, DEFAULT_MODE, answer)
+ self.assertEqual(plan["machine_learning"]["resources"]["memory_mb"], 8192, answer)
+
+ def test_the_installers_give_the_gpu_to_both_containers(self, *_):
+ script = (ROOT / "remote/install_immich_stack.sh").read_text()
+ helper = (ROOT / "remote/oci_immich_ml.sh").read_text()
+ self.assertIn('configure_immich_nvidia "$SERVER_ID" "compute,video,utility"', script)
+ self.assertIn('configure_immich_nvidia "$ML_ID" "compute,utility"', helper)
+ self.assertIn('--dev1 "path=/dev/kfd', helper)
+ self.assertIn("MIGraphXExecutionProvider", helper)
+ self.assertIn("ML_ROOTFS_SIZE=40", helper)
+ self.assertIn('--rootfs "${ROOTFS_STORAGE}:${ML_ROOTFS_SIZE}"', script)
+ self.assertIn("'rocm')", (ROOT / "remote/oci_stack_replay.py").read_text())
+ self.assertIn("MIGraphXExecutionProvider", (ROOT / "remote/oci_stack_native.py").read_text())
+
+ def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_):
+ script = (ROOT / "remote/install_immich_stack.sh").read_text()
+ self.assertNotIn('translate "Machine learning"', script)
+ self.assertNotIn('translate("Machine learning")', (ROOT / "src/proxmenux_oci/cli.py").read_text())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_operation_notice.py b/oci/tests/test_operation_notice.py
new file mode 100644
index 00000000..f04e7474
--- /dev/null
+++ b/oci/tests/test_operation_notice.py
@@ -0,0 +1,62 @@
+"""An update or a recreation marks its containers for the Monitor and reports
+its result once, whether it works or fails."""
+
+import json
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "remote"))
+
+import oci_operation_notice as notice
+
+
+class OperationNoticeTests(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.markers = Path(tmp.name)
+ patcher = patch.object(notice, "MARKERS", self.markers)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def mark(self, vmid):
+ return json.loads((self.markers / str(vmid)).read_text())
+
+ def test_the_containers_are_marked_while_it_runs_and_the_result_is_sent(self):
+ with patch.object(notice, "notify") as notify:
+ with notice.operation([115, 116], "update", "Immich", 115):
+ self.assertIsNone(self.mark(115)["ended"])
+ self.assertIsNone(self.mark(116)["ended"])
+ notify.assert_not_called()
+ self.assertIsNotNone(self.mark(115)["ended"])
+ notify.assert_called_once_with("oci_update_completed",
+ {"app_name": "Immich", "vmid": 115, "containers": "CT 115, CT 116"})
+
+ def test_a_failure_is_reported_with_its_reason_and_raised(self):
+ with patch.object(notice, "notify") as notify:
+ with self.assertRaises(RuntimeError):
+ with notice.operation([120], "recreate", "Jellyfin"):
+ raise RuntimeError("the new image did not answer")
+ event, data = notify.call_args.args
+ self.assertEqual(event, "oci_recreate_failed")
+ self.assertEqual(data["reason"], "the new image did not answer")
+ self.assertIsNotNone(self.mark(120)["ended"])
+
+ def test_a_monitor_that_does_not_answer_never_stops_the_operation(self):
+ with patch.object(notice.urllib.request, "urlopen", side_effect=OSError("refused")):
+ self.assertFalse(notice.notify("oci_update_completed", {"app_name": "x"}))
+ with notice.operation([120], "update", "Jellyfin"):
+ pass
+
+ def test_both_engines_report_through_it(self):
+ self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_update_current.py").read_text())
+ self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_stack_native.py").read_text())
+ self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_stack_modify.py").read_text())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_stack_resources.py b/oci/tests/test_stack_resources.py
index e7130ce0..1814e801 100644
--- a/oci/tests/test_stack_resources.py
+++ b/oci/tests/test_stack_resources.py
@@ -15,7 +15,7 @@ from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
-SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 3072)}
+SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 4096)}
REMOTE = {"nextcloud-stack": "nextcloud", "paperless-ngx": "paperless", "tandoor": "tandoor", "immich": "immich"}
diff --git a/oci/tests/test_wizard_retry.py b/oci/tests/test_wizard_retry.py
new file mode 100644
index 00000000..4922e911
--- /dev/null
+++ b/oci/tests/test_wizard_retry.py
@@ -0,0 +1,64 @@
+"""A value the wizard cannot accept asks that question again, with every
+earlier answer kept, instead of ending the wizard."""
+
+from pathlib import Path
+import sys
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from proxmenux_oci import cli
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import ADVANCED_MODE
+from test_advanced_flow_order import RecordingUI, addresses, storages
+
+
+class TypoUI(RecordingUI):
+ """Types 8o for the cores the first time, and 8 the second."""
+ back_enabled = False
+
+ def __init__(self):
+ super().__init__()
+ self.messages = []
+ self.cores = iter(["8o", "8"])
+
+ def ask(self, text, default=None, required=True):
+ if text == "CPU cores":
+ self.asked.append(text)
+ return next(self.cores)
+ return super().ask(text, default, required)
+
+ def message(self, text, title=None):
+ self.messages.append(text)
+
+ def review(self, text, title=None, question=None, default=True):
+ return False
+
+
+@patch("proxmenux_oci.i18n.language", return_value="en")
+@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
+@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
+@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
+@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False})
+@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
+class WizardRetryTests(unittest.TestCase):
+ def test_a_mistyped_number_asks_the_same_question_again(self, *_):
+ ui = TypoUI()
+ cli.install_template(ui, Catalog(ROOT).compose("tandoor"), "tandoor", ADVANCED_MODE)
+ self.assertEqual(ui.asked.count("CPU cores"), 2)
+ # The answers given before the mistake are replayed, not asked again.
+ self.assertEqual(ui.asked.count("Stack name"), 1)
+ self.assertEqual(len(ui.messages), 1)
+ self.assertIn("The value must be a number: '8o'", ui.messages[0])
+ self.assertIn("Enter the value again.", ui.messages[0])
+
+ def test_an_error_before_any_answer_still_ends_the_wizard(self, *_):
+ from proxmenux_oci.ui import BacktrackUI
+ self.assertFalse(BacktrackUI(TypoUI()).retry_last(ValueError("x")))
+
+
+if __name__ == "__main__":
+ unittest.main()