From a064a7471e3beed28a84e9183063ab0df55cd818 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 6 Mar 2026 19:32:10 +0100 Subject: [PATCH] Update notification service --- AppImage/components/latency-detail-modal.tsx | 775 ++++++++++++++++--- AppImage/scripts/flask_auth_routes.py | 21 +- AppImage/scripts/notification_events.py | 13 - AppImage/scripts/notification_manager.py | 14 +- 4 files changed, 697 insertions(+), 126 deletions(-) diff --git a/AppImage/components/latency-detail-modal.tsx b/AppImage/components/latency-detail-modal.tsx index da798b0b..f39c27b5 100644 --- a/AppImage/components/latency-detail-modal.tsx +++ b/AppImage/components/latency-detail-modal.tsx @@ -1,10 +1,12 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } 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 { Button } from "./ui/button" +import { Badge } from "./ui/badge" +import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText } from "lucide-react" +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts" import { useIsMobile } from "../hooks/use-mobile" import { fetchApi } from "@/lib/api-config" @@ -17,9 +19,9 @@ const TIMEFRAME_OPTIONS = [ ] 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)" }, + { value: "gateway", label: "Gateway (Router)", realtime: false }, + { value: "cloudflare", label: "Cloudflare (1.1.1.1)", realtime: true }, + { value: "google", label: "Google DNS (8.8.8.8)", realtime: true }, ] interface LatencyHistoryPoint { @@ -37,6 +39,17 @@ interface LatencyStats { current: number } +interface RealtimeResult { + target: string + target_ip: string + latency_avg: number | null + latency_min: number | null + latency_max: number | null + packet_loss: number + status: string + timestamp?: number +} + interface LatencyDetailModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -76,28 +89,429 @@ const getStatusColor = (latency: number) => { 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" } +const getStatusInfo = (latency: number | null) => { + if (latency === null || 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" } } +const getStatusText = (latency: number | null): string => { + if (latency === null || latency === 0) return "N/A" + if (latency < 50) return "Excellent" + if (latency < 100) return "Good" + if (latency < 200) return "Fair" + return "Poor" +} + +interface ReportData { + target: string + targetLabel: string + isRealtime: boolean + stats: LatencyStats + realtimeResults: RealtimeResult[] + data: LatencyHistoryPoint[] + timeframe: string +} + +const generateLatencyReport = (report: ReportData) => { + const now = new Date().toLocaleString() + const statusText = report.isRealtime + ? getStatusText(report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ?? null) + : getStatusText(report.stats.current) + + const statusColorMap: Record = { + "Excellent": "#22c55e", + "Good": "#22c55e", + "Fair": "#f59e0b", + "Poor": "#ef4444", + "N/A": "#888888" + } + const statusColor = statusColorMap[statusText] || "#888888" + + const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe + + // Build test results table for realtime mode + const realtimeTableRows = report.realtimeResults.map((r, i) => ` + + ${i + 1} + ${new Date(r.timestamp || Date.now()).toLocaleTimeString()} + ${r.latency_avg !== null ? r.latency_avg + ' ms' : 'Failed'} + ${r.latency_min !== null ? r.latency_min + ' ms' : '-'} + ${r.latency_max !== null ? r.latency_max + ' ms' : '-'} + ${r.packet_loss}% + ${getStatusText(r.latency_avg)} + + `).join('') + + // Build history summary for gateway mode + const historyStats = report.data.length > 0 ? { + samples: report.data.length, + avgPacketLoss: (report.data.reduce((acc, d) => acc + (d.packet_loss || 0), 0) / report.data.length).toFixed(2), + startTime: new Date(report.data[0].timestamp * 1000).toLocaleString(), + endTime: new Date(report.data[report.data.length - 1].timestamp * 1000).toLocaleString(), + } : null + + const html = ` + + + + + Network Latency Report - ProxMenux Monitor + + + +
+ +
+
+

Network Latency Report

+

ProxMenux Monitor - Network Performance Analysis

+
+
+
Generated: ${now}
+
Target: ${report.targetLabel}
+
Mode: ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}
+
ID: PMXL-${Date.now().toString(36).toUpperCase()}
+
+
+ + +
+
1. Executive Summary
+
+
+
+
Overall Status
+
+ ${statusText} +
+
Current Latency
+
+ ${report.isRealtime + ? (report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ?? 'N/A') + (report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ? ' ms' : '') + : report.stats.current + ' ms'} +
+
+
+
+
Analysis Summary
+

+ ${report.isRealtime + ? `This report contains ${report.realtimeResults.length} real-time latency test(s) performed against ${report.targetLabel}. ${ + report.realtimeResults.length > 0 + ? `The average latency across all tests is ${(report.realtimeResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / report.realtimeResults.length).toFixed(1)} ms.` + : '' + }` + : `This report analyzes ${report.data.length} latency samples collected over ${timeframeLabel.toLowerCase()} against the network gateway. The average latency during this period was ${report.stats.avg} ms with a minimum of ${report.stats.min} ms and maximum of ${report.stats.max} ms.` + } +

+
+

Performance Rating

+

${ + statusText === 'Excellent' ? 'Network latency is excellent. No action required.' : + statusText === 'Good' ? 'Network latency is within acceptable parameters.' : + statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' : + statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' : + 'Unable to determine network status.' + }

+
+
+
+
+ + +
+
2. Latency Statistics
+
+
+
Current
+
+ ${report.isRealtime + ? (report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ?? 'N/A') + ' ms' + : report.stats.current + ' ms'} +
+
+
+
Minimum
+
+ ${report.isRealtime + ? (report.realtimeResults.length > 0 ? Math.min(...report.realtimeResults.map(r => r.latency_min || Infinity)).toFixed(1) : 'N/A') + ' ms' + : report.stats.min + ' ms'} +
+
+
+
Average
+
+ ${report.isRealtime + ? (report.realtimeResults.length > 0 ? (report.realtimeResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / report.realtimeResults.length).toFixed(1) : 'N/A') + ' ms' + : report.stats.avg + ' ms'} +
+
+
+
Maximum
+
+ ${report.isRealtime + ? (report.realtimeResults.length > 0 ? Math.max(...report.realtimeResults.map(r => r.latency_max || 0)).toFixed(1) : 'N/A') + ' ms' + : report.stats.max + ' ms'} +
+
+
+ ${!report.isRealtime && historyStats ? ` +
+
+
Sample Count
+
${historyStats.samples}
+
+
+
Period Start
+
${historyStats.startTime}
+
+
+
Period End
+
${historyStats.endTime}
+
+
+ ` : ''} +
+ + ${report.isRealtime && report.realtimeResults.length > 0 ? ` + +
+
3. Test Results
+ + + + + + + + + + + + + + ${realtimeTableRows} + +
#TimeAvg LatencyMinMaxPacket LossStatus
+
+ ` : ''} + + +
+
${report.isRealtime ? '4' : '3'}. Reference Thresholds
+
+
+
+
Excellent (< 50ms): Optimal network performance for all applications including real-time gaming and video calls.
+
+
+
+
Good (50-100ms): Acceptable latency for most applications. Minor impact on real-time interactions.
+
+
+
+
Fair (100-200ms): Noticeable delay in interactive applications. May affect VoIP and gaming quality.
+
+
+
+
Poor (> 200ms): Significant latency causing degraded user experience. Investigation recommended.
+
+
+
+ + +
+
${report.isRealtime ? '5' : '4'}. Methodology
+
+

+ Test Method: ICMP Echo Request (Ping) +

+

+ Target: ${report.targetLabel} ${report.target === 'gateway' ? '(Default network gateway)' : `(${report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'})`} +

+

+ Samples per Test: 3 consecutive pings +

+

+ Metrics Collected: Round-trip time (RTT) minimum, average, maximum, and packet loss percentage +

+
+
+ + + +
+ + + +` + + const printWindow = window.open('', '_blank') + if (printWindow) { + printWindow.document.write(html) + printWindow.document.close() + } +} + 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 [realtimeResults, setRealtimeResults] = useState([]) + const [realtimeTesting, setRealtimeTesting] = useState(false) const isMobile = useIsMobile() + const isRealtime = TARGET_OPTIONS.find(t => t.value === target)?.realtime ?? false + + // Fetch history for gateway useEffect(() => { - if (open) { + if (open && target === "gateway") { fetchHistory() } }, [open, timeframe, target]) + // Auto-test when switching to realtime target + useEffect(() => { + if (open && isRealtime) { + // Clear previous results and run initial test + setRealtimeResults([]) + runRealtimeTest() + } + }, [open, target]) + const fetchHistory = async () => { setLoading(true) try { @@ -109,12 +523,28 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten setStats(result.stats) } } catch (err) { - // Silently fail - will show empty state + // Silently fail } finally { setLoading(false) } } + const runRealtimeTest = useCallback(async () => { + if (realtimeTesting) return + setRealtimeTesting(true) + try { + const result = await fetchApi(`/api/network/latency/current?target=${target}`) + if (result) { + const newResult = { ...result, timestamp: Date.now() } + setRealtimeResults(prev => [...prev.slice(-19), newResult]) // Keep last 20 results + } + } catch (err) { + // Silently fail + } finally { + setRealtimeTesting(false) + } + }, [target, realtimeTesting]) + const formatTime = (timestamp: number) => { const date = new Date(timestamp * 1000) if (timeframe === "hour" || timeframe === "6hour") { @@ -126,19 +556,40 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten } } + const formatRealtimeTime = (timestamp: number) => { + return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + } + + // Gateway view data const chartData = data.map((d) => ({ ...d, time: formatTime(d.timestamp), })) - const currentLat = currentLatency && currentLatency > 0 ? Math.round(currentLatency * 10) / 10 : stats.current + // Realtime view data + const realtimeChartData = realtimeResults.map(r => ({ + time: formatRealtimeTime(r.timestamp || Date.now()), + value: r.latency_avg || 0, + packet_loss: r.packet_loss, + })) + + const lastRealtimeResult = realtimeResults[realtimeResults.length - 1] + const realtimeLatency = lastRealtimeResult?.latency_avg ?? null + + const currentLat = isRealtime + ? realtimeLatency + : (currentLatency && currentLatency > 0 ? Math.round(currentLatency * 10) / 10 : stats.current) + const currentStatus = getStatusInfo(currentLat) - const chartColor = getStatusColor(currentLat) + const chartColor = getStatusColor(currentLat || 0) 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 + const realtimeValues = realtimeResults.map(r => r.latency_avg).filter(v => v !== null) as number[] + const realtimeYMax = realtimeValues.length > 0 ? Math.ceil(Math.max(...realtimeValues) * 1.2) : 200 + return ( @@ -150,7 +601,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
- + {!isRealtime && ( + + )} + {isRealtime && ( + + )} +
+ {/* Realtime mode indicator */} + {isRealtime && ( +
+ + Real-time test mode - Results are not stored. Click "Test Again" for new measurements. +
+ )} + {/* Stats bar */}
-
+
Current
-
{currentLat} ms
-
-
-
- Min -
-
{stats.min} ms
-
-
-
- Avg -
-
{stats.avg} ms
-
-
-
- Max -
-
{stats.max} ms
+
{currentLat !== null ? `${currentLat} ms` : '---'}
+ {isRealtime ? ( + <> +
+
+ Min +
+
+ {lastRealtimeResult?.latency_min !== null ? `${lastRealtimeResult?.latency_min} ms` : '---'} +
+
+
+
+ Max +
+
+ {lastRealtimeResult?.latency_max !== null ? `${lastRealtimeResult?.latency_max} ms` : '---'} +
+
+
+
Packet Loss
+
0 ? 'text-red-500' : 'text-foreground'}`}> + {lastRealtimeResult?.packet_loss !== undefined ? `${lastRealtimeResult.packet_loss}%` : '---'} +
+
+ + ) : ( + <> +
+
+ Min +
+
{stats.min} ms
+
+
+
+ Avg +
+
{stats.avg} ms
+
+
+
+ Max +
+
{stats.max} ms
+
+ + )}
{/* Chart */}
- {loading ? ( -
-
-
-
+ {isRealtime ? ( + // Realtime chart - shows test results from this session + realtimeChartData.length === 0 ? ( +
+
+ {realtimeTesting ? ( + <> + +

Running latency test...

+ + ) : ( + <> + +

Click "Test Again" to run a latency test

+ + )} +
-
- ) : chartData.length === 0 ? ( -
-
- -

No latency data available for this period

-

Data is collected every 60 seconds

-
-
+ ) : ( + + + + + `${v}ms`} + width={isMobile ? 45 : 50} + /> + } /> + + + + ) ) : ( - - - - - - - - - - - `${v}ms`} - width={isMobile ? 45 : 50} - /> - } /> - - - + // Gateway historical chart + loading ? ( +
+
+
+
+
+
+ ) : chartData.length === 0 ? ( +
+
+ +

No latency data available for this period

+

Data is collected every 60 seconds

+
+
+ ) : ( + + + + + + + + + + + `${v}ms`} + width={isMobile ? 45 : 50} + /> + } /> + + + + ) )}
+ + {/* Test history for realtime mode */} + {isRealtime && realtimeResults.length > 0 && ( +
+ {realtimeResults.length} test{realtimeResults.length > 1 ? 's' : ''} this session +
+ )}
) diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py index 254cb76b..14491c26 100644 --- a/AppImage/scripts/flask_auth_routes.py +++ b/AppImage/scripts/flask_auth_routes.py @@ -4,6 +4,7 @@ Provides REST API endpoints for authentication management """ import logging +import logging.handlers import os import subprocess import threading @@ -13,13 +14,25 @@ import auth_manager import jwt import datetime -# Dedicated logger for auth failures (Fail2Ban reads this) +# Dedicated logger for auth failures (Fail2Ban reads this file) auth_logger = logging.getLogger("proxmenux-auth") -_auth_handler = logging.FileHandler("/var/log/proxmenux-auth.log") -_auth_handler.setFormatter(logging.Formatter("%(asctime)s proxmenux-auth: %(message)s")) -auth_logger.addHandler(_auth_handler) auth_logger.setLevel(logging.WARNING) +# Handler 1: File for Fail2Ban +_auth_file_handler = logging.FileHandler("/var/log/proxmenux-auth.log") +_auth_file_handler.setFormatter(logging.Formatter("%(asctime)s proxmenux-auth: %(message)s")) +auth_logger.addHandler(_auth_file_handler) + +# Handler 2: Syslog for JournalWatcher notifications +# This sends to the systemd journal so notification_events.py can detect auth failures +try: + _auth_syslog_handler = logging.handlers.SysLogHandler(address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_AUTH) + _auth_syslog_handler.setFormatter(logging.Formatter("proxmenux-auth: %(message)s")) + _auth_syslog_handler.ident = "proxmenux-auth" + auth_logger.addHandler(_auth_syslog_handler) +except Exception: + pass # Syslog may not be available in all environments + def _get_client_ip(): """Get the real client IP, supporting reverse proxies (X-Forwarded-For, X-Real-IP)""" diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 7d4c6b9d..25afe0a7 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -246,10 +246,6 @@ class JournalWatcher: syslog_id = entry.get('SYSLOG_IDENTIFIER', '') priority = int(entry.get('PRIORITY', 6)) - # Debug: log auth-related messages - if 'auth' in msg.lower() or 'password' in msg.lower(): - print(f"[v0] JournalWatcher received auth message: syslog_id={syslog_id}, msg={msg[:80]}") - self._check_auth_failure(msg, syslog_id, entry) self._check_fail2ban(msg, syslog_id) self._check_kernel_critical(msg, syslog_id, priority) @@ -279,15 +275,10 @@ class JournalWatcher: (r'pvedaemon\[.*authentication failure.*rhost=(\S+)', 'pve'), ] - # Debug: check if message contains auth failure - if 'authentication failure' in msg.lower() or 'failed password' in msg.lower(): - print(f"[v0] _check_auth_failure processing: {msg[:100]}") - for pattern, service in patterns: match = re.search(pattern, msg, re.IGNORECASE) if match: groups = match.groups() - print(f"[v0] Auth pattern matched: service={service}, groups={groups}") if service == 'ssh': username, source_ip = groups[0], groups[1] elif service == 'pam': @@ -295,8 +286,6 @@ class JournalWatcher: else: source_ip = groups[0] username = 'unknown' - - print(f"[v0] Emitting auth_fail: ip={source_ip}, user={username}, service={service}") self._emit('auth_fail', 'WARNING', { 'source_ip': source_ip, 'username': username, @@ -1139,7 +1128,6 @@ class JournalWatcher: now = time.time() last = self._recent_events.get(event.fingerprint, 0) if now - last < self._dedup_window: - print(f"[v0] _emit SKIPPED (dedup): {event_type} fingerprint={event.fingerprint[:20]}") return # Skip duplicate within 30s window self._recent_events[event.fingerprint] = now @@ -1151,7 +1139,6 @@ class JournalWatcher: k: v for k, v in self._recent_events.items() if v > cutoff } - print(f"[v0] _emit QUEUED: {event_type} to queue (queue size: {self._queue.qsize()})") self._queue.put(event) diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 8de3d16c..0fd69fd7 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -655,29 +655,19 @@ class NotificationManager: event_group = template.get('group', 'other') default_event_enabled = 'true' if template.get('default_enabled', True) else 'false' - print(f"[v0] _dispatch_to_channels called: event_type={event_type}, group={event_group}, channels={list(channels.keys())}") - for ch_name, channel in channels.items(): # ── Per-channel category check ── # Default: category enabled (true) unless explicitly disabled. ch_group_key = f'{ch_name}.events.{event_group}' - ch_group_val = self._config.get(ch_group_key, 'true') - print(f"[v0] Channel {ch_name}: {ch_group_key}={ch_group_val}") - if ch_group_val == 'false': - print(f"[v0] Channel {ch_name}: SKIPPED - category {event_group} disabled") + if self._config.get(ch_group_key, 'true') == 'false': continue # Channel has this category disabled # ── Per-channel event check ── # Default: from template default_enabled, unless explicitly set. ch_event_key = f'{ch_name}.event.{event_type}' - ch_event_val = self._config.get(ch_event_key, default_event_enabled) - print(f"[v0] Channel {ch_name}: {ch_event_key}={ch_event_val} (default={default_event_enabled})") - if ch_event_val == 'false': - print(f"[v0] Channel {ch_name}: SKIPPED - event {event_type} disabled") + if self._config.get(ch_event_key, default_event_enabled) == 'false': continue # Channel has this specific event disabled - print(f"[v0] Channel {ch_name}: SENDING notification for {event_type}") - try: # Per-channel emoji enrichment (opt-in via {channel}.rich_format) ch_title, ch_body = title, body