diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx
index d25018ef..1567239e 100644
--- a/AppImage/components/notification-settings.tsx
+++ b/AppImage/components/notification-settings.tsx
@@ -18,6 +18,7 @@ import {
interface ChannelConfig {
enabled: boolean
+ rich_format?: boolean
bot_token?: string
chat_id?: string
url?: string
@@ -651,45 +652,7 @@ export function NotificationSettings() {
)}
- {/* PBS manual section (collapsible) */}
-
-
-
-
- Configure PBS notifications (manual)
-
-
-
-
- PVE backups launched from the PVE interface are covered automatically by the PVE webhook above.
-
-
- However, PBS has its own internal jobs (Verify, Prune, GC, Sync) that generate
- separate notifications. These must be configured directly on the PBS server.
-
-
-
-
- Append to /etc/proxmox-backup/notifications.cfg on the PBS host:
-
-
-{`webhook: proxmenux-webhook
-\tmethod post
-\turl http://:8008/api/notifications/webhook
-matcher: proxmenux-pbs
-\ttarget proxmenux-webhook
-\tmatch-severity warning,error`}
-
-
-
-
-
- {"Replace with the IP of this PVE node (not 127.0.0.1, unless PBS runs on the same host). Append at the end -- do not delete existing content."}
-
-
-
-
@@ -876,6 +839,27 @@ matcher: proxmenux-pbs
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
/>
+ {/* Message format */}
+
+
+
Rich messages
+
Enrich notifications with contextual emojis and icons
+
+
{ if (editMode) updateChannel("telegram", "rich_format", !config.channels.telegram?.rich_format) }}
+ >
+
+
+
{renderChannelCategories("telegram")}
{/* Send Test */}
@@ -939,6 +923,27 @@ matcher: proxmenux-pbs
+ {/* Message format */}
+
+
+
Rich messages
+
Enrich notifications with contextual emojis and icons
+
+
{ if (editMode) updateChannel("gotify", "rich_format", !config.channels.gotify?.rich_format) }}
+ >
+
+
+
{renderChannelCategories("gotify")}
{/* Send Test */}
@@ -993,6 +998,27 @@ matcher: proxmenux-pbs
+ {/* Message format */}
+
+
+
Rich messages
+
Enrich notifications with contextual emojis and icons
+
+
{ if (editMode) updateChannel("discord", "rich_format", !config.channels.discord?.rich_format) }}
+ >
+
+
+
{renderChannelCategories("discord")}
{/* Send Test */}
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py
index d9a91760..d9de1a4b 100644
--- a/AppImage/scripts/notification_manager.py
+++ b/AppImage/scripts/notification_manager.py
@@ -35,7 +35,7 @@ if BASE_DIR not in sys.path:
from notification_channels import create_channel, CHANNEL_TYPES
from notification_templates import (
- render_template, format_with_ai, TEMPLATES,
+ render_template, format_with_ai, enrich_with_emojis, TEMPLATES,
EVENT_GROUPS, get_event_types_by_group, get_default_enabled_events
)
from notification_events import (
@@ -667,9 +667,17 @@ class NotificationManager:
continue # Channel has this specific event disabled
try:
- result = channel.send(title, body, severity, data)
+ # Per-channel emoji enrichment (opt-in via {channel}.rich_format)
+ ch_title, ch_body = title, body
+ rich_key = f'{ch_name}.rich_format'
+ if self._config.get(rich_key, 'false') == 'true':
+ ch_title, ch_body = enrich_with_emojis(
+ event_type, title, body, data
+ )
+
+ result = channel.send(ch_title, ch_body, severity, data)
self._record_history(
- event_type, ch_name, title, body, severity,
+ event_type, ch_name, ch_title, ch_body, severity,
result.get('success', False),
result.get('error', ''),
source
@@ -1146,6 +1154,7 @@ class NotificationManager:
for ch_type, info in CHANNEL_TYPES.items():
ch_cfg: Dict[str, Any] = {
'enabled': self._config.get(f'{ch_type}.enabled', 'false') == 'true',
+ 'rich_format': self._config.get(f'{ch_type}.rich_format', 'false') == 'true',
}
for config_key in info['config_keys']:
ch_cfg[config_key] = self._config.get(f'{ch_type}.{config_key}', '')
diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py
index 0c97faca..7f4e676b 100644
--- a/AppImage/scripts/notification_templates.py
+++ b/AppImage/scripts/notification_templates.py
@@ -1019,6 +1019,196 @@ def get_default_enabled_events() -> Dict[str, bool]:
}
+# ─── Emoji Enrichment (per-channel opt-in) ──────────────────────
+
+# Category-level header icons
+CATEGORY_EMOJI = {
+ 'vm_ct': '\U0001F5A5\uFE0F', # desktop computer
+ 'backup': '\U0001F4BE', # floppy disk (backup)
+ 'resources': '\U0001F4CA', # bar chart
+ 'storage': '\U0001F4BD', # minidisc / hard disk
+ 'network': '\U0001F310', # globe with meridians
+ 'security': '\U0001F6E1\uFE0F', # shield
+ 'cluster': '\U0001F517', # chain link
+ 'services': '\u2699\uFE0F', # gear
+ 'health': '\U0001FA7A', # stethoscope
+ 'updates': '\U0001F504', # counterclockwise arrows (update)
+ 'other': '\U0001F4E8', # incoming envelope
+}
+
+# Event-specific title icons (override category default when present)
+EVENT_EMOJI = {
+ # VM / CT
+ 'vm_start': '\u25B6\uFE0F', # play button
+ 'vm_stop': '\u23F9\uFE0F', # stop button
+ 'vm_shutdown': '\u23CF\uFE0F', # eject
+ 'vm_fail': '\U0001F4A5', # collision (crash)
+ 'vm_restart': '\U0001F504', # cycle
+ 'ct_start': '\u25B6\uFE0F',
+ 'ct_stop': '\u23F9\uFE0F',
+ 'ct_shutdown': '\u23CF\uFE0F',
+ 'ct_restart': '\U0001F504',
+ 'ct_fail': '\U0001F4A5',
+ 'migration_start': '\U0001F69A', # moving truck
+ 'migration_complete': '\u2705', # check mark
+ 'migration_fail': '\u274C', # cross mark
+ 'replication_fail': '\u274C',
+ 'replication_complete': '\u2705',
+ # Backups
+ 'backup_start': '\U0001F4E6', # package
+ 'backup_complete': '\u2705',
+ 'backup_fail': '\u274C',
+ 'snapshot_complete': '\U0001F4F8', # camera with flash
+ 'snapshot_fail': '\u274C',
+ # Resources
+ 'cpu_high': '\U0001F525', # fire
+ 'ram_high': '\U0001F4A7', # droplet
+ 'temp_high': '\U0001F321\uFE0F', # thermometer
+ 'load_high': '\u26A0\uFE0F', # warning
+ # Storage
+ 'disk_space_low': '\U0001F4C9', # chart decreasing
+ 'disk_io_error': '\U0001F4A5',
+ 'storage_unavailable': '\U0001F6AB', # prohibited
+ # Network
+ 'network_down': '\U0001F50C', # electric plug
+ 'network_latency': '\U0001F422', # turtle (slow)
+ # Security
+ 'auth_fail': '\U0001F6A8', # police light
+ 'ip_block': '\U0001F6B7', # no pedestrians (banned)
+ 'firewall_issue': '\U0001F525',
+ 'user_permission_change': '\U0001F511', # key
+ # Cluster
+ 'split_brain': '\U0001F4A2', # anger symbol
+ 'node_disconnect': '\U0001F50C',
+ 'node_reconnect': '\u2705',
+ # Services
+ 'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
+ 'system_reboot': '\U0001F504',
+ 'system_problem': '\u26A0\uFE0F',
+ 'service_fail': '\u274C',
+ 'oom_kill': '\U0001F4A3', # bomb
+ # Health
+ 'new_error': '\U0001F198', # SOS
+ 'error_resolved': '\u2705',
+ 'error_escalated': '\U0001F53A', # red triangle up
+ 'health_degraded': '\u26A0\uFE0F',
+ 'health_persistent': '\U0001F4CB', # clipboard
+ # Updates
+ 'update_summary': '\U0001F4E6',
+ 'pve_update': '\U0001F195', # NEW
+ 'update_complete': '\u2705',
+}
+
+# Decorative field-level icons for body text enrichment
+FIELD_EMOJI = {
+ 'hostname': '\U0001F4BB', # laptop
+ 'vmid': '\U0001F194', # ID button
+ 'vmname': '\U0001F3F7\uFE0F', # label
+ 'device': '\U0001F4BD', # disk
+ 'mount': '\U0001F4C2', # open folder
+ 'source_ip': '\U0001F310', # globe
+ 'username': '\U0001F464', # bust in silhouette
+ 'service_name': '\u2699\uFE0F', # gear
+ 'node_name': '\U0001F5A5\uFE0F', # computer
+ 'target_node': '\U0001F3AF', # direct hit (target)
+ 'category': '\U0001F4CC', # pushpin
+ 'severity': '\U0001F6A6', # traffic light
+ 'duration': '\u23F1\uFE0F', # stopwatch
+ 'timestamp': '\U0001F552', # clock three
+ 'size': '\U0001F4CF', # ruler
+ 'reason': '\U0001F4DD', # memo
+ 'value': '\U0001F4CA', # chart
+ 'threshold': '\U0001F6A7', # construction
+ 'jail': '\U0001F512', # lock
+ 'failures': '\U0001F522', # input numbers
+ 'quorum': '\U0001F465', # busts in silhouette
+ 'total_count': '\U0001F4E6', # package
+ 'security_count': '\U0001F6E1\uFE0F', # shield
+ 'pve_count': '\U0001F4E6',
+ 'kernel_count': '\u2699\uFE0F',
+ 'important_list': '\U0001F4CB', # clipboard
+}
+
+
+def enrich_with_emojis(event_type: str, title: str, body: str,
+ data: Dict[str, Any]) -> tuple:
+ """Replace the plain title/body with emoji-enriched versions.
+
+ Returns (enriched_title, enriched_body).
+ The function is idempotent: if the title already starts with an emoji,
+ it is returned unchanged.
+ """
+ # Pick the best title icon: event-specific > category > severity circle
+ template = TEMPLATES.get(event_type, {})
+ group = template.get('group', 'other')
+ severity = data.get('severity', 'INFO')
+
+ icon = EVENT_EMOJI.get(event_type) or CATEGORY_EMOJI.get(group) or SEVERITY_ICONS.get(severity, '')
+
+ # Build enriched title: replace severity circle with event-specific icon
+ # Current format: "hostname: Something" -> "ICON hostname: Something"
+ # If title already starts with an emoji (from a previous pass), skip.
+ enriched_title = title
+ if icon and not any(title.startswith(e) for e in SEVERITY_ICONS.values()):
+ enriched_title = f'{icon} {title}'
+ elif icon:
+ # Replace existing severity circle with richer icon
+ for sev_icon in SEVERITY_ICONS.values():
+ if title.startswith(sev_icon):
+ enriched_title = title.replace(sev_icon, icon, 1)
+ break
+
+ # Build enriched body: prepend field emojis to recognizable lines
+ lines = body.split('\n')
+ enriched_lines = []
+
+ for line in lines:
+ stripped = line.strip()
+ if not stripped:
+ enriched_lines.append(line)
+ continue
+
+ # Try to match "FieldName: value" patterns
+ enriched = False
+ for field_key, field_icon in FIELD_EMOJI.items():
+ # Match common label patterns: "Device:", "Duration:", "Size:", etc.
+ label_variants = [
+ field_key.replace('_', ' ').title(), # "Source Ip" -> not great
+ field_key.replace('_', ' '), # "source ip"
+ ]
+ # Also add specific known labels
+ _LABEL_MAP = {
+ 'vmid': 'VM/CT', 'vmname': 'Name', 'source_ip': 'Source IP',
+ 'service_name': 'Service', 'node_name': 'Node',
+ 'target_node': 'Target', 'total_count': 'Total updates',
+ 'security_count': 'Security updates', 'pve_count': 'Proxmox-related updates',
+ 'kernel_count': 'Kernel updates', 'important_list': 'Important packages',
+ 'duration': 'Duration', 'severity': 'Previous severity',
+ 'original_severity': 'Previous severity',
+ }
+ if field_key in _LABEL_MAP:
+ label_variants.append(_LABEL_MAP[field_key])
+
+ for label in label_variants:
+ if stripped.lower().startswith(label.lower() + ':'):
+ enriched_lines.append(f'{field_icon} {stripped}')
+ enriched = True
+ break
+ elif stripped.lower().startswith(label.lower() + ' '):
+ enriched_lines.append(f'{field_icon} {stripped}')
+ enriched = True
+ break
+ if enriched:
+ break
+
+ if not enriched:
+ enriched_lines.append(line)
+
+ enriched_body = '\n'.join(enriched_lines)
+
+ return enriched_title, enriched_body
+
+
# ─── AI Enhancement (Optional) ───────────────────────────────────
class AIEnhancer: