Update notification service

This commit is contained in:
MacRimi
2026-03-30 19:55:19 +02:00
parent 261b2bfb3c
commit 2fc5e2865d
7 changed files with 526 additions and 84 deletions

View File

@@ -62,6 +62,18 @@ interface RemoteStorage {
reason?: string reason?: string
} }
interface NetworkInterface {
name: string
type: string
is_up: boolean
speed: number
ip_address: string | null
exclude_health: boolean
exclude_notifications: boolean
excluded_at?: string
reason?: string
}
export function Settings() { export function Settings() {
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([]) const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true) const [loadingTools, setLoadingTools] = useState(true)
@@ -81,12 +93,18 @@ export function Settings() {
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([]) const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
const [loadingStorages, setLoadingStorages] = useState(true) const [loadingStorages, setLoadingStorages] = useState(true)
const [savingStorage, setSavingStorage] = useState<string | null>(null) const [savingStorage, setSavingStorage] = useState<string | null>(null)
// Network Interface Exclusions
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
loadProxmenuxTools() loadProxmenuxTools()
getUnitsSettings() getUnitsSettings()
loadHealthSettings() loadHealthSettings()
loadRemoteStorages() loadRemoteStorages()
loadNetworkInterfaces()
}, []) }, [])
const loadProxmenuxTools = async () => { const loadProxmenuxTools = async () => {
@@ -177,11 +195,53 @@ export function Settings() {
)) ))
} catch (err) { } catch (err) {
console.error("Failed to update storage exclusion:", err) console.error("Failed to update storage exclusion:", err)
} finally { } finally {
setSavingStorage(null) setSavingStorage(null)
}
} }
}
const loadNetworkInterfaces = async () => {
try {
const data = await fetchApi("/api/health/interfaces")
if (data.interfaces) {
setNetworkInterfaces(data.interfaces)
}
} catch (err) {
console.error("Failed to load network interfaces:", err)
} finally {
setLoadingInterfaces(false)
}
}
const handleInterfaceExclusionChange = async (interfaceName: string, interfaceType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
setSavingInterface(interfaceName)
try {
// If both are false, remove the exclusion
if (!excludeHealth && !excludeNotifications) {
await fetchApi(`/api/health/interface-exclusions/${encodeURIComponent(interfaceName)}`, {
method: "DELETE"
})
} else {
await fetchApi("/api/health/interface-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
interface_name: interfaceName,
interface_type: interfaceType,
exclude_health: excludeHealth,
exclude_notifications: excludeNotifications
})
})
}
// Reload interfaces to get updated state
await loadNetworkInterfaces()
} catch (err) {
console.error("Failed to update interface exclusion:", err)
} finally {
setSavingInterface(null)
}
}
const getSelectValue = (hours: number, key: string): string => { const getSelectValue = (hours: number, key: string): string => {
if (hours === -1) return "-1" if (hours === -1) return "-1"
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours)) const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
@@ -621,6 +681,131 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
{/* Network Interface Exclusions */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-blue-500" />
<CardTitle>Network Interface Exclusions</CardTitle>
</div>
<CardDescription>
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
Use this for interfaces that are intentionally disabled or unused.
</CardDescription>
</CardHeader>
<CardContent>
{loadingInterfaces ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
) : networkInterfaces.length === 0 ? (
<div className="text-center py-8">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">No network interfaces detected</p>
</div>
) : (
<div className="space-y-0">
{/* Header */}
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">Interface</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
</div>
{/* Interface rows */}
<div className="divide-y divide-border/50">
{networkInterfaces.map((iface) => {
const isExcluded = iface.exclude_health || iface.exclude_notifications
const isSaving = savingInterface === iface.name
const isDown = !iface.is_up
return (
<div key={iface.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${
isDown ? 'bg-red-500' : 'bg-green-500'
}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium truncate ${isExcluded ? 'text-muted-foreground' : ''}`}>
{iface.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{iface.type}
</Badge>
{isDown && !isExcluded && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
DOWN
</Badge>
)}
{isExcluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
Excluded
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
</span>
</div>
</div>
{/* Health toggle */}
<div className="flex justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!iface.exclude_health}
onCheckedChange={(checked) => {
handleInterfaceExclusionChange(
iface.name,
iface.type,
!checked,
iface.exclude_notifications
)
}}
/>
)}
</div>
{/* Notifications toggle */}
<div className="flex justify-center w-20">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={!iface.exclude_notifications}
onCheckedChange={(checked) => {
handleInterfaceExclusionChange(
iface.name,
iface.type,
iface.exclude_health,
!checked
)
}}
/>
)}
</div>
</div>
)
})}
</div>
{/* Info footer */}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
<strong>Health:</strong> When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor.
<br />
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notification Settings */} {/* Notification Settings */}
<NotificationSettings /> <NotificationSettings />

View File

@@ -456,3 +456,145 @@ def delete_storage_exclusion(storage_name):
return jsonify({'error': 'Storage not found in exclusions'}), 404 return jsonify({'error': 'Storage not found in exclusions'}), 404
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ═══════════════════════════════════════════════════════════════════════════
# NETWORK INTERFACE EXCLUSION ROUTES
# ═══════════════════════════════════════════════════════════════════════════
@health_bp.route('/api/health/interfaces', methods=['GET'])
def get_network_interfaces():
"""Get all network interfaces with their exclusion status."""
try:
import psutil
# Get all interfaces
net_if_stats = psutil.net_if_stats()
net_if_addrs = psutil.net_if_addrs()
# Get current exclusions
exclusions = {e['interface_name']: e for e in health_persistence.get_excluded_interfaces()}
result = []
for iface, stats in net_if_stats.items():
if iface == 'lo':
continue
# Determine interface type
if iface.startswith('vmbr'):
iface_type = 'bridge'
elif iface.startswith('bond'):
iface_type = 'bond'
elif iface.startswith(('vlan', 'veth')):
iface_type = 'vlan'
elif iface.startswith(('eth', 'ens', 'enp', 'eno')):
iface_type = 'physical'
else:
iface_type = 'other'
# Get IP address if any
ip_addr = None
if iface in net_if_addrs:
for addr in net_if_addrs[iface]:
if addr.family == 2: # IPv4
ip_addr = addr.address
break
exclusion = exclusions.get(iface, {})
result.append({
'name': iface,
'type': iface_type,
'is_up': stats.isup,
'speed': stats.speed,
'ip_address': ip_addr,
'exclude_health': exclusion.get('exclude_health', 0) == 1,
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
'excluded_at': exclusion.get('excluded_at'),
'reason': exclusion.get('reason')
})
# Sort: bridges first, then physical, then others
type_order = {'bridge': 0, 'bond': 1, 'physical': 2, 'vlan': 3, 'other': 4}
result.sort(key=lambda x: (type_order.get(x['type'], 5), x['name']))
return jsonify({'interfaces': result})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions', methods=['GET'])
def get_interface_exclusions():
"""Get all interface exclusions."""
try:
exclusions = health_persistence.get_excluded_interfaces()
return jsonify({'exclusions': exclusions})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions', methods=['POST'])
def save_interface_exclusion():
"""
Add or update an interface exclusion.
Request body:
{
"interface_name": "vmbr0",
"interface_type": "bridge",
"exclude_health": true,
"exclude_notifications": true,
"reason": "Intentionally disabled bridge"
}
"""
try:
data = request.get_json()
if not data or 'interface_name' not in data:
return jsonify({'error': 'interface_name is required'}), 400
interface_name = data['interface_name']
interface_type = data.get('interface_type', 'unknown')
exclude_health = data.get('exclude_health', True)
exclude_notifications = data.get('exclude_notifications', True)
reason = data.get('reason')
# Check if already excluded
existing = health_persistence.get_excluded_interfaces()
exists = any(e['interface_name'] == interface_name for e in existing)
if exists:
# Update existing
success = health_persistence.update_interface_exclusion(
interface_name, exclude_health, exclude_notifications
)
else:
# Add new
success = health_persistence.exclude_interface(
interface_name, interface_type, exclude_health, exclude_notifications, reason
)
if success:
return jsonify({
'success': True,
'message': f'Interface {interface_name} exclusion saved',
'interface_name': interface_name
})
else:
return jsonify({'error': 'Failed to save exclusion'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/interface-exclusions/<interface_name>', methods=['DELETE'])
def delete_interface_exclusion(interface_name):
"""Remove an interface from the exclusion list."""
try:
success = health_persistence.remove_interface_exclusion(interface_name)
if success:
return jsonify({
'success': True,
'message': f'Interface {interface_name} removed from exclusions'
})
else:
return jsonify({'error': 'Interface not found in exclusions'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -2335,6 +2335,7 @@ class HealthMonitor:
""" """
Optimized network check - only alerts for interfaces that are actually in use. Optimized network check - only alerts for interfaces that are actually in use.
Avoids false positives for unused physical interfaces. Avoids false positives for unused physical interfaces.
Respects interface exclusions configured by the user.
""" """
try: try:
issues = [] issues = []
@@ -2352,12 +2353,25 @@ class HealthMonitor:
except Exception: except Exception:
net_if_addrs = {} net_if_addrs = {}
# Get excluded interfaces (for health checks)
excluded_interfaces = health_persistence.get_excluded_interface_names('health')
active_interfaces = set() active_interfaces = set()
for interface, stats in net_if_stats.items(): for interface, stats in net_if_stats.items():
if interface == 'lo': if interface == 'lo':
continue continue
# Skip excluded interfaces
if interface in excluded_interfaces:
interface_details[interface] = {
'status': 'EXCLUDED',
'reason': 'Excluded from monitoring',
'is_up': stats.isup,
'dismissable': True
}
continue
# Check if important interface is down # Check if important interface is down
if not stats.isup: if not stats.isup:
should_alert = False should_alert = False
@@ -3870,7 +3884,7 @@ class HealthMonitor:
status = 'WARNING' status = 'WARNING'
reason = 'Failed to check for updates (apt-get error)' reason = 'Failed to check for updates (apt-get error)'
# ── Build checks dict ──────────────────────────────── # ── Build checks dict ────────<EFBFBD><EFBFBD>────────────────────────
age_dismissed = bool(age_result and age_result.get('type') == 'skipped_acknowledged') age_dismissed = bool(age_result and age_result.get('type') == 'skipped_acknowledged')
update_age_status = 'CRITICAL' if (last_update_days and last_update_days >= 548) else ( update_age_status = 'CRITICAL' if (last_update_days and last_update_days >= 548) else (
'INFO' if age_dismissed else ('WARNING' if (last_update_days and last_update_days >= 365) else 'OK')) 'INFO' if age_dismissed else ('WARNING' if (last_update_days and last_update_days >= 365) else 'OK'))

View File

@@ -251,6 +251,21 @@ class HealthPersistence:
''') ''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_excluded_storage ON excluded_storages(storage_name)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_excluded_storage ON excluded_storages(storage_name)')
# Table for excluded network interfaces - allows users to exclude interfaces
# (like intentionally disabled bridges) from health monitoring and notifications
cursor.execute('''
CREATE TABLE IF NOT EXISTS excluded_interfaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
interface_name TEXT UNIQUE NOT NULL,
interface_type TEXT NOT NULL,
excluded_at TEXT NOT NULL,
exclude_health INTEGER DEFAULT 1,
exclude_notifications INTEGER DEFAULT 1,
reason TEXT
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_excluded_interface ON excluded_interfaces(interface_name)')
conn.commit() conn.commit()
conn.close() conn.close()
@@ -2328,6 +2343,140 @@ class HealthPersistence:
return {row[0] for row in cursor.fetchall()} return {row[0] for row in cursor.fetchall()}
except Exception: except Exception:
return set() return set()
# ═══════════════════════════════════════════════════════════════════════════
# NETWORK INTERFACE EXCLUSION MANAGEMENT
# ═══════════════════════════════════════════════════════════════════════════
def get_excluded_interfaces(self) -> List[Dict[str, Any]]:
"""Get list of all excluded network interfaces."""
try:
with self._db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT interface_name, interface_type, excluded_at,
exclude_health, exclude_notifications, reason
FROM excluded_interfaces
''')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
print(f"[HealthPersistence] Error getting excluded interfaces: {e}")
return []
def is_interface_excluded(self, interface_name: str, check_type: str = 'health') -> bool:
"""
Check if a network interface is excluded from monitoring.
Args:
interface_name: Name of the interface (e.g., 'vmbr0', 'eth0')
check_type: 'health' or 'notifications'
Returns:
True if the interface is excluded for the given check type
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
column = 'exclude_health' if check_type == 'health' else 'exclude_notifications'
cursor.execute(f'''
SELECT 1 FROM excluded_interfaces
WHERE interface_name = ? AND {column} = 1
''', (interface_name,))
return cursor.fetchone() is not None
except Exception:
return False
def exclude_interface(self, interface_name: str, interface_type: str,
exclude_health: bool = True, exclude_notifications: bool = True,
reason: str = None) -> bool:
"""
Add a network interface to the exclusion list.
Args:
interface_name: Name of the interface (e.g., 'vmbr0')
interface_type: Type of interface ('bridge', 'physical', 'bond', 'vlan')
exclude_health: Whether to exclude from health monitoring
exclude_notifications: Whether to exclude from notifications
reason: Optional reason for exclusion
Returns:
True if successful
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO excluded_interfaces
(interface_name, interface_type, excluded_at, exclude_health, exclude_notifications, reason)
VALUES (?, ?, ?, ?, ?, ?)
''', (
interface_name,
interface_type,
datetime.now().isoformat(),
1 if exclude_health else 0,
1 if exclude_notifications else 0,
reason
))
conn.commit()
print(f"[HealthPersistence] Interface {interface_name} added to exclusions")
return True
except Exception as e:
print(f"[HealthPersistence] Error excluding interface: {e}")
return False
def update_interface_exclusion(self, interface_name: str,
exclude_health: bool, exclude_notifications: bool) -> bool:
"""Update exclusion settings for an interface."""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE excluded_interfaces
SET exclude_health = ?, exclude_notifications = ?
WHERE interface_name = ?
''', (1 if exclude_health else 0, 1 if exclude_notifications else 0, interface_name))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
print(f"[HealthPersistence] Error updating interface exclusion: {e}")
return False
def remove_interface_exclusion(self, interface_name: str) -> bool:
"""Remove an interface from the exclusion list."""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM excluded_interfaces WHERE interface_name = ?', (interface_name,))
conn.commit()
removed = cursor.rowcount > 0
if removed:
print(f"[HealthPersistence] Interface {interface_name} removed from exclusions")
return removed
except Exception as e:
print(f"[HealthPersistence] Error removing interface exclusion: {e}")
return False
def get_excluded_interface_names(self, check_type: str = 'health') -> set:
"""
Get set of interface names excluded for a specific check type.
Args:
check_type: 'health' or 'notifications'
Returns:
Set of excluded interface names
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
column = 'exclude_health' if check_type == 'health' else 'exclude_notifications'
cursor.execute(f'''
SELECT interface_name FROM excluded_interfaces
WHERE {column} = 1
''')
return {row[0] for row in cursor.fetchall()}
except Exception:
return set()
# Global instance # Global instance

View File

@@ -28,7 +28,7 @@ from pathlib import Path
# ─── Shared State for Cross-Watcher Coordination ────────────────── # ─── Shared State for Cross-Watcher Coordination ──────────────────
# ─── Startup Grace Period ─────────────────────────────────────────────────── # ─── Startup Grace Period ───────────────────────────────────────────────────<EFBFBD><EFBFBD>
# Import centralized startup grace management # Import centralized startup grace management
# This provides a single source of truth for all grace period logic # This provides a single source of truth for all grace period logic
import startup_grace import startup_grace
@@ -2610,7 +2610,7 @@ class PollingCollector:
pass pass
# ─── Proxmox Webhook Receiver ─────────────<EFBFBD><EFBFBD><EFBFBD>───────────────────── # ─── Proxmox Webhook Receiver ──────────────────────────────────
class ProxmoxHookWatcher: class ProxmoxHookWatcher:
"""Receives native Proxmox VE notifications via local webhook endpoint. """Receives native Proxmox VE notifications via local webhook endpoint.

View File

@@ -385,7 +385,7 @@ class BurstAggregator:
return etype return etype
# ─── Notification Manager ───────────────────<EFBFBD><EFBFBD>───────────────────── # ─── Notification Manager ────────────────────────────────────────
class NotificationManager: class NotificationManager:
"""Central notification orchestrator. """Central notification orchestrator.

View File

@@ -1500,83 +1500,35 @@ Rules for the tip:
# Emoji instructions injected into AI_SYSTEM_PROMPT for rich channels (Telegram, Discord, Pushover) # Emoji instructions injected into AI_SYSTEM_PROMPT for rich channels (Telegram, Discord, Pushover)
AI_EMOJI_INSTRUCTIONS = """ AI_EMOJI_INSTRUCTIONS = """
═══ EMOJI RULES ═══ ═══ EMOJI ENRICHMENT (VISUAL CLARITY) ═══
Use 1-2 emojis at START of lines where they add clarity. Combine when meaningful (💾✅ backup ok). Your goal is to maintain the original structure of the message while using emojis to add visual clarity,
Not every line needs emoji — use them to highlight, not as filler. Blank lines = completely empty. ESPECIALLY when adding new context, formatting technical data, or writing tips.
TITLE: ✅success ❌failed 💥crash 🆘critical 📦updates 🆕pve-update 🚚migration ⏹stop RULES:
🔽shutdown ⚠warning 💢split-brain 🔌disconnect 🚨auth-fail 🚷banned 📋digest 1. PRESERVE BASE STRUCTURE: Respect the original fields and layout provided in the input message.
🚀 = something STARTS (VM/CT start, backup start, server boot, task begin) 2. ENHANCE WITH ICONS: Place emojis at the START of a line to identify the data type.
Combine: 💾🚀backup-start 🖥🚀system-boot 🚀VM/CT-start 3. NEW CONTEXT: When adding journal info, SMART data, or known errors, use appropriate icons to make it readable.
4. NO SPAM: Do not put emojis in the middle or end of sentences. Use 1-3 emojis at START of lines where they add clarity. Combine when meaningful (💾✅ backup ok).
5. HIGHLIGHT ONLY: Not every line needs emoji — use them to highlight, not as filler. Blank lines = completely empty.
BODY: 🏷VM/CT name ✔ok ❌error 💽size 💾total ⏱duration 🗄storage 📊summary TITLE EMOJIS:
📦updates 🔒security 🔄proxmox ⚙kernel 🗂packages 💿disk 📝reason ✅ success ❌ failed 💥 crash 🆘 critical 📦 updates 🆕 pve-update 🚚 migration
🌐IP 👤user 🌡temp 🔥CPU 💧RAM 🎯target 🔹current 🟢new 📌item ⏹️ stop 🔽 shutdown ⚠️ warning 💢 split-brain 🔌 disconnect 🚨 auth-fail 🚷 banned 📋 digest
🚀 = something STARTS (VM/CT start, backup start, server boot, task begin)
Combine: 💾🚀 backup-start 🖥️🚀 system-boot 🚀 VM/CT-start
BODY EMOJIS:
🏷️ VM/CT name ✔️ ok ❌ error 💽 size 💾 total ⏱️ duration 🗄️ storage 📊 summary
📦 updates 🔒 security 🔄 proxmox ⚙️ kernel 🗂️ packages 💿 disk 📝 reason/log
🌐 IP 👤 user 🌡️ temp 🔥 CPU 💧 RAM 🎯 target 🔹 current 🟢 new 📌 item
BLANK LINES: Insert between logical sections (VM entries, before summary, before packages block). BLANK LINES: Insert between logical sections (VM entries, before summary, before packages block).
═══ EXAMPLES (follow these formats) ═══ NEW CONTEXT formatting (use when adding journal/SMART/enriched data):
📝 Logs indicate process crashed (exit-code 255)
BACKUP START: 💿 Device /dev/sdb: SMART Health FAILED
[TITLE] ⚠️ Recurring issue: 5 occurrences in last 24h
💾🚀 pve01: Backup started 💡 Tip: Run 'systemctl status pvedaemon' to verify"""
[BODY]
Backup job starting on storage PBS.
🏷️ VMs: web01 (100), db (101)
BACKUP COMPLETE:
[TITLE]
💾✅ pve01: Backup complete
[BODY]
Backup job finished on storage local-bak.
🏷️ VM web01 (ID: 100)
✔️ Status: ok
💽 Size: 12.3 GiB
⏱️ Duration: 00:04:21
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
📊 Total: 1 backup | 💾 12.3 GiB | ⏱️ 00:04:21
BACKUP PARTIAL FAIL:
[TITLE]
💾❌ pve01: Backup partially failed
[BODY]
Backup job finished with errors.
🏷️ VM web01 (ID: 100)
✔️ Status: ok
💽 Size: 12.3 GiB
🏷️ VM broken (ID: 102)
❌ Status: error
📊 Total: 2 backups | ❌ 1 failed
UPDATES:
[TITLE]
📦 amd: Updates available
[BODY]
📦 Total updates: 24
🔒 Security updates: 6
🔄 Proxmox updates: 0
🗂️ Important packages:
• none
VM/CT START:
[TITLE]
🚀 pve01: VM arch-linux (100) started
[BODY]
🏷️ Virtual machine arch-linux (ID: 100)
✔️ Now running
HEALTH DEGRADED:
[TITLE]
⚠️ amd: Health warning — Disk I/O
[BODY]
💿 Device: /dev/sda
⚠️ 1 sector unreadable (pending)"""
# No emoji instructions for email/plain text channels # No emoji instructions for email/plain text channels