diff --git a/AppImage/components/latency-detail-modal.tsx b/AppImage/components/latency-detail-modal.tsx new file mode 100644 index 00000000..da798b0b --- /dev/null +++ b/AppImage/components/latency-detail-modal.tsx @@ -0,0 +1,267 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { Activity, TrendingDown, TrendingUp, Minus } from "lucide-react" +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +import { useIsMobile } from "../hooks/use-mobile" +import { fetchApi } from "@/lib/api-config" + +const TIMEFRAME_OPTIONS = [ + { value: "hour", label: "1 Hour" }, + { value: "6hour", label: "6 Hours" }, + { value: "day", label: "24 Hours" }, + { value: "3day", label: "3 Days" }, + { value: "week", label: "7 Days" }, +] + +const TARGET_OPTIONS = [ + { value: "gateway", label: "Gateway (Router)" }, + { value: "cloudflare", label: "Cloudflare (1.1.1.1)" }, + { value: "google", label: "Google DNS (8.8.8.8)" }, +] + +interface LatencyHistoryPoint { + timestamp: number + value: number + min?: number + max?: number + packet_loss?: number +} + +interface LatencyStats { + min: number + max: number + avg: number + current: number +} + +interface LatencyDetailModalProps { + open: boolean + onOpenChange: (open: boolean) => void + currentLatency?: number +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const entry = payload[0] + const packetLoss = entry?.payload?.packet_loss + return ( +
+

{label}

+
+
+
+ Latency: + {entry.value} ms +
+ {packetLoss !== undefined && packetLoss > 0 && ( +
+
+ Pkt Loss: + {packetLoss}% +
+ )} +
+
+ ) + } + return null +} + +const getStatusColor = (latency: number) => { + if (latency >= 200) return "#ef4444" + if (latency >= 100) return "#f59e0b" + return "#22c55e" +} + +const getStatusInfo = (latency: number) => { + if (latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } + if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } + return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" } +} + +export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) { + const [timeframe, setTimeframe] = useState("hour") + const [target, setTarget] = useState("gateway") + const [data, setData] = useState([]) + const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 }) + const [loading, setLoading] = useState(true) + const isMobile = useIsMobile() + + useEffect(() => { + if (open) { + fetchHistory() + } + }, [open, timeframe, target]) + + const fetchHistory = async () => { + setLoading(true) + try { + const result = await fetchApi<{ data: LatencyHistoryPoint[]; stats: LatencyStats; target: string }>( + `/api/network/latency/history?target=${target}&timeframe=${timeframe}` + ) + if (result && result.data) { + setData(result.data) + setStats(result.stats) + } + } catch (err) { + // Silently fail - will show empty state + } finally { + setLoading(false) + } + } + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp * 1000) + if (timeframe === "hour" || timeframe === "6hour") { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } else if (timeframe === "day") { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } else { + return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) + } + } + + const chartData = data.map((d) => ({ + ...d, + time: formatTime(d.timestamp), + })) + + const currentLat = currentLatency && currentLatency > 0 ? Math.round(currentLatency * 10) / 10 : stats.current + const currentStatus = getStatusInfo(currentLat) + const chartColor = getStatusColor(currentLat) + + const values = data.map((d) => d.value).filter(v => v !== null && v !== undefined) + const yMin = 0 + const yMax = values.length > 0 ? Math.ceil(Math.max(...values) * 1.2) : 200 + + return ( + + + +
+ + + Network Latency + +
+ + +
+
+
+ + {/* Stats bar */} +
+
+
Current
+
{currentLat} ms
+
+
+
+ Min +
+
{stats.min} ms
+
+
+
+ Avg +
+
{stats.avg} ms
+
+
+
+ Max +
+
{stats.max} ms
+
+
+ + {/* Chart */} +
+ {loading ? ( +
+
+
+
+
+
+ ) : chartData.length === 0 ? ( +
+
+ +

No latency data available for this period

+

Data is collected every 60 seconds

+
+
+ ) : ( + + + + + + + + + + + `${v}ms`} + width={isMobile ? 45 : 50} + /> + } /> + + + + )} +
+ +
+ ) +} diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx index eabc2692..48258320 100644 --- a/AppImage/components/network-metrics.tsx +++ b/AppImage/components/network-metrics.tsx @@ -4,12 +4,14 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" -import { Wifi, Activity, Network, Router, AlertCircle, Zap } from 'lucide-react' +import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer } from 'lucide-react' import useSWR from "swr" import { NetworkTrafficChart } from "./network-traffic-chart" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" +import { LatencyDetailModal } from "./latency-detail-modal" +import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts" interface NetworkData { interfaces: NetworkInterface[] @@ -150,8 +152,19 @@ export function NetworkMetrics() { const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day") const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) + const [latencyModalOpen, setLatencyModalOpen] = useState(false) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit()) + + // Latency history for sparkline (last hour) + const { data: latencyData } = useSWR<{ + data: Array<{ timestamp: number; value: number }> + stats: { min: number; max: number; avg: number; current: number } + target: string + }>("/api/network/latency/history?target=gateway&timeframe=hour", + (url: string) => fetchApi(url), + { refreshInterval: 60000, revalidateOnFocus: false } + ) useEffect(() => { setNetworkUnit(getNetworkUnit()) @@ -330,48 +343,87 @@ export function NetworkMetrics() { + {/* Merged Network Config & Health Card */} - Network Configuration - + Network Status + + {healthStatus} +
-
+
Hostname - {hostname} + {hostname}
-
- Domain - {domain} -
-
+
Primary DNS - {primaryDNS} + {primaryDNS} +
+
+ Packet Loss + {avgPacketLoss}% +
+
+ Errors + {totalErrors}
- + {/* Latency Card with Sparkline */} + setLatencyModalOpen(true)} + > - Network Health - + Network Latency + - - {healthStatus} - -
-
- Packet Loss: - {avgPacketLoss}% -
-
- Errors: - {totalErrors} +
+
+ {latencyData?.stats?.current ?? 0} ms
+ + {(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" : + (latencyData?.stats?.current ?? 0) < 100 ? "Good" : + (latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"} +
+ {/* Sparkline */} + {latencyData?.data && latencyData.data.length > 0 && ( +
+ + + + + + +
+ )} +

+ Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms +

@@ -1091,6 +1143,12 @@ export function NetworkMetrics() { )} + + {/* Latency Detail Modal */} +
) } diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 80977894..a87149e5 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -1307,7 +1307,7 @@ export function StorageOverview() { Loading observations...
) : ( -
+
{diskObservations.map((obs) => (
= 60: # Every 60 iterations = 60 minutes _cleanup_old_temperature_data() + _cleanup_old_latency_data() cleanup_counter = 0 time.sleep(60) +# ── Latency History (SQLite) ────────────────────────────────────────────────── +# Stores network latency readings every 60s in the same database as temperature. +# Supports multiple targets (gateway, cloudflare, google). +# Retention: 7 days max, cleaned up every hour. + +LATENCY_TARGETS = { + 'gateway': None, # Auto-detect default gateway + 'cloudflare': '1.1.1.1', + 'google': '8.8.8.8', +} + +def _get_default_gateway(): + """Get the default gateway IP address.""" + try: + result = subprocess.run( + ['ip', 'route', 'show', 'default'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + # Parse: "default via 192.168.1.1 dev eth0" + parts = result.stdout.strip().split() + if 'via' in parts: + idx = parts.index('via') + if idx + 1 < len(parts): + return parts[idx + 1] + except Exception: + pass + return '192.168.1.1' # Fallback + +def init_latency_db(): + """Create the latency_history table if it doesn't exist.""" + try: + conn = _get_temp_db() + conn.execute(""" + CREATE TABLE IF NOT EXISTS latency_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + target TEXT NOT NULL, + latency_avg REAL, + latency_min REAL, + latency_max REAL, + packet_loss REAL DEFAULT 0 + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_latency_timestamp_target + ON latency_history(timestamp, target) + """) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"[ProxMenux] Latency DB init failed: {e}") + return False + +def _measure_latency(target_ip: str) -> dict: + """Ping a target and return latency stats.""" + try: + result = subprocess.run( + ['ping', '-c', '3', '-W', '2', target_ip], + capture_output=True, text=True, timeout=10 + ) + + if result.returncode == 0: + latencies = [] + for line in result.stdout.split('\n'): + if 'time=' in line: + try: + latency_str = line.split('time=')[1].split()[0] + latencies.append(float(latency_str)) + except: + pass + + if latencies: + return { + 'success': True, + 'avg': round(sum(latencies) / len(latencies), 1), + 'min': round(min(latencies), 1), + 'max': round(max(latencies), 1), + 'packet_loss': round((3 - len(latencies)) / 3 * 100, 1) + } + + # Ping failed - 100% packet loss + return {'success': False, 'avg': None, 'min': None, 'max': None, 'packet_loss': 100.0} + except Exception: + return {'success': False, 'avg': None, 'min': None, 'max': None, 'packet_loss': 100.0} + +def _record_latency(): + """Record latency to the default gateway.""" + try: + gateway = _get_default_gateway() + stats = _measure_latency(gateway) + + conn = _get_temp_db() + conn.execute( + """INSERT INTO latency_history + (timestamp, target, latency_avg, latency_min, latency_max, packet_loss) + VALUES (?, ?, ?, ?, ?, ?)""", + (int(time.time()), 'gateway', stats['avg'], stats['min'], stats['max'], stats['packet_loss']) + ) + conn.commit() + conn.close() + except Exception: + pass + +def _cleanup_old_latency_data(): + """Remove latency records older than 7 days.""" + try: + cutoff = int(time.time()) - (7 * 24 * 3600) + conn = _get_temp_db() + conn.execute("DELETE FROM latency_history WHERE timestamp < ?", (cutoff,)) + conn.commit() + conn.close() + except Exception: + pass + +def get_latency_history(target='gateway', timeframe='hour'): + """Get latency history with downsampling for longer timeframes.""" + try: + now = int(time.time()) + if timeframe == "hour": + since = now - 3600 + interval = None # All points (~60) + elif timeframe == "6hour": + since = now - 6 * 3600 + interval = 300 # 5 min avg + elif timeframe == "day": + since = now - 86400 + interval = 600 # 10 min avg + elif timeframe == "3day": + since = now - 3 * 86400 + interval = 1800 # 30 min avg + elif timeframe == "week": + since = now - 7 * 86400 + interval = 3600 # 1h avg + else: + since = now - 3600 + interval = None + + conn = _get_temp_db() + + if interval is None: + cursor = conn.execute( + """SELECT timestamp, latency_avg, latency_min, latency_max, packet_loss + FROM latency_history + WHERE timestamp >= ? AND target = ? + ORDER BY timestamp ASC""", + (since, target) + ) + rows = cursor.fetchall() + data = [{"timestamp": r[0], "value": r[1], "min": r[2], "max": r[3], "packet_loss": r[4]} for r in rows if r[1] is not None] + else: + cursor = conn.execute( + """SELECT (timestamp / ?) * ? as bucket, + ROUND(AVG(latency_avg), 1) as avg_val, + ROUND(MIN(latency_min), 1) as min_val, + ROUND(MAX(latency_max), 1) as max_val, + ROUND(AVG(packet_loss), 1) as avg_loss + FROM latency_history + WHERE timestamp >= ? AND target = ? + GROUP BY bucket + ORDER BY bucket ASC""", + (interval, interval, since, target) + ) + rows = cursor.fetchall() + data = [{"timestamp": r[0], "value": r[1], "min": r[2], "max": r[3], "packet_loss": r[4]} for r in rows if r[1] is not None] + + conn.close() + + # Compute stats + if data: + values = [d["value"] for d in data if d["value"] is not None] + if values: + mins = [d["min"] for d in data if d.get("min") is not None] + maxs = [d["max"] for d in data if d.get("max") is not None] + stats = { + "min": round(min(mins) if mins else min(values), 1), + "max": round(max(maxs) if maxs else max(values), 1), + "avg": round(sum(values) / len(values), 1), + "current": values[-1] if values else 0 + } + else: + stats = {"min": 0, "max": 0, "avg": 0, "current": 0} + else: + stats = {"min": 0, "max": 0, "avg": 0, "current": 0} + + return {"data": data, "stats": stats, "target": target} + except Exception as e: + return {"data": [], "stats": {"min": 0, "max": 0, "avg": 0, "current": 0}, "target": target} + +def get_current_latency(target='gateway'): + """Get the most recent latency measurement for a target.""" + try: + # If gateway, resolve to actual IP + if target == 'gateway': + target_ip = _get_default_gateway() + else: + target_ip = LATENCY_TARGETS.get(target, target) + + stats = _measure_latency(target_ip) + return { + 'target': target, + 'target_ip': target_ip, + 'latency_avg': stats['avg'], + 'latency_min': stats['min'], + 'latency_max': stats['max'], + 'packet_loss': stats['packet_loss'], + 'status': 'ok' if stats['success'] and stats['avg'] and stats['avg'] < 100 else 'warning' if stats['success'] else 'error' + } + except Exception: + return {'target': target, 'latency_avg': None, 'status': 'error'} + + def _health_collector_loop(): """Background thread: run full health checks every 5 minutes. Keeps the health cache always fresh and records events/errors in the DB. @@ -621,9 +836,22 @@ def _health_collector_loop(): # Compare each category's current status to previous cycle. # Notify when a category DEGRADES (OK->WARNING, WARNING->CRITICAL, etc.) # Include the detailed 'reason' so the user knows exactly what triggered it. + # + # IMPORTANT: Some health categories map to specific notification toggles: + # - network + latency issue -> 'network_latency' toggle + # - network + connectivity issue -> 'network_down' toggle + # If the specific toggle is disabled, skip that notification. details = result.get('details', {}) degraded = [] + # Map health categories to specific event types for toggle checks + _CATEGORY_EVENT_MAP = { + # (category, reason_contains) -> event_type to check + ('network', 'latency'): 'network_latency', + ('network', 'connectivity'): 'network_down', + ('network', 'unreachable'): 'network_down', + } + for cat_key, cat_data in details.items(): cur_status = cat_data.get('status', 'OK') prev_status = _prev_statuses.get(cat_key, 'OK') @@ -632,12 +860,23 @@ def _health_collector_loop(): if cur_rank > prev_rank and cur_rank >= 2: # WARNING or CRITICAL reason = cat_data.get('reason', f'{cat_key} status changed to {cur_status}') + reason_lower = reason.lower() cat_name = _CAT_NAMES.get(cat_key, cat_key) - degraded.append({ - 'category': cat_name, - 'status': cur_status, - 'reason': reason, - }) + + # Check if this specific notification type is enabled + skip_notification = False + for (map_cat, map_keyword), event_type in _CATEGORY_EVENT_MAP.items(): + if cat_key == map_cat and map_keyword in reason_lower: + if not notification_manager.is_event_enabled(event_type): + skip_notification = True + break + + if not skip_notification: + degraded.append({ + 'category': cat_name, + 'status': cur_status, + 'reason': reason, + }) _prev_statuses[cat_key] = cur_status @@ -5438,6 +5677,44 @@ def api_temperature_history(): return jsonify({'data': [], 'stats': {'min': 0, 'max': 0, 'avg': 0, 'current': 0}}), 500 +@app.route('/api/network/latency/history', methods=['GET']) +@require_auth +def api_latency_history(): + """Get latency history for charts. + + Query params: + target: gateway (default), cloudflare, google + timeframe: hour, 6hour, day, 3day, week + """ + try: + target = request.args.get('target', 'gateway') + if target not in ('gateway', 'cloudflare', 'google'): + target = 'gateway' + timeframe = request.args.get('timeframe', 'hour') + if timeframe not in ('hour', '6hour', 'day', '3day', 'week'): + timeframe = 'hour' + result = get_latency_history(target, timeframe) + return jsonify(result) + except Exception as e: + return jsonify({'data': [], 'stats': {'min': 0, 'max': 0, 'avg': 0, 'current': 0}, 'target': 'gateway'}), 500 + + +@app.route('/api/network/latency/current', methods=['GET']) +@require_auth +def api_latency_current(): + """Get current latency measurement for a target. + + Query params: + target: gateway (default), cloudflare, google, or custom IP + """ + try: + target = request.args.get('target', 'gateway') + result = get_current_latency(target) + return jsonify(result) + except Exception as e: + return jsonify({'target': target, 'latency_avg': None, 'status': 'error'}), 500 + + @app.route('/api/storage', methods=['GET']) @require_auth def api_storage(): @@ -7382,17 +7659,18 @@ if __name__ == '__main__': except Exception as e: print(f"[ProxMenux] journald check skipped: {e}") - # ── Temperature history collector ── - # Initialize SQLite DB and start background thread to record CPU temp every 60s - if init_temperature_db(): - # Record initial reading immediately + # ── Temperature & Latency history collector ── + # Initialize SQLite DB and start background thread to record CPU temp + latency every 60s + if init_temperature_db() and init_latency_db(): + # Record initial readings immediately _record_temperature() - # Start background collector thread + _record_latency() + # Start background collector thread (handles both temp and latency) temp_thread = threading.Thread(target=_temperature_collector_loop, daemon=True) temp_thread.start() - print("[ProxMenux] Temperature history collector started (60s interval, 30d retention)") + print("[ProxMenux] Temperature & Latency history collector started (60s interval)") else: - print("[ProxMenux] Temperature history disabled (DB init failed)") + print("[ProxMenux] Temperature/Latency history disabled (DB init failed)") # ── Background Health Monitor ── # Run full health checks every 5 min, keeping cache fresh and recording events for notifications diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 0d452256..bb835e37 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -2006,7 +2006,11 @@ class HealthMonitor: return {'status': 'UNKNOWN', 'reason': f'Network check unavailable: {str(e)}', 'checks': {}} def _check_network_latency(self) -> Optional[Dict[str, Any]]: - """Check network latency to 1.1.1.1 (cached)""" + """Check network latency to 1.1.1.1 using 3 consecutive pings. + + Uses 3 pings to avoid false positives from transient network spikes. + Reports the average latency and only warns if all 3 exceed threshold. + """ cache_key = 'network_latency' current_time = time.time() @@ -2015,42 +2019,60 @@ class HealthMonitor: return self.cached_results.get(cache_key) try: + # Use 3 pings to get reliable latency measurement result = subprocess.run( - ['ping', '-c', '1', '-W', '1', '1.1.1.1'], + ['ping', '-c', '3', '-W', '2', '1.1.1.1'], capture_output=True, text=True, - timeout=self.NETWORK_TIMEOUT + timeout=self.NETWORK_TIMEOUT + 6 # Allow time for 3 pings ) if result.returncode == 0: + # Parse individual ping times + latencies = [] for line in result.stdout.split('\n'): if 'time=' in line: try: latency_str = line.split('time=')[1].split()[0] - latency = float(latency_str) - - if latency > self.NETWORK_LATENCY_CRITICAL: - status = 'CRITICAL' - reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_CRITICAL}ms' - elif latency > self.NETWORK_LATENCY_WARNING: - status = 'WARNING' - reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_WARNING}ms' - else: - status = 'OK' - reason = None - - latency_result = { - 'status': status, - 'latency_ms': round(latency, 1) - } - if reason: - latency_result['reason'] = reason - - self.cached_results[cache_key] = latency_result - self.last_check_times[cache_key] = current_time - return latency_result + latencies.append(float(latency_str)) except: pass + + if latencies: + # Calculate average latency + avg_latency = sum(latencies) / len(latencies) + max_latency = max(latencies) + min_latency = min(latencies) + + # Count how many pings exceeded thresholds + critical_count = sum(1 for l in latencies if l > self.NETWORK_LATENCY_CRITICAL) + warning_count = sum(1 for l in latencies if l > self.NETWORK_LATENCY_WARNING) + + # Only report WARNING/CRITICAL if majority of pings exceed threshold + # This prevents false positives from single transient spikes + if critical_count >= 2: # 2 or more of 3 pings are critical + status = 'CRITICAL' + reason = f'Latency {avg_latency:.1f}ms avg >{self.NETWORK_LATENCY_CRITICAL}ms (min:{min_latency:.0f} max:{max_latency:.0f})' + elif warning_count >= 2: # 2 or more of 3 pings exceed warning + status = 'WARNING' + reason = f'Latency {avg_latency:.1f}ms avg >{self.NETWORK_LATENCY_WARNING}ms (min:{min_latency:.0f} max:{max_latency:.0f})' + else: + status = 'OK' + reason = None + + latency_result = { + 'status': status, + 'latency_ms': round(avg_latency, 1), + 'latency_min': round(min_latency, 1), + 'latency_max': round(max_latency, 1), + 'samples': len(latencies), + } + if reason: + latency_result['reason'] = reason + + self.cached_results[cache_key] = latency_result + self.last_check_times[cache_key] = current_time + return latency_result # If ping failed (timeout, unreachable) - distinguish the reason stderr_lower = (result.stderr or '').lower() if hasattr(result, 'stderr') else '' diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 81125c15..0fd69fd7 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -902,10 +902,21 @@ class NotificationManager: def send_notification(self, event_type: str, severity: str, title: str, message: str, data: Optional[Dict] = None, - source: str = 'api') -> Dict[str, Any]: + source: str = 'api', + skip_toggle_check: bool = False) -> Dict[str, Any]: """Send a notification directly (bypasses queue and cooldown). Used by CLI and API for explicit sends. + + Args: + event_type: Type of event (must match TEMPLATES key) + severity: INFO, WARNING, CRITICAL + title: Notification title + message: Notification body + data: Extra data for template rendering + source: Origin of notification (api, cli, health_monitor, etc.) + skip_toggle_check: If True, send even if event toggle is disabled. + Use for 'custom' or 'other' events that should always send. """ if not self._channels: self._load_config() @@ -917,6 +928,17 @@ class NotificationManager: 'channels_sent': [], } + # Check if this event type is enabled (unless explicitly skipped) + # 'custom' and 'other' events always send (used for manual/script notifications) + if not skip_toggle_check and event_type not in ('custom', 'other'): + if not self.is_event_enabled(event_type): + return { + 'success': False, + 'error': f'Event type "{event_type}" is disabled in notification settings', + 'channels_sent': [], + 'skipped': True, + } + # Render template if available if event_type in TEMPLATES and not message: rendered = render_template(event_type, data or {}) @@ -1076,6 +1098,54 @@ class NotificationManager: return {'success': True, 'enabled': enabled} + def is_event_enabled(self, event_type: str) -> bool: + """Check if a specific event type is enabled for notifications. + + Returns True if ANY active channel has this event enabled. + Returns False only if ALL channels have explicitly disabled this event. + Used by callers like health_polling_thread to skip notifications + for disabled events. + + The UI stores toggles per-channel as '{channel}.event.{event_type}'. + We check all configured channels - if any has it enabled, return True. + """ + if not self._config: + self._load_config() + + # Get template info for default state + tmpl = TEMPLATES.get(event_type, {}) + default_enabled = 'true' if tmpl.get('default_enabled', True) else 'false' + event_group = tmpl.get('group', 'other') + + # Check each configured channel + # A channel is "active" if it has .enabled = true + channel_types = ['telegram', 'gotify', 'discord', 'email', 'ntfy', 'pushover', 'slack'] + active_channels = [] + + for ch_name in channel_types: + if self._config.get(f'{ch_name}.enabled', 'false') == 'true': + active_channels.append(ch_name) + + # If no channels are configured, consider events as "disabled" + # (no point generating notifications with no destination) + if not active_channels: + return False + + # Check if ANY active channel has this event enabled + for ch_name in active_channels: + # First check category toggle for this channel + ch_group_key = f'{ch_name}.events.{event_group}' + if self._config.get(ch_group_key, 'true') == 'false': + continue # Category disabled for this channel + + # Then check event-specific toggle + ch_event_key = f'{ch_name}.event.{event_type}' + if self._config.get(ch_event_key, default_enabled) == 'true': + return True # At least one channel has it enabled + + # All active channels have this event disabled + return False + def list_channels(self) -> Dict[str, Any]: """List all channel types with their configuration status.""" if not self._config: