diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index ed007dd1..7fe92a80 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -19,6 +19,7 @@ PYTHON_FILES = ( "tests/test_audit_catalog.py", "tests/storage/test_nvme_status_message.py", "tests/test_fastfetch_config_generation.py", + "tests/test_notification_active_payloads.py", ) NODE_FILES = ( "tests/lxc_updates/test_docker_delegated_ui.cjs", diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 4af6fc4a..2121692e 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -122,6 +122,15 @@ class CommandDescriptionsTests(unittest.TestCase): "temperatureIncomplete", "temperatureUnavailableCount", ): temporary["storage"].setdefault(key, catalog("en")["storage"][key]) + # The temperature alert fallback is new English-only runtime + # text until locale migration. Seed only its four new keys + # in this disposable steady-state catalog; shipped locales + # remain unchanged and runtime fallback is tested separately. + source_fallback = catalog("en")["runtime"]["notifications"]["fallback"] + fallback = temporary["runtime"]["notifications"]["fallback"] + for key in ("temperatureAlertTitle", "temperatureAlertBody", + "recordedReason", "recordedDetails"): + fallback.setdefault(key, source_fallback[key]) path.write_text(json.dumps(temporary, ensure_ascii=False)) # Model steady state after the bot fills these intentional new # messages; keep repository locales and all other leaves intact. diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index a6f89f92..a199f439 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -54,7 +54,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Run seven qualified Python files + - name: Run eight qualified Python files env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python 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 8663ef8c..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 CPU temperature — {value}°C","body":"CPU 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"},"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 4e2431db..c93e6e79 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -1503,10 +1503,10 @@ class HealthMonitor: def _check_cpu_temperature(self) -> Optional[Dict[str, Any]]: """ - Check CPU temperature with temporal logic: + Check the maximum reported sensor temperature with temporal logic: - WARNING if temp >80°C sustained for >3 minutes - Auto-clears if temp ≤80°C for 30 seconds - - No dismiss button (non-dismissable) + - Recorded errors retain their current dismissable flag """ cache_key = 'cpu_temp' current_time = time.time() @@ -1574,15 +1574,22 @@ class HealthMonitor: duration_str = f'{actual_minutes}m {actual_seconds}s' if actual_minutes > 0 else f'{actual_seconds}s' status = 'WARNING' - reason = f'CPU temperature {max_temp}°C >80°C sustained for {duration_str}' + reason = f'Sensor temperature {max_temp}°C >80°C; high samples span {duration_str}' - # Record non-dismissable error + # Record the existing dismissable error contract health_persistence.record_error( error_key='cpu_temperature', category='temperature', severity='WARNING', reason=reason, - details={'temperature': max_temp, 'duration': actual_duration, 'dismissable': True} + details={ + '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: # Temperature has been ≤80°C for 30 seconds - clear the error @@ -1595,7 +1602,7 @@ class HealthMonitor: if health_persistence.is_error_active('cpu_temperature', category='temperature'): # Keep the warning active status = 'WARNING' - reason = f'CPU temperature {max_temp}°C still elevated' + reason = f'Sensor temperature {max_temp}°C still elevated' else: # No active warning yet status = 'OK' diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index 38bd8f68..60e0afd4 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -1038,6 +1038,11 @@ class EmailChannel(NotificationChannel): # Determine group for section header event_type = data.get('_event_type', '') group = data.get('_group', 'other') + # Keep unbroken recorded text inside the temperature email's table. + # Both properties are inline for mail clients; other events retain + # their original markup and layout. + temp_cell_wrap = 'word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;' if event_type == 'temp_high' else '' + temp_table_layout = 'table-layout:fixed;' if event_type == 'temp_high' else '' section_label = _runtime_text(f'email.groups.{group}', data) report_label = _runtime_text('email.report', data, group=section_label) host_label = _runtime_text('email.host', data) @@ -1049,6 +1054,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. @@ -1082,22 +1094,22 @@ class EmailChannel(NotificationChannel): if label: rows_html += f''' {label} - {value} + {value} ''' else: # Full-width row (no label, just description text) rows_html += f''' - {value} + {value} ''' # ── 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)}

-

{html_mod.escape(reason)}

+

{html_mod.escape(reason)}

''' # ── Clean subject for display (remove prefix if present) ── @@ -1146,7 +1158,7 @@ class EmailChannel(NotificationChannel): - +
{rows_html}
diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 9ce16074..b8b20c91 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -680,7 +680,7 @@ def _format_health_degraded(data: Dict[str, Any], return str(data.get('title') or ''), str(data.get('reason') or '') def category_label(item: Dict[str, Any]) -> str: - category = str(item.get('key') or '') + category = str(item.get('key') or item.get('cat_key') or '') return ( runtime_message(f'healthDegraded.categories.{category}', language) or str(item.get('category') or category) @@ -695,7 +695,7 @@ def _format_health_degraded(data: Dict[str, Any], def localized_reason(item: Dict[str, Any]) -> str: reason = str(item.get('reason') or '') - if str(item.get('key') or '') == 'cpu': + if str(item.get('key') or item.get('cat_key') or '') == 'cpu': match = _CPU_SUSTAINED_REASON.fullmatch(reason) if match: return runtime_message( @@ -1081,8 +1081,8 @@ TEMPLATES = { 'default_enabled': True, }, 'temp_high': { - 'title': '{hostname}: High CPU temperature — {value}°C', - 'body': 'CPU temperature has reached {value}°C (threshold: {threshold}°C).\n{details}', + 'title': '{hostname}: High sensor temperature — {value}°C', + 'body': 'Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}', 'label': 'High temperature', 'group': 'resources', 'default_enabled': True, @@ -1885,6 +1885,50 @@ def render_template(event_type: str, data: Dict[str, Any], } variables.update(data) + # Old persisted errors and manual events may lack a complete reading. + # Accept plain numeric strings, but never interpret booleans or objects as + # measurements, nor display an infinite value or an overflowing integer. + if event_type == 'temp_high': + import math as _math + def finite_measurement(key): + raw = data.get(key) + if type(raw) not in (int, float, str): + return False + try: + return _math.isfinite(float(raw)) + 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')): + 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 = variables.get('details') + reason = reason.strip() if isinstance(reason, str) else '' + details = details.strip() if isinstance(details, str) else '' + if reason: + lines.append(runtime_message('fallback.recordedReason', language, reason=reason)) + if details and details != reason: + lines.append(runtime_message('fallback.recordedDetails', language, details=details)) + # Format the localized labels once; do not interpret braces in + # recorded raw text as template placeholders a second time. + variables['_temperature_fallback_body'] = '\n'.join(lines) + template['body'] = '{_temperature_fallback_body}' + if event_type == 'lxc_update_applied' and isinstance(data.get('lxc_update'), dict): status = str(data['lxc_update'].get('status') or '') localized_status = runtime_message(f'lxcUpdate.status.{status}', language) 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/CONTRIBUTING.md b/CONTRIBUTING.md index a31006d4..a404974a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -863,7 +863,8 @@ publication workflow and has read-only repository permissions. #### Qualified offline `tests/` CI lanes `.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed -seven Python files and ten Node files in `.github/scripts/run_offline_qualified.py`. +eight Python files (including the inert active-notification payload fixture) and ten +Node files in `.github/scripts/run_offline_qualified.py`. Run the same allowlist locally from the root of a clean, disposable checkout with no `AppImage/node_modules`. Do not run this over a normal Monitor install: the commands refuse an existing directory, file, or symlink (including a diff --git a/tests/test_notification_active_payloads.py b/tests/test_notification_active_payloads.py new file mode 100644 index 00000000..156f3c20 --- /dev/null +++ b/tests/test_notification_active_payloads.py @@ -0,0 +1,482 @@ +"""Inert actual-function tests for active notification payloads; no app imports.""" +import ast +import copy +import html +import json +import math +import re +import sys +import time +import types +import unittest +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / 'AppImage/scripts' +MESSAGES = ROOT / 'AppImage/messages' + + +def extracted(path, name, cls=None, namespace=None): + tree = ast.parse((SCRIPTS / path).read_text()) + nodes = tree.body + if cls: + nodes = next(n for n in nodes if isinstance(n, ast.ClassDef) and n.name == cls).body + node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name) + # Never execute module code or decorators; no operational module imports. + node = copy.deepcopy(node) + node.decorator_list = [] + scope = namespace if namespace is not None else {} + exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), + '', 'exec'), scope) + return scope[name] + + +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', '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) + def lookup(lang, key): + value = catalogs.get(lang, {}) + 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 + + 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)) + templates = ast.literal_eval(definition.value) + cpu_pattern = next(n for n in src.body if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == '_CPU_SUSTAINED_REASON' + for t in n.targets)) + assert isinstance(cpu_pattern.value, ast.Call) + 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, {}), + '_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) + + +def temperature_case(history, reading=89., active=False): + class Persistence: + emitted = [] + resolved = [] + def record_error(self, **kw): self.emitted.append(kw) + def is_error_active(self, *a, **kw): return active + def resolve_error(self, *a, **kw): self.resolved.append((a, kw)) + db = Persistence() + env = {'time': types.SimpleNamespace(time=lambda: 100000), + 'subprocess': types.SimpleNamespace(run=lambda *a, **kw: types.SimpleNamespace( + returncode=0, stdout=f'temp1_input: {reading:.3f}\ntemp2_input: 45.000\n')), + 'health_persistence': db, 'Optional': __import__('typing').Optional, + 'Dict': dict, 'Any': object} + check = extracted('health_monitor.py', '_check_cpu_temperature', 'HealthMonitor', env) + monitor = types.SimpleNamespace(last_check_times={}, cached_results={}, state_history=defaultdict(list)) + monitor.state_history['cpu_temp_history'] = list(history) + result = check(monitor) + return result, db + + +def produced_temperature(): + result, db = temperature_case([ + {'value': 89., 'time': 99900 + n * 5} for n in range(17)]) + assert result['status'] == 'WARNING' and len(db.emitted) == 1 + return db.emitted[0] + + +def polled_event(entry): + raw_row = {'error_key': entry['error_key'], 'category': entry['category'], + 'severity': entry['severity'], 'reason': entry['reason'], + 'details': json.dumps(entry['details']), 'last_seen': '', + 'first_seen': '', 'acknowledged': 0} + class Cursor: + def execute(self, *a): pass + def fetchall(self): return [raw_row] + class Connection: + def __enter__(self): return self + def __exit__(self, *a): return False + def cursor(self): return Cursor() + typing = __import__('typing') + get_active = extracted('health_persistence.py', 'get_active_errors', 'HealthPersistence', + {'json': json, 'Optional': typing.Optional, 'List': typing.List, + 'Dict': typing.Dict, 'Any': typing.Any}) + persisted = get_active(types.SimpleNamespace(_db_connection=lambda **kw: Connection())) + assert len(persisted) == 1 and persisted[0]['details'] == entry['details'] + class Persistence: + def get_active_errors(self): return persisted + class Event: + def __init__(self, event_type, severity, data, **kw): + self.event_type, self.severity, self.data = event_type, severity, data + q = types.SimpleNamespace(items=[], put=lambda event: q.items.append(event)) + tree = ast.parse((SCRIPTS / 'notification_events.py').read_text()) + cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'PollingCollector') + maps = {target.id: ast.literal_eval(n.value) for n in cls.body if isinstance(n, ast.Assign) + for target in n.targets if isinstance(target, ast.Name) and target.id in + ('_CATEGORY_TO_EVENT_TYPE', '_ENTITY_MAP', '_CATEGORY_COOLDOWNS')} + collector = types.SimpleNamespace(_CATEGORY_TO_EVENT_TYPE=maps['_CATEGORY_TO_EVENT_TYPE'], + _ENTITY_MAP=maps['_ENTITY_MAP'], _CATEGORY_COOLDOWNS=maps['_CATEGORY_COOLDOWNS'], + DIGEST_INTERVAL=86400, SAME_ERROR_COOLDOWN=86400, _queue=q, _hostname='node.example', + _first_poll_done=True, _known_errors={}, _last_notified={}, _notified_severity={}, + _is_usb_cache={}, _get_cooldown_from_db=lambda _: None, + _persist_last_notified=lambda *a: None, _save_known_errors_meta=lambda: None) + old = sys.modules.get('health_persistence') + sys.modules['health_persistence'] = types.SimpleNamespace(health_persistence=Persistence()) + try: + poll = extracted('notification_events.py', '_check_persistent_health', 'PollingCollector', + {'time': types.SimpleNamespace(time=lambda: 100000), 'json': json, + 'startup_grace': types.SimpleNamespace(should_suppress_category=lambda _: False), + 'NotificationEvent': Event, 'Dict': dict}) + poll(collector) + finally: + if old is None: sys.modules.pop('health_persistence', None) + else: sys.modules['health_persistence'] = old + assert len(q.items) == 1 + return q.items[0] + + +class ActivePayloadTests(unittest.TestCase): + def test_temperature_sampling_boundaries_and_recovery_policy(self): + # These characterize the existing threshold/guard, not new policy. + highs = [{'value': 89., 'time': 99900 + n * 5} for n in range(17)] + below, db = temperature_case(highs[:-1]) # current sample makes 17 + self.assertEqual(below['status'], 'OK') + self.assertEqual((db.emitted, db.resolved), ([], [])) + warning, db = temperature_case(highs) # current sample makes 18 + self.assertEqual(warning['status'], 'WARNING') + self.assertEqual(len(db.emitted), 1) + self.assertEqual(db.emitted[0]['details']['duration'], 100) + self.assertEqual((db.emitted[0]['category'], db.emitted[0]['severity'], + db.emitted[0]['details']['dismissable']), ('temperature', 'WARNING', True)) + low = [{'value': 79., 'time': t} for t in (99980, 99990)] + pending, db = temperature_case(low[:1], reading=79., active=True) + self.assertEqual(pending['status'], 'WARNING') + self.assertEqual((db.emitted, db.resolved), ([], [])) + recovered, db = temperature_case(low, reading=79., active=True) + self.assertEqual(recovered['status'], 'OK') + self.assertEqual(db.emitted, []) + self.assertEqual(db.resolved[0][0][0], 'cpu_temperature') + + def test_temperature_record_queue_and_english_render_use_measured_sensor(self): + record = produced_temperature() + self.assertIn('sensor', record['reason'].lower()) + self.assertEqual(record['details']['temperature'], 89.) + self.assertTrue(record['details']['dismissable']) + event = polled_event(record) + self.assertEqual((event.event_type, event.severity), ('temp_high', 'WARNING')) + self.assertEqual(event.data['value'], 89.) + self.assertEqual(event.data['threshold'], 80) + self.assertEqual(event.data['duration'], record['details']['duration']) + self.assertIn('High samples span', event.data['details']) + rendered = render_fixture()('temp_high', event.data) + self.assertIn('sensor temperature', rendered['title'].lower()) + self.assertIn('89', rendered['title']) + self.assertIn('80', rendered['body']) + self.assertNotIn('CPU', rendered['title'] + rendered['body']) + + def test_missing_temperature_measurement_never_renders_blank_celsius_claim(self): + event = polled_event({'error_key': 'cpu_temperature', 'category': 'temperature', + 'severity': 'WARNING', 'reason': 'legacy raw reason', + 'details': {'duration': 90}}) + 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']) + 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() + for value, threshold in ((math.nan, 80), (math.inf, 80), (89, math.inf), + (True, 80), (10**400, 80), (89, 10**400)): + with self.subTest(value=value, threshold=threshold): + out = render('temp_high', {'value': value, 'threshold': threshold}) + self.assertNotIn('°C', out['title'] + out['body']) + + def test_numeric_strings_from_manual_payloads_retain_finite_readings(self): + out = render_fixture()('temp_high', {'value': '89.0', 'threshold': '80'}) + self.assertIn('89.0°C', out['title']) + self.assertIn('80°C', out['body']) + for bad in ('', 'NaN', 'Infinity', '1e999', 'eighty', ' ', object()): + with self.subTest(value=repr(bad)): + out = render_fixture()('temp_high', {'value': bad, 'threshold': '80'}) + self.assertNotIn('°C', out['title'] + out['body']) + + def test_incomplete_measurement_preserves_recorded_context_once(self): + render = render_fixture() + for data, fragments in ( + ({'reason': 'CPU temperature 89°C >80°C sustained for 2m'}, + ('Recorded reason: CPU temperature 89°C >80°C sustained for 2m',)), + ({'value': 89, 'details': 'High samples span 2m.'}, + ('Recorded details: High samples span 2m.',)), + ({'value': 89, 'reason': 'old raw text', 'details': 'old raw text'}, + ('Recorded reason: old raw text',)), + ): + with self.subTest(data=data): + out = render('temp_high', data) + self.assertNotIn('°C', out['title'] + out['body'].split('\n')[0]) + self.assertIn('without a complete measurement', out['body']) + for fragment in fragments: + self.assertIn(fragment, out['body']) + if data.get('reason') == data.get('details'): + 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}', + }) + self.assertIn('Recorded reason: legacy {value} at {unknown}', out['body']) + self.assertIn('Recorded details: sensor {threshold}', out['body']) + + def test_shipped_locales_use_sensor_wording_and_localized_sample_span(self): + event = polled_event(produced_temperature()) + 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. + class Stop(Exception): pass + class Clock: + count = 0 + def time(self): return 1. + def sleep(self, _): + self.count += 1 + if self.count > 1: raise Stop + class Manager: + _enabled = True + _config = {} + events = [] + def is_event_enabled(self, *_): return True + def emit_event(self, **kw): self.events.append(kw) + manager = Manager() + scope = {'notification_manager': manager, 'time': Clock(), + 'startup_grace': types.SimpleNamespace(should_suppress_category=lambda *_: False), + '_capture_health_journal_context': lambda *_: '', + 'resolve_notification_hostname': lambda name, *_: name, + 'health_monitor': types.SimpleNamespace(get_detailed_status=lambda: { + 'hostname': 'node.example', 'details': {'cpu': {'status': 'WARNING', + 'reason': 'CPU >85% sustained for 300s'}}, 'overall': 'WARNING', 'summary': 'x'}, + cached_results={}, last_check_times={})} + # Remove only infrastructure imports from the extracted function. + tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) + fn = copy.deepcopy(next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_health_collector_loop')) + for node in ast.walk(fn): + for field, value in ast.iter_fields(node): + if isinstance(value, list): + setattr(node, field, [child for child in value if not ( + isinstance(child, ast.ImportFrom) and child.module == 'health_monitor' + or isinstance(child, ast.Import) and any(a.name == 'startup_grace' for a in child.names))]) + exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), '', 'exec'), scope) + with self.assertRaises(Stop): scope['_health_collector_loop']() + self.assertEqual(len(manager.events), 1) + payload = manager.events[0]['data'] + item = payload['health_degraded']['categories'][0] + self.assertEqual(item['cat_key'], 'cpu') + out = render_fixture('it')('health_degraded', {**payload, 'severity': 'WARNING'}) + self.assertIn('Utilizzo e temperatura della CPU', out['title']) + self.assertEqual(out['body'], 'La CPU è superiore al 85% per 300s.') + + def test_legacy_key_and_unknown_reason_remain_honest(self): + render = render_fixture('it') + def payload(item): + return {'hostname': 'node.example', 'health_degraded': {'categories': [item]}} + legacy = render('health_degraded', payload({'key': 'cpu', 'status': 'WARNING', + 'reason': 'CPU >85% sustained for 300s'})) + self.assertIn('Utilizzo e temperatura della CPU', legacy['title']) + self.assertIn('La CPU', legacy['body']) + raw = render('health_degraded', payload({'cat_key': 'services', 'category': 'PVE Services', + 'status': 'CRITICAL', 'reason': 'Services inactive: pvedaemon', 'entity': 'pvedaemon'})) + self.assertIn('Servizi PVE', raw['title']) + self.assertIn('pvedaemon', raw['title']) + self.assertEqual(raw['body'], 'Services inactive: pvedaemon') + unknown = render('health_degraded', payload({'cat_key': 'other', 'category': 'External category', + 'reason': 'raw unknown', 'status': 'WARNING'})) + self.assertIn('External category', unknown['title']) + self.assertEqual(unknown['body'], 'raw unknown') + + conflicting = render('health_degraded', payload({'key': 'memory', 'cat_key': 'cpu', + 'status': 'WARNING', 'reason': 'CPU >85% sustained for 300s'})) + self.assertIn('Memoria e scambio', conflicting['title']) + self.assertEqual(conflicting['body'], 'CPU >85% sustained for 300s') + multi = render('health_degraded', {'hostname': 'node.example', + 'health_degraded': {'categories': [ + {'cat_key': 'cpu', 'status': 'WARNING', 'reason': 'CPU >85% sustained for 300s'}, + {'cat_key': 'services', 'status': 'CRITICAL', 'reason': 'Services inactive: pvedaemon'}]}}) + self.assertIn('Utilizzo e temperatura della CPU', multi['body']) + self.assertIn('Servizi PVE', multi['body']) + self.assertIn('Services inactive: pvedaemon', multi['body']) + + +if __name__ == '__main__': + unittest.main()