diff --git a/AppImage/components/latency-detail-modal.tsx b/AppImage/components/latency-detail-modal.tsx index 86159a47..e55c5111 100644 --- a/AppImage/components/latency-detail-modal.tsx +++ b/AppImage/components/latency-detail-modal.tsx @@ -125,20 +125,13 @@ const generateLatencyReport = (report: ReportData) => { const logoUrl = `${window.location.origin}/images/proxmenux-logo.png` // Calculate stats for realtime results - const realtimeStats = report.realtimeResults.length > 0 ? (() => { - const validResults = report.realtimeResults.filter(r => r.latency_avg !== null) - const minValues = report.realtimeResults.filter(r => r.latency_min !== null).map(r => r.latency_min!) - const maxValues = report.realtimeResults.filter(r => r.latency_max !== null).map(r => r.latency_max!) - const avgValues = validResults.map(r => r.latency_avg!) - - return { - min: minValues.length > 0 ? Math.min(...minValues) : (avgValues.length > 0 ? Math.min(...avgValues) : 0), - max: maxValues.length > 0 ? Math.max(...maxValues) : (avgValues.length > 0 ? Math.max(...avgValues) : 0), - avg: validResults.length > 0 ? validResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / validResults.length : 0, - current: report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ?? null, - avgPacketLoss: report.realtimeResults.reduce((acc, r) => acc + (r.packet_loss || 0), 0) / report.realtimeResults.length, - } - })() : null + const realtimeStats = report.realtimeResults.length > 0 ? { + min: Math.min(...report.realtimeResults.filter(r => r.latency_min !== null).map(r => r.latency_min!)), + max: Math.max(...report.realtimeResults.filter(r => r.latency_max !== null).map(r => r.latency_max!)), + avg: report.realtimeResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / report.realtimeResults.length, + current: report.realtimeResults[report.realtimeResults.length - 1]?.latency_avg ?? null, + avgPacketLoss: report.realtimeResults.reduce((acc, r) => acc + (r.packet_loss || 0), 0) / report.realtimeResults.length, + } : null const statusText = report.isRealtime ? getStatusText(realtimeStats?.current ?? null) @@ -177,11 +170,17 @@ const generateLatencyReport = (report: ReportData) => { endTime: new Date(report.data[report.data.length - 1].timestamp * 1000).toLocaleString(), } : null - // Generate chart SVG - use average values for the line chart + // Generate chart SVG - expand realtime to all 3 values (min, avg, max) per sample const chartData = report.isRealtime - ? report.realtimeResults - .filter(r => r.latency_avg !== null) - .map(r => r.latency_avg!) + ? report.realtimeResults.flatMap(r => { + const points: number[] = [] + if (r.latency_min !== null) points.push(r.latency_min) + if (r.latency_avg !== null && r.latency_avg !== r.latency_min && r.latency_avg !== r.latency_max) { + points.push(r.latency_avg) + } + if (r.latency_max !== null) points.push(r.latency_max) + return points.length > 0 ? points : [r.latency_avg ?? 0] + }) : report.data.map(d => d.value || 0) let chartSvg = '

Not enough data points for chart

' @@ -780,32 +779,30 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten time: new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), })) - // Use average value for the chart line (min/max are shown in stats) - const realtimeChartData = realtimeResults - .filter(r => r.latency_avg !== null) - .map(r => ({ - time: new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - value: r.latency_avg, - min: r.latency_min, - max: r.latency_max, - packet_loss: r.packet_loss - })) + // Expand each sample to 3 data points (min, avg, max) for accurate representation + const realtimeChartData = realtimeResults.flatMap((r, i) => { + const time = new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + const points = [] + if (r.latency_min !== null) points.push({ time, value: r.latency_min, packet_loss: r.packet_loss }) + if (r.latency_avg !== null && r.latency_avg !== r.latency_min && r.latency_avg !== r.latency_max) { + points.push({ time, value: r.latency_avg, packet_loss: r.packet_loss }) + } + if (r.latency_max !== null) points.push({ time, value: r.latency_max, packet_loss: r.packet_loss }) + // If no valid points, add avg as fallback + if (points.length === 0 && r.latency_avg !== null) { + points.push({ time, value: r.latency_avg, packet_loss: r.packet_loss }) + } + return points + }) // Calculate realtime stats - const realtimeStats = realtimeResults.length > 0 ? (() => { - const validResults = realtimeResults.filter(r => r.latency_avg !== null) - const minValues = realtimeResults.filter(r => r.latency_min !== null).map(r => r.latency_min!) - const maxValues = realtimeResults.filter(r => r.latency_max !== null).map(r => r.latency_max!) - const avgValues = validResults.map(r => r.latency_avg!) - - return { - current: realtimeResults[realtimeResults.length - 1]?.latency_avg ?? 0, - min: minValues.length > 0 ? Math.min(...minValues) : (avgValues.length > 0 ? Math.min(...avgValues) : 0), - max: maxValues.length > 0 ? Math.max(...maxValues) : (avgValues.length > 0 ? Math.max(...avgValues) : 0), - avg: validResults.length > 0 ? validResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / validResults.length : 0, - packetLoss: realtimeResults[realtimeResults.length - 1]?.packet_loss ?? 0, - } - })() : null + const realtimeStats = realtimeResults.length > 0 ? { + current: realtimeResults[realtimeResults.length - 1]?.latency_avg ?? 0, + min: Math.min(...realtimeResults.filter(r => r.latency_min !== null).map(r => r.latency_min!)) || 0, + max: Math.max(...realtimeResults.filter(r => r.latency_max !== null).map(r => r.latency_max!)) || 0, + avg: realtimeResults.reduce((acc, r) => acc + (r.latency_avg || 0), 0) / realtimeResults.length, + packetLoss: realtimeResults[realtimeResults.length - 1]?.packet_loss ?? 0, + } : null const displayStats = isRealtime ? { current: realtimeStats?.current ?? 0, diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 24dafeaa..90579fe4 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -631,39 +631,39 @@ def init_latency_db(): return False def _measure_latency(target_ip: str) -> dict: - """Ping a target and return latency stats. Uses 3 pings and returns avg, min, max for full visibility.""" - 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} + """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. Only stores the average of 3 pings.""" + """Record latency to the default gateway.""" try: gateway = _get_default_gateway() stats = _measure_latency(gateway) @@ -671,9 +671,9 @@ def _record_latency(): conn = _get_temp_db() conn.execute( """INSERT INTO latency_history - (timestamp, target, latency_avg, packet_loss) - VALUES (?, ?, ?, ?)""", - (int(time.time()), 'gateway', stats['avg'], stats['packet_loss']) + (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() @@ -718,18 +718,20 @@ def get_latency_history(target='gateway', timeframe='hour'): if interval is None: cursor = conn.execute( - """SELECT timestamp, latency_avg, packet_loss + """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], "packet_loss": r[2]} for r in rows if r[1] is not None] + 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 = ? @@ -738,20 +740,32 @@ def get_latency_history(target='gateway', timeframe='hour'): (interval, interval, since, target) ) rows = cursor.fetchall() - data = [{"timestamp": r[0], "value": r[1], "packet_loss": r[2]} for r in rows if r[1] is not None] + 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 using the averaged values shown in the graph + # Compute stats if data: values = [d["value"] for d in data if d["value"] is not None] if values: - stats = { - "min": round(min(values), 1), - "max": round(max(values), 1), - "avg": round(sum(values) / len(values), 1), - "current": values[-1] if values else 0 - } + # For gateway, use min/max of the averages (values) so stats match the graph + # For other targets (realtime), use actual min/max from individual pings + if target == 'gateway': + stats = { + "min": round(min(values), 1), + "max": round(max(values), 1), + "avg": round(sum(values) / len(values), 1), + "current": values[-1] if values else 0 + } + else: + 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: @@ -761,69 +775,27 @@ def get_latency_history(target='gateway', timeframe='hour'): except Exception as e: return {"data": [], "stats": {"min": 0, "max": 0, "avg": 0, "current": 0}, "target": target} -def get_latest_gateway_latency(): - """Get the most recent gateway latency from the database (no new ping). - Used by health monitor to avoid duplicate pings.""" - try: - conn = _get_temp_db() - cursor = conn.execute( - """SELECT timestamp, latency_avg, packet_loss FROM latency_history - WHERE target = 'gateway' ORDER BY timestamp DESC LIMIT 1""" - ) - row = cursor.fetchone() - conn.close() - - if row: - timestamp, latency_avg, packet_loss = row - age = int(time.time()) - timestamp - # Only return if data is fresh (less than 2 minutes old) - if age <= 120: - return { - 'latency_avg': latency_avg, - 'packet_loss': packet_loss, - 'timestamp': timestamp, - 'age_seconds': age, - 'fresh': True - } - return {'fresh': False, 'latency_avg': None} - except Exception: - return {'fresh': False, 'latency_avg': None} - def get_current_latency(target='gateway'): - """Get the most recent latency measurement for a target. - For gateway: reads from database (already monitored every 60s). - For cloudflare/google: does a fresh ping (on-demand only).""" + """Get the most recent latency measurement for a target.""" try: - # Gateway uses stored data to avoid duplicate pings + # If gateway, resolve to actual IP if target == 'gateway': target_ip = _get_default_gateway() - stored = get_latest_gateway_latency() - if stored.get('fresh'): - return { - 'target': target, - 'target_ip': target_ip, - 'latency_avg': stored['latency_avg'], - 'packet_loss': stored['packet_loss'], - 'status': 'ok' if stored['latency_avg'] and stored['latency_avg'] < 100 else 'warning' - } - # Fallback: do fresh measurement if no stored data - stats = _measure_latency(target_ip) else: - # Cloudflare/Google: fresh ping (not continuously monitored) - 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.get('min'), - 'latency_max': stats.get('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, 'latency_min': None, 'latency_max': None, 'status': 'error'} + 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(): diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 93baab71..07798eb5 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -1966,12 +1966,11 @@ class HealthMonitor: if latency_status: latency_ms = latency_status.get('latency_ms', 'N/A') latency_sev = latency_status.get('status', 'OK') - interface_details['connectivity'] = latency_status - target_display = latency_status.get('target', 'gateway') - connectivity_check = { - 'status': latency_sev if latency_sev not in ['UNKNOWN'] else 'OK', - 'detail': f'Latency {latency_ms}ms to {target_display}' if isinstance(latency_ms, (int, float)) else latency_status.get('reason', 'Unknown'), - } + interface_details['connectivity'] = latency_status + connectivity_check = { + 'status': latency_sev if latency_sev not in ['UNKNOWN'] else 'OK', + 'detail': f'Latency {latency_ms}ms to gateway' if isinstance(latency_ms, (int, float)) else latency_status.get('reason', 'Unknown'), + } if latency_sev not in ['OK', 'INFO', 'UNKNOWN']: issues.append(latency_status.get('reason', 'Network latency issue')) else: @@ -2007,48 +2006,74 @@ 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 using the gateway latency already monitored. + """Check network latency by reading from the gateway latency monitor database. - Uses the latency data from flask_server's continuous monitoring (every 60s) - instead of doing a separate ping to avoid duplicate network checks. - The gateway latency is based on the average of 3 pings to avoid false positives. + Reads the most recent gateway latency measurement from the SQLite database + that is updated every 60 seconds by the latency monitor thread. + This avoids redundant ping operations and uses the existing monitoring data. """ cache_key = 'network_latency' current_time = time.time() - # Use shorter cache since we're reading from DB (no network overhead) if cache_key in self.last_check_times: - if current_time - self.last_check_times[cache_key] < 30: + if current_time - self.last_check_times[cache_key] < 60: return self.cached_results.get(cache_key) try: - # Import and use the stored gateway latency from flask_server - import sys - import os - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from flask_server import get_latest_gateway_latency, _get_default_gateway + import sqlite3 + db_path = "/usr/local/share/proxmenux/monitor.db" - stored = get_latest_gateway_latency() - gateway_ip = _get_default_gateway() or 'gateway' + # Check if database exists + if not os.path.exists(db_path): + return {'status': 'UNKNOWN', 'reason': 'Latency monitor database not available'} - if stored.get('fresh') and stored.get('latency_avg') is not None: - avg_latency = stored['latency_avg'] - packet_loss = stored.get('packet_loss', 0) + conn = sqlite3.connect(db_path, timeout=5) + cursor = conn.execute( + """SELECT latency_avg, latency_min, latency_max, packet_loss, timestamp + FROM latency_history + WHERE target = 'gateway' + ORDER BY timestamp DESC + LIMIT 1""" + ) + row = cursor.fetchone() + conn.close() + + if row and row[0] is not None: + avg_latency = row[0] + min_latency = row[1] + max_latency = row[2] + packet_loss = row[3] or 0 + data_age = current_time - row[4] + + # If data is older than 2 minutes, consider it stale + if data_age > 120: + stale_result = { + 'status': 'UNKNOWN', + 'reason': 'Latency data is stale (>2 min old)' + } + self.cached_results[cache_key] = stale_result + self.last_check_times[cache_key] = current_time + return stale_result # Check for packet loss first - if packet_loss is not None and packet_loss >= 50: + if packet_loss >= 100: + loss_result = { + 'status': 'CRITICAL', + 'reason': 'Packet loss to gateway (100% loss)', + 'latency_ms': None, + 'packet_loss': packet_loss + } + self.cached_results[cache_key] = loss_result + self.last_check_times[cache_key] = current_time + return loss_result + + # Evaluate latency thresholds + if avg_latency > self.NETWORK_LATENCY_CRITICAL: status = 'CRITICAL' - reason = f'High packet loss ({packet_loss:.0f}%) to gateway' - elif packet_loss is not None and packet_loss > 0: - status = 'WARNING' - reason = f'Packet loss ({packet_loss:.0f}%) to gateway' - # Check latency thresholds - elif avg_latency > self.NETWORK_LATENCY_CRITICAL: - status = 'CRITICAL' - reason = f'Latency {avg_latency:.1f}ms >{self.NETWORK_LATENCY_CRITICAL}ms to gateway' + reason = f'Latency {avg_latency:.1f}ms to gateway >{self.NETWORK_LATENCY_CRITICAL}ms' elif avg_latency > self.NETWORK_LATENCY_WARNING: status = 'WARNING' - reason = f'Latency {avg_latency:.1f}ms >{self.NETWORK_LATENCY_WARNING}ms to gateway' + reason = f'Latency {avg_latency:.1f}ms to gateway >{self.NETWORK_LATENCY_WARNING}ms' else: status = 'OK' reason = None @@ -2056,7 +2081,9 @@ class HealthMonitor: latency_result = { 'status': status, 'latency_ms': round(avg_latency, 1), - 'target': gateway_ip, + 'latency_min': round(min_latency, 1) if min_latency else None, + 'latency_max': round(max_latency, 1) if max_latency else None, + 'packet_loss': packet_loss, } if reason: latency_result['reason'] = reason @@ -2065,11 +2092,17 @@ class HealthMonitor: self.last_check_times[cache_key] = current_time return latency_result - # No fresh data available - return unknown (monitoring may not be running) - return {'status': 'UNKNOWN', 'reason': 'No recent latency data available'} + # No data in database yet + no_data_result = { + 'status': 'UNKNOWN', + 'reason': 'No gateway latency data available yet' + } + self.cached_results[cache_key] = no_data_result + self.last_check_times[cache_key] = current_time + return no_data_result except Exception as e: - return {'status': 'UNKNOWN', 'reason': f'Latency check unavailable: {str(e)}'} + return {'status': 'UNKNOWN', 'reason': f'Latency check failed: {str(e)}'} def _is_vzdump_active(self) -> bool: """Check if a vzdump (backup) job is currently running."""