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
}
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() {
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true)
@@ -81,12 +93,18 @@ export function Settings() {
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
const [loadingStorages, setLoadingStorages] = useState(true)
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(() => {
loadProxmenuxTools()
getUnitsSettings()
loadHealthSettings()
loadRemoteStorages()
loadProxmenuxTools()
getUnitsSettings()
loadHealthSettings()
loadRemoteStorages()
loadNetworkInterfaces()
}, [])
const loadProxmenuxTools = async () => {
@@ -177,11 +195,53 @@ export function Settings() {
))
} catch (err) {
console.error("Failed to update storage exclusion:", err)
} finally {
setSavingStorage(null)
}
} finally {
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 => {
if (hours === -1) return "-1"
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
@@ -621,6 +681,131 @@ export function Settings() {
</CardContent>
</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 */}
<NotificationSettings />

View File

@@ -456,3 +456,145 @@ def delete_storage_exclusion(storage_name):
return jsonify({'error': 'Storage not found in exclusions'}), 404
except Exception as e:
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.
Avoids false positives for unused physical interfaces.
Respects interface exclusions configured by the user.
"""
try:
issues = []
@@ -2352,12 +2353,25 @@ class HealthMonitor:
except Exception:
net_if_addrs = {}
# Get excluded interfaces (for health checks)
excluded_interfaces = health_persistence.get_excluded_interface_names('health')
active_interfaces = set()
for interface, stats in net_if_stats.items():
if interface == 'lo':
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
if not stats.isup:
should_alert = False
@@ -3870,7 +3884,7 @@ class HealthMonitor:
status = 'WARNING'
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')
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'))

View File

@@ -251,6 +251,21 @@ class HealthPersistence:
''')
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.close()
@@ -2328,6 +2343,140 @@ class HealthPersistence:
return {row[0] for row in cursor.fetchall()}
except Exception:
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

View File

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

View File

@@ -385,7 +385,7 @@ class BurstAggregator:
return etype
# ─── Notification Manager ───────────────────<EFBFBD><EFBFBD>─────────────────────
# ─── Notification Manager ────────────────────────────────────────
class NotificationManager:
"""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)
AI_EMOJI_INSTRUCTIONS = """
═══ EMOJI RULES ═══
Use 1-2 emojis at START of lines where they add clarity. Combine when meaningful (💾✅ backup ok).
Not every line needs emoji — use them to highlight, not as filler. Blank lines = completely empty.
═══ EMOJI ENRICHMENT (VISUAL CLARITY) ═══
Your goal is to maintain the original structure of the message while using emojis to add visual clarity,
ESPECIALLY when adding new context, formatting technical data, or writing tips.
TITLE: ✅success ❌failed 💥crash 🆘critical 📦updates 🆕pve-update 🚚migration ⏹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
RULES:
1. PRESERVE BASE STRUCTURE: Respect the original fields and layout provided in the input message.
2. ENHANCE WITH ICONS: Place emojis at the START of a line to identify the data type.
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
📦updates 🔒security 🔄proxmox ⚙kernel 🗂packages 💿disk 📝reason
🌐IP 👤user 🌡temp 🔥CPU 💧RAM 🎯target 🔹current 🟢new 📌item
TITLE EMOJIS:
✅ success ❌ failed 💥 crash 🆘 critical 📦 updates 🆕 pve-update 🚚 migration
⏹️ 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).
═══ EXAMPLES (follow these formats) ═══
BACKUP START:
[TITLE]
💾🚀 pve01: Backup started
[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)"""
NEW CONTEXT formatting (use when adding journal/SMART/enriched data):
📝 Logs indicate process crashed (exit-code 255)
💿 Device /dev/sdb: SMART Health FAILED
⚠️ Recurring issue: 5 occurrences in last 24h
💡 Tip: Run 'systemctl status pvedaemon' to verify"""
# No emoji instructions for email/plain text channels