Update notification service

This commit is contained in:
MacRimi
2026-03-03 19:19:56 +01:00
parent 2a4d056b59
commit 9a11c41424
3 changed files with 266 additions and 41 deletions

View File

@@ -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() {
)}
</div>
{/* PBS manual section (collapsible) */}
<details className="group">
<summary className="text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors flex items-center gap-1.5">
<ChevronDown className="h-3 w-3 group-open:rotate-180 transition-transform" />
<Webhook className="h-3 w-3" />
Configure PBS notifications (manual)
</summary>
<div className="mt-2 p-3 bg-muted/30 rounded-md border border-border space-y-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground leading-relaxed">
PVE backups launched from the PVE interface are covered automatically by the PVE webhook above.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
However, PBS has its own internal jobs (Verify, Prune, GC, Sync) that generate
separate notifications. These must be configured directly on the PBS server.
</p>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-medium text-muted-foreground">
Append to /etc/proxmox-backup/notifications.cfg on the PBS host:
</p>
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`webhook: proxmenux-webhook
\tmethod post
\turl http://<PVE_IP>:8008/api/notifications/webhook
matcher: proxmenux-pbs
\ttarget proxmenux-webhook
\tmatch-severity warning,error`}
</pre>
</div>
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[10px] text-blue-400/90 leading-relaxed">
{"Replace <PVE_IP> 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."}
</p>
</div>
</div>
</details>
</div>
</CardContent>
</Card>
@@ -876,6 +839,27 @@ matcher: proxmenux-pbs
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
/>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.telegram?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.telegram?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("telegram", "rich_format", !config.channels.telegram?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.telegram?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("telegram")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
@@ -939,6 +923,27 @@ matcher: proxmenux-pbs
</button>
</div>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.gotify?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.gotify?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("gotify", "rich_format", !config.channels.gotify?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.gotify?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("gotify")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
@@ -993,6 +998,27 @@ matcher: proxmenux-pbs
</button>
</div>
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium">Rich messages</Label>
<p className="text-[10px] text-muted-foreground">Enrich notifications with contextual emojis and icons</p>
</div>
<button
type="button"
role="switch"
aria-checked={config.channels.discord?.rich_format || false}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${config.channels.discord?.rich_format ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel("discord", "rich_format", !config.channels.discord?.rich_format) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.channels.discord?.rich_format ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{renderChannelCategories("discord")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">

View File

@@ -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}', '')

View File

@@ -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: