From 25cb81d740abec916575e25a81c2a32ec1efa595 Mon Sep 17 00:00:00 2001 From: martino <32328813+f3rs3n@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:31:16 +0200 Subject: [PATCH] fix(notifications): localize sensor alerts and preserve email context --- AppImage/messages/de/common.json | 13 +- AppImage/messages/en/common.json | 2 +- AppImage/messages/es/common.json | 13 +- AppImage/messages/fr/common.json | 13 +- AppImage/messages/it/common.json | 13 +- AppImage/messages/pt/common.json | 13 +- AppImage/messages/sk/common.json | 13 +- AppImage/messages/sv/common.json | 13 +- AppImage/scripts/health_monitor.py | 3 + AppImage/scripts/notification_channels.py | 9 +- AppImage/scripts/notification_templates.py | 16 +- .../tests/test_notification_runtime_i18n.py | 9 +- tests/test_notification_active_payloads.py | 196 ++++++++++++++++-- 13 files changed, 279 insertions(+), 47 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 49e69925..59447cba 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -6446,8 +6446,8 @@ "label": "Hohe Speichernutzung" }, "temp_high": { - "title": "{hostname}: Hohe CPU-Temperatur – {value}°C", - "body": "Die CPU-Temperatur hat {value}°C erreicht (Schwellenwert: {threshold}°C).\n{details}", + "title": "{hostname}: Hohe Sensortemperatur – {value}°C", + "body": "Die Sensortemperatur hat {value}°C erreicht (Schwellenwert: {threshold}°C).\n{details}", "label": "Hohe Temperatur" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Gesundheitsprüfung beeinträchtigt", - "none": "keine" + "none": "keine", + "temperatureAlertTitle": "{hostname}: Warnung zur Sensortemperatur", + "temperatureAlertBody": "Ein Temperaturalarm wurde ohne vollständige Messdaten erfasst.", + "recordedReason": "Erfasster Grund: {reason}", + "recordedDetails": "Erfasste Details: {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Gastgeber", "footer": "ProxMenux Benachrichtigungsdienst" } + }, + "temperature": { + "sampleSpan": "Die hohen Messwerte erstrecken sich über {duration}." } } } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 3be4e3d9..cc497d8f 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -6251,5 +6251,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"}}}}} + "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"}}},"temperature":{"sampleSpan":"High samples span {duration}."}}} } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index f6b1335a..afb508a4 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -6446,8 +6446,8 @@ "label": "Uso elevado de memoria" }, "temp_high": { - "title": "{hostname}: Temperatura alta de la CPU: {value}°C", - "body": "La temperatura de la CPU ha alcanzado {value}°C (umbral: {threshold}°C).\n{details}", + "title": "{hostname}: Temperatura elevada del sensor — {value}°C", + "body": "La temperatura del sensor ha alcanzado {value}°C (umbral: {threshold}°C).\n{details}", "label": "Alta temperatura" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Comprobación de salud degradada", - "none": "ninguno" + "none": "ninguno", + "temperatureAlertTitle": "{hostname}: Alerta de temperatura del sensor", + "temperatureAlertBody": "Se registró una alerta de temperatura sin datos de medición completos.", + "recordedReason": "Motivo registrado: {reason}", + "recordedDetails": "Detalles registrados: {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Host", "footer": "Servicio de notificaciones de ProxMenux" } + }, + "temperature": { + "sampleSpan": "Las muestras elevadas abarcan {duration}." } } } diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 7c356470..81b5efe3 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -6446,8 +6446,8 @@ "label": "Utilisation élevée de la mémoire" }, "temp_high": { - "title": "{hostname} : température élevée du processeur – {value}°C", - "body": "La température du processeur a atteint {value}°C (seuil : {threshold}°C).\n{details}", + "title": "{hostname} : température élevée du capteur – {value}°C", + "body": "La température du capteur a atteint {value}°C (seuil : {threshold}°C).\n{details}", "label": "Haute température" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname} : {event_type}", "healthCheckDegraded": "Bilan de santé dégradé", - "none": "aucun" + "none": "aucun", + "temperatureAlertTitle": "{hostname} : alerte de température du capteur", + "temperatureAlertBody": "Une alerte de température a été enregistrée sans données de mesure complètes.", + "recordedReason": "Motif enregistré : {reason}", + "recordedDetails": "Détails enregistrés : {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Hôte", "footer": "ProxMenux Service de notification" } + }, + "temperature": { + "sampleSpan": "Les relevés élevés s'étendent sur {duration}." } } } diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 020022cc..b8e733e8 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -6446,8 +6446,8 @@ "label": "Utilizzo elevato della memoria" }, "temp_high": { - "title": "{hostname}: temperatura elevata della CPU — {value}°C", - "body": "La temperatura della CPU ha raggiunto {value}°C (soglia: {threshold}°C).\n{details}", + "title": "{hostname}: temperatura elevata del sensore — {value}°C", + "body": "La temperatura del sensore ha raggiunto {value}°C (soglia: {threshold}°C).\n{details}", "label": "Alta temperatura" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Controllo sanitario degradato", - "none": "nessuno" + "none": "nessuno", + "temperatureAlertTitle": "{hostname}: avviso sulla temperatura del sensore", + "temperatureAlertBody": "È stato registrato un avviso di temperatura senza dati di misurazione completi.", + "recordedReason": "Motivo registrato: {reason}", + "recordedDetails": "Dettagli registrati: {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Ospite", "footer": "ProxMenux Servizio di notifica" } + }, + "temperature": { + "sampleSpan": "Intervallo dei campioni sopra soglia: {duration}." } } } diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index b631de0e..0527db09 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -6446,8 +6446,8 @@ "label": "Alto uso de memória" }, "temp_high": { - "title": "{hostname}: Alta temperatura da CPU - {value}°C", - "body": "A temperatura da CPU atingiu {value}°C (limite: {threshold}°C).\n{details}", + "title": "{hostname}: Temperatura elevada do sensor — {value}°C", + "body": "A temperatura do sensor atingiu {value}°C (limite: {threshold}°C).\n{details}", "label": "Alta temperatura" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Exame de saúde degradado", - "none": "nenhum" + "none": "nenhum", + "temperatureAlertTitle": "{hostname}: Alerta de temperatura do sensor", + "temperatureAlertBody": "Foi registado um alerta de temperatura sem dados de medição completos.", + "recordedReason": "Motivo registado: {reason}", + "recordedDetails": "Detalhes registados: {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Anfitrião", "footer": "Serviço de Notificação ProxMenux" } + }, + "temperature": { + "sampleSpan": "As amostras elevadas abrangem {duration}." } } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 47ba9fe6..5fbe0fec 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -6445,8 +6445,8 @@ "label": "Vysoké využitie pamäte" }, "temp_high": { - "title": "{hostname}: Vysoká teplota CPU — {value}°C", - "body": "Teplota CPU dosiahla {value}°C (prahová hodnota: {threshold}°C).\n{details}", + "title": "{hostname}: Vysoká teplota snímača — {value}°C", + "body": "Teplota snímača dosiahla {value}°C (prahová hodnota: {threshold}°C).\n{details}", "label": "Vysoká teplota" }, "disk_space_low": { @@ -6780,7 +6780,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Zhoršený stav kontroly", - "none": "žiadne" + "none": "žiadne", + "temperatureAlertTitle": "{hostname}: Upozornenie na teplotu snímača", + "temperatureAlertBody": "Upozornenie na teplotu bolo zaznamenané bez úplných údajov o meraní.", + "recordedReason": "Zaznamenaný dôvod: {reason}", + "recordedDetails": "Zaznamenané podrobnosti: {details}" }, "fields": { "vmid": "VM/CT", @@ -6980,6 +6984,9 @@ "started": "Spustené" } } + }, + "temperature": { + "sampleSpan": "Vysoké vzorky pokrývajú interval {duration}." } } } diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 98d79bb1..a61454c0 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -6446,8 +6446,8 @@ "label": "Hög minnesanvändning" }, "temp_high": { - "title": "{hostname}: Hög CPU-temperatur — {value}°C", - "body": "CPU-temperaturen har nått {value}°C (tröskel: {threshold}°C).\n{details}", + "title": "{hostname}: Hög sensortemperatur — {value}°C", + "body": "Sensortemperaturen har nått {value}°C (tröskel: {threshold}°C).\n{details}", "label": "Hög temperatur" }, "disk_space_low": { @@ -6781,7 +6781,11 @@ "fallback": { "unknownTitle": "{hostname}: {event_type}", "healthCheckDegraded": "Hälsokontroll försämrad", - "none": "ingen" + "none": "ingen", + "temperatureAlertTitle": "{hostname}: Varning om sensortemperatur", + "temperatureAlertBody": "En temperaturvarning registrerades utan fullständiga mätdata.", + "recordedReason": "Registrerad orsak: {reason}", + "recordedDetails": "Registrerade detaljer: {details}" }, "fields": { "vmid": "VM/CT", @@ -6981,6 +6985,9 @@ "host": "Värd", "footer": "ProxMenux Notification Service" } + }, + "temperature": { + "sampleSpan": "De höga mätvärdena sträcker sig över {duration}." } } } diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index dcaff298..c93e6e79 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -1586,6 +1586,9 @@ class HealthMonitor: 'temperature': max_temp, 'duration': actual_duration, 'dismissable': True, 'value': max_temp, 'threshold': 80, 'details': f'High samples span {duration_str}.', + # Explicit provenance for render-time localization; + # older/manual detail strings remain verbatim. + 'temperature_detail_kind': 'high_samples_span', } ) elif len(recovery_samples) >= 3: diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index 38bd8f68..00dca079 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -1049,6 +1049,13 @@ class EmailChannel(NotificationChannel): # ── Build structured detail rows from known data fields ── detail_rows = self._build_detail_rows(data, event_type, group, html_mod) + # The temperature renderer has already validated whether both + # measurements exist and localized its sampled-span detail. Its body + # is authoritative: raw fields can otherwise print a misleading °C + # value for an incomplete manual/persisted payload and hide the body. + if event_type == 'temp_high': + detail_rows = [] + # Vzdump bodies are authoritative multi-item inventories. Keep their # lines exactly once, but retain the localized structured status row; # the remaining structured backup metadata only duplicates the report. @@ -1093,7 +1100,7 @@ class EmailChannel(NotificationChannel): # ── Reason / details block (long text, displayed separately) ── reason = data.get('reason', '') reason_html = '' - if reason and len(reason) > 80: + if reason and len(reason) > 80 and not (event_type == 'temp_high' and reason in body): reason_html = f'''

{_runtime_text('email.details', data)}

diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 373038e3..b8b20c91 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -1899,13 +1899,25 @@ def render_template(event_type: str, data: Dict[str, Any], except (OverflowError, ValueError, TypeError): return False + # Only the known sampler-generated detail is eligible: duration alone + # cannot establish provenance and manual/legacy text is not rewritten. + seconds = data.get('duration') + if (data.get('temperature_detail_kind') == 'high_samples_span' + and type(seconds) is int and 0 <= seconds <= 240): + span = (f'{seconds // 60}m {seconds % 60}s' + if seconds >= 60 else f'{seconds}s') + if data.get('details') == f'High samples span {span}.': + variables['details'] = runtime_message( + 'temperature.sampleSpan', language, duration=span) + if not all(finite_measurement(key) for key in ('value', 'threshold')): - template['title'] = runtime_message( + variables['_temperature_fallback_title'] = runtime_message( 'fallback.temperatureAlertTitle', language, hostname=variables['hostname'], ) + template['title'] = '{_temperature_fallback_title}' lines = [runtime_message('fallback.temperatureAlertBody', language)] reason = data.get('reason') - details = data.get('details') + details = variables.get('details') reason = reason.strip() if isinstance(reason, str) else '' details = details.strip() if isinstance(details, str) else '' if reason: diff --git a/AppImage/scripts/tests/test_notification_runtime_i18n.py b/AppImage/scripts/tests/test_notification_runtime_i18n.py index 3e1b2193..14cacb30 100644 --- a/AppImage/scripts/tests/test_notification_runtime_i18n.py +++ b/AppImage/scripts/tests/test_notification_runtime_i18n.py @@ -110,7 +110,12 @@ class RuntimeCatalogTests(unittest.TestCase): values.update({"hostname": "HOST-ŽILINA", "severity": "WARNING"}) with mock.patch.object(notification_templates, "_get_hostname", return_value="HOST-ŽILINA"): for event_type in notification_templates.TEMPLATES: - rendered = notification_templates.render_template(event_type, values, language="sk") + event_values = dict(values) + if event_type == "temp_high": + # Temperature is a measured numeric contract; arbitrary + # DYNAMIC_VALUE is correctly rejected by its fallback. + event_values.update(value="89", threshold="80") + rendered = notification_templates.render_template(event_type, event_values, language="sk") combined = rendered["title"] + "\n" + rendered["body"] if notification_templates.TEMPLATES[event_type].get("formatter"): continue @@ -119,7 +124,7 @@ class RuntimeCatalogTests(unittest.TestCase): + notification_templates.TEMPLATES[event_type]["body"] ): if name not in {"entity_suffix", "title_or_default"}: - self.assertIn(str(values[name]), combined, f"{event_type}:{name}") + self.assertIn(str(event_values[name]), combined, f"{event_type}:{name}") def test_lxc_update_result_and_details_render_in_slovak(self): data = { diff --git a/tests/test_notification_active_payloads.py b/tests/test_notification_active_payloads.py index db4655f3..156f3c20 100644 --- a/tests/test_notification_active_payloads.py +++ b/tests/test_notification_active_payloads.py @@ -32,9 +32,17 @@ def extracted(path, name, cls=None, namespace=None): return scope[name] -def render_fixture(language='en', overlays=None): +def render_fixture(language='en', overlays=None, missing=(), translations=None): catalogs = {lang: json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications'] - for lang in ('en', 'it')} + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')} + if language == 'synthetic': + catalogs[language] = {'templates': {'temp_high': { + 'title': 'Synthetic {value}°C', 'body': 'Synthetic {threshold}°C\n{details}'}}} + if translations: + catalogs[language].update(translations) + for key in missing: + section, leaf = key.split('.', 1) + catalogs[language].get(section, {}).pop(leaf, None) if overlays: for lang, title, body in overlays: catalogs[lang]['templates']['temp_high'].update(title=title, body=body) @@ -43,9 +51,7 @@ def render_fixture(language='en', overlays=None): for segment in key.split('.'): value = value.get(segment) if isinstance(value, dict) else None return value if isinstance(value, str) and value else None - def message(key, lang, **kw): - value = lookup(lang, key) or lookup('en', key) or '' - return value.format(**kw) if value else '' + src = ast.parse((SCRIPTS / 'notification_templates.py').read_text()) definition = next(n for n in src.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)) @@ -57,10 +63,15 @@ def render_fixture(language='en', overlays=None): assert isinstance(cpu_pattern.value.func, ast.Attribute) and cpu_pattern.value.func.attr == 'compile' assert len(cpu_pattern.value.args) == 1 scope = {'TEMPLATES': templates, '_load_runtime_catalog': lambda lang: catalogs.get(lang, {}), - '_catalog_value': lambda cat, key: lookup(language if cat is catalogs.get(language) else 'en', key), - 'runtime_message': message, '_get_hostname': lambda: 'node.example', - 'time': time, 'html': html, 're': re, 'Dict': dict, 'Any': object, 'Tuple': tuple, + '_get_hostname': lambda: 'node.example', + 'time': time, 'html': html, 're': re, 'Dict': dict, 'Any': object, + 'Optional': __import__('typing').Optional, 'Tuple': tuple, '_CPU_SUSTAINED_REASON': re.compile(ast.literal_eval(cpu_pattern.value.args[0]))} + safe_dict = next(n for n in src.body if isinstance(n, ast.ClassDef) and n.name == '_SafeFormatDict') + exec(compile(ast.fix_missing_locations(ast.Module(body=[safe_dict], type_ignores=[])), + '', 'exec'), scope) + extracted('notification_templates.py', '_catalog_value', namespace=scope) + extracted('notification_templates.py', 'runtime_message', namespace=scope) extracted('notification_templates.py', '_format_health_degraded', namespace=scope) render = extracted('notification_templates.py', 'render_template', namespace=scope) return lambda kind, data: render(kind, data, language=language) @@ -186,11 +197,12 @@ class ActivePayloadTests(unittest.TestCase): event = polled_event({'error_key': 'cpu_temperature', 'category': 'temperature', 'severity': 'WARNING', 'reason': 'legacy raw reason', 'details': {'duration': 90}}) - for lang in ('en', 'it'): + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): out = render_fixture(lang)('temp_high', event.data) self.assertNotIn('°C', out['title'] + out['body']) self.assertNotIn('CPU', out['title'] + out['body']) - self.assertIn('temperature', (out['title'] + out['body']).lower()) + expected = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['fallback']['temperatureAlertTitle'] + self.assertIn(expected.replace('{hostname}', 'node.example'), out['title']) def test_nonfinite_temperature_payload_is_not_presented_as_a_reading(self): render = render_fixture() @@ -229,6 +241,15 @@ class ActivePayloadTests(unittest.TestCase): self.assertEqual(out['body'].count('old raw text'), 1) self.assertNotIn('Recorded', render('temp_high', {})['body']) + def test_incomplete_title_keeps_raw_brace_bearing_hostname_in_every_locale(self): + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): + fallback = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['fallback']['temperatureAlertTitle'] + for hostname in ('host{value}', 'host{value.__class__}'): + with self.subTest(lang=lang, hostname=hostname): + out = render_fixture(lang)('temp_high', {'hostname': hostname}) + self.assertEqual(out['title'], fallback.replace('{hostname}', hostname)) + self.assertNotIn('°C', out['title']) + def test_incomplete_measurement_keeps_literal_braces_in_raw_context(self): out = render_fixture()('temp_high', { 'reason': 'legacy {value} at {unknown}', 'details': 'sensor {threshold}', @@ -236,17 +257,152 @@ class ActivePayloadTests(unittest.TestCase): self.assertIn('Recorded reason: legacy {value} at {unknown}', out['body']) self.assertIn('Recorded details: sensor {threshold}', out['body']) - def test_shipped_italian_stale_override_and_isolated_locale_overlay(self): + def test_shipped_locales_use_sensor_wording_and_localized_sample_span(self): event = polled_event(produced_temperature()) - shipped = render_fixture('it')('temp_high', event.data) - self.assertIn('CPU', shipped['title'] + shipped['body']) # delivery gate, not a pass - overlay = render_fixture('it', overlays=[('it', - '{hostname}: temperatura elevata del sensore — {value}°C', - 'La temperatura del sensore ha raggiunto {value}°C (soglia: {threshold}°C).\n{details}')]) - proposed = overlay('temp_high', event.data) - self.assertNotIn('CPU', proposed['title'] + proposed['body']) - self.assertIn('89', proposed['title']) - self.assertIn('80', proposed['body']) + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): + with self.subTest(lang=lang): + out = render_fixture(lang)('temp_high', event.data) + self.assertNotIn('CPU', out['title'] + out['body']) + self.assertNotIn('High samples span', out['body'] if lang != 'en' else '') + self.assertIn('89', out['title']) + self.assertIn('80', out['body']) + expected = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['temperature']['sampleSpan'].replace('{duration}', '1m 40s') + self.assertIn(expected, out['body']) + + def test_structured_span_does_not_replace_unrelated_or_unmarked_details(self): + producer = polled_event(produced_temperature()).data + for changed in ({'temperature_detail_kind': None}, {'details': 'Manual {duration} remains'}, + {'duration': 'Infinity'}, {'duration': 10**400}, {'duration': -1}, + {'duration': True}, {'duration': 1.5}): + with self.subTest(changed=changed): + data = {**producer, **changed} + out = render_fixture('it')('temp_high', data) + self.assertIn(data['details'], out['body']) + raw = render_fixture('it')('temp_high', {'value': 89, 'threshold': 80, + 'duration': 100, 'details': 'Manual {duration} remains'}) + self.assertIn('Manual {duration} remains', raw['body']) + + def test_synthetic_locale_and_missing_key_fallback_for_every_temperature_key(self): + keys = ('fallback.temperatureAlertTitle', 'fallback.temperatureAlertBody', + 'fallback.recordedReason', 'fallback.recordedDetails', 'temperature.sampleSpan') + producer = polled_event(produced_temperature()).data + synthetic = render_fixture('synthetic') + for key in keys: + with self.subTest(key=key): + data = producer if key.endswith('sampleSpan') else {'reason': 'raw R', 'details': 'raw D'} + output = synthetic('temp_high', data) + english = render_fixture('en')('temp_high', data) + if key.endswith('sampleSpan'): + self.assertIn('High samples span 1m 40s.', output['body']) + else: + self.assertEqual(output['body'] if not key.endswith('Title') else output['title'], + english['body'] if not key.endswith('Title') else english['title']) + for key in keys: + # Exercise actual fallback per missing key even after catalog migration. + with self.subTest(missing=key): + if key.endswith('sampleSpan'): + self.assertIn('High samples span 1m 40s.', + render_fixture('it', missing=[key])('temp_high', producer)['body']) + else: + data = {'reason': 'raw R', 'details': 'raw D'} + result = render_fixture('it', missing=[key])('temp_high', data) + en_value = json.loads((MESSAGES / 'en' / 'common.json').read_text())['runtime']['notifications'] + self.assertIn(en_value['fallback'][key.split('.')[1]].format( + hostname='node.example', reason='raw R', details='raw D'), + result['title'] if key.endswith('Title') else result['body']) + # A synthetic translated value proves the runtime consumer uses lookup. + translated = render_fixture('synthetic', overlays=[('synthetic', 'Translated {value}°C', + 'Translated {threshold}°C\n{details}')])('temp_high', producer) + self.assertIn('Translated', translated['title']) + custom = render_fixture('synthetic', translations={ + 'fallback': {'temperatureAlertTitle': 'CUSTOM {hostname}', + 'temperatureAlertBody': 'CUSTOM incomplete.', + 'recordedReason': 'CUSTOM R {reason}', + 'recordedDetails': 'CUSTOM D {details}'}, + 'temperature': {'sampleSpan': 'CUSTOM span {duration}.'}, + }) + complete = custom('temp_high', producer) + self.assertIn('CUSTOM span 1m 40s.', complete['body']) + incomplete = custom('temp_high', {'reason': 'raw R', 'details': 'raw D'}) + self.assertIn('CUSTOM node.example', incomplete['title']) + for fragment in ('CUSTOM incomplete.', 'CUSTOM R raw R', 'CUSTOM D raw D'): + self.assertIn(fragment, incomplete['body']) + + def test_email_contains_complete_and_incomplete_localized_temperature_body(self): + # The actual channel formats structured fields; the temperature body + # must not disappear merely because those rows exist. + import importlib.util + spec = importlib.util.spec_from_file_location('notification_channels_fixture', + SCRIPTS / 'notification_channels.py') + channels = importlib.util.module_from_spec(spec) + spec.loader.exec_module(channels) + old = sys.path[:] + sys.path.insert(0, str(SCRIPTS)) + try: + channel = object.__new__(channels.EmailChannel) + channel.subject_prefix = '[ProxMenux]' + produced = polled_event(produced_temperature()).data + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): + for name, data in (('complete', produced), ('incomplete', { + 'hostname': 'node.example', 'reason': 'legacy {token}', + 'details': 'raw {details}', 'value': 89, + })): + with self.subTest(lang=lang, name=name): + rendered = render_fixture(lang)('temp_high', data) + message = channel._format_html(rendered['title'], rendered['body'], + 'WARNING', {**data, '_notification_language': lang, + '_event_type': 'temp_high', '_group': rendered['group']}) + # HTML escaping may replace apostrophes/angle brackets. + for line in rendered['body'].splitlines(): + if ':' in line and len(line.partition(':')[0]) < 40: + label, _, value = line.partition(':') + self.assertIn(html.escape(label.strip()), message) + self.assertIn(html.escape(value.strip()), message) + else: + self.assertIn(html.escape(line), message) + if name == 'incomplete': + self.assertNotIn('89C', message) + finally: + sys.path[:] = old + + def test_long_reason_email_once_if_temperature_body_already_contains_it(self): + import importlib.util + spec = importlib.util.spec_from_file_location('notification_channels_fixture', + SCRIPTS / 'notification_channels.py') + channels = importlib.util.module_from_spec(spec) + spec.loader.exec_module(channels) + old = sys.path[:] + sys.path.insert(0, str(SCRIPTS)) + try: + channel = object.__new__(channels.EmailChannel) + channel.subject_prefix = '[ProxMenux]' + reason = 'Recorded observation ' + 'R' * 90 + ' & {value}' + escaped = html.escape(reason) + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): + render = render_fixture(lang) + for kind, fields in (('incomplete', {'details': 'raw {details}'}), + ('complete', {'value': 89, 'threshold': 80})): + with self.subTest(lang=lang, kind=kind): + data = {'hostname': 'host{value.__class__}', 'reason': reason, **fields} + rendered = render('temp_high', data) + message = channel._format_html(rendered['title'], rendered['body'], + 'WARNING', {**data, '_notification_language': lang, + '_event_type': 'temp_high', '_group': rendered['group']}) + self.assertEqual(message.count(escaped), 1) + self.assertNotIn('', message) + self.assertIn(html.escape(rendered['title']), message) + if kind == 'incomplete': + self.assertIn(reason, rendered['body']) + self.assertIn('raw {details}', rendered['body']) + else: + self.assertNotIn(reason, rendered['body']) + self.assertIn('89', message) + # An unrelated event retains its existing long-reason block. + other = channel._format_html('Other alert', reason, 'WARNING', + {'reason': reason, '_event_type': 'other', '_group': 'other'}) + self.assertEqual(other.count(escaped), 2) + finally: + sys.path[:] = old def test_collector_schema_categories_and_cpu_reason_are_localized(self): # Execute actual collector-to-formatter path against inert per-cycle inputs.