Files
ProxMenux/AppImage/components/latency-detail-modal.tsx
2026-03-16 18:19:19 +01:00

1145 lines
48 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, Square } 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"
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)", shortLabel: "Gateway", realtime: false },
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
]
// Realtime test configuration
const REALTIME_TEST_DURATION = 120 // 2 minutes in seconds
const REALTIME_TEST_INTERVAL = 5 // 5 seconds between tests
interface LatencyHistoryPoint {
timestamp: number
value: number
min?: number
max?: number
packet_loss?: number
}
interface LatencyStats {
min: number
max: number
avg: number
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
currentLatency?: number
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const entry = payload[0]
const data = entry?.payload
const packetLoss = data?.packet_loss
const hasMinMax = data?.min !== undefined && data?.max !== undefined && data?.min !== data?.max
return (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
<p className="text-sm font-semibold text-white mb-2">{label}</p>
<div className="space-y-1.5">
{hasMinMax ? (
// Show min/avg/max when downsampled data is available
<>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
<span className="text-sm font-semibold text-white">{data.value} ms</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
</div>
</>
) : (
// Simple latency display for single data points
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
</div>
)}
{packetLoss !== undefined && packetLoss > 0 && (
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
</div>
)}
</div>
</div>
)
}
return null
}
const getStatusColor = (latency: number) => {
if (latency >= 200) return "#ef4444"
if (latency >= 100) return "#f59e0b"
return "#22c55e"
}
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
testDuration?: number
}
const generateLatencyReport = (report: ReportData) => {
const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
const realtimeStats = validRealtimeValues.length > 0 ? {
min: Math.min(...validRealtimeValues),
max: Math.max(...validRealtimeValues),
avg: validRealtimeValues.reduce((acc, v) => acc + v, 0) / validRealtimeValues.length,
current: validRealtimeValues[validRealtimeValues.length - 1] ?? 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)
: getStatusText(report.stats.current)
// Colors matching Lynis report
const statusColorMap: Record<string, string> = {
"Excellent": "#16a34a",
"Good": "#16a34a",
"Fair": "#ca8a04",
"Poor": "#dc2626",
"N/A": "#64748b"
}
const statusColor = statusColorMap[statusText] || "#64748b"
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
// Build test results table for realtime mode - each row is now an individual ping measurement
const realtimeTableRows = report.realtimeResults.map((r, i) => `
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
</tr>
`).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
// Build history table rows for gateway mode (last 20 records)
const historyTableRows = report.data.slice(-20).map((d, i) => `
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
<td>${i + 1}</td>
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
</tr>
`).join('')
// Generate chart SVG - data already expanded for realtime
const chartData = report.isRealtime
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
: report.data.map(d => d.value || 0)
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
if (chartData.length >= 2) {
const rawMin = Math.min(...chartData)
const rawMax = Math.max(...chartData)
// Ensure a minimum range of 10ms or 20% of the average to avoid flat lines
const avgVal = chartData.reduce((a, b) => a + b, 0) / chartData.length
const minRange = Math.max(10, avgVal * 0.2)
const range = Math.max(rawMax - rawMin, minRange)
// Center the data if range was expanded
const midPoint = (rawMin + rawMax) / 2
const minVal = midPoint - range / 2
const maxVal = midPoint + range / 2
const width = 700
const height = 120
const padding = 40
const chartHeight = height - padding * 2
const chartWidth = width - padding * 2
const points = chartData.map((val, i) => {
const x = padding + (i / (chartData.length - 1)) * chartWidth
const y = padding + chartHeight - ((val - minVal) / range) * chartHeight
return `${x},${y}`
}).join(' ')
const areaPoints = `${padding},${height - padding} ${points} ${width - padding},${height - padding}`
chartSvg = `
<svg width="100%" viewBox="0 0 ${width} ${height}" style="display:block;">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.05"/>
</linearGradient>
</defs>
<line x1="${padding}" y1="${padding}" x2="${padding}" y2="${height - padding}" stroke="#e2e8f0" stroke-width="1"/>
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" stroke="#e2e8f0" stroke-width="1"/>
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="4"/>
<text x="${padding - 5}" y="${padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(maxVal)}ms</text>
<text x="${padding - 5}" y="${height / 2 + 3}" font-size="9" fill="#64748b" text-anchor="end">${Math.round((minVal + maxVal) / 2)}ms</text>
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
</svg>
`
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Network Latency Report - ${report.targetLabel}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@page { margin: 10mm; size: A4; }
@media print {
html, body { margin: 0 !important; padding: 0 !important; }
.no-print { display: none !important; }
.page-break { page-break-before: always; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { font-size: 11px; padding-top: 0; }
.section { margin-bottom: 16px; }
.rpt-header-left p, .rpt-header-right { color: #374151; }
.rpt-header-right .rid { color: #4b5563; }
.exec-text p { color: #374151; }
.score-bar-labels { color: #4b5563; }
.card-label { color: #4b5563; }
.card-sub { color: #374151; }
.chk-tbl th { color: #374151; }
.rpt-footer { color: #4b5563; }
}
@media screen {
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; }
}
/* Top bar for screen only */
.top-bar {
position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0;
padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100;
font-size: 13px;
}
.top-bar-left { display: flex; align-items: center; gap: 12px; }
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer;
}
.top-bar button:hover { background: #0891b2; }
@media (min-width: 640px) {
.top-bar { padding: 12px 24px; }
.top-bar-subtitle { display: block; }
}
@media print { .top-bar { display: none; } body { padding-top: 0; } }
/* Header */
.rpt-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px;
}
.rpt-header-left { display: flex; align-items: center; gap: 14px; }
.rpt-header-left img { height: 44px; width: auto; }
.rpt-header-left h1 { font-size: 22px; font-weight: 700; color: #0f172a; }
.rpt-header-left p { font-size: 11px; color: #64748b; }
.rpt-header-right { text-align: right; font-size: 11px; color: #64748b; line-height: 1.6; }
.rpt-header-right .rid { font-family: monospace; font-size: 10px; color: #94a3b8; }
/* Sections */
.section { margin-bottom: 22px; }
.section-title {
font-size: 14px; font-weight: 700; color: #0f172a; text-transform: uppercase;
letter-spacing: 0.05em; padding-bottom: 5px; border-bottom: 2px solid #e2e8f0; margin-bottom: 12px;
}
/* Executive summary */
.exec-box {
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
flex-wrap: wrap;
}
.score-ring {
width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: center; border: 4px solid; flex-shrink: 0;
}
.score-num { font-size: 32px; font-weight: 800; line-height: 1; }
.score-unit { font-size: 14px; font-weight: 600; opacity: 0.8; }
.score-lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-top: 2px; }
.exec-text { flex: 1; min-width: 200px; }
.exec-text h3 { font-size: 16px; margin-bottom: 4px; }
.exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; }
/* Latency gauge */
.latency-gauge {
display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 160px;
}
.gauge-value { display: flex; align-items: baseline; gap: 2px; margin-top: -10px; }
.gauge-num { font-size: 32px; font-weight: 800; line-height: 1; }
.gauge-unit { font-size: 14px; font-weight: 600; opacity: 0.8; }
.gauge-status { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-top: 2px; }
/* Latency range display */
.latency-range {
display: flex; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.range-item { display: flex; flex-direction: column; gap: 2px; min-width: 60px; }
.range-label { font-size: 9px; font-weight: 600; color: #94a3b8; text-transform: uppercase; }
.range-value { font-size: 14px; font-weight: 700; }
/* Grids */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.card-value { font-size: 13px; font-weight: 600; color: #0f172a; }
.card-c { text-align: center; }
.card-c .card-value { font-size: 20px; font-weight: 800; }
.card-c .card-label { margin-top: 3px; margin-bottom: 0; }
.card-sub { font-size: 9px; color: #64748b; margin-top: 2px; }
/* Tags */
.f-tag { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
/* Tables */
.chk-tbl { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 14px; }
.chk-tbl th { text-align: left; padding: 6px 8px; font-size: 10px; color: #64748b; font-weight: 600; border-bottom: 1px solid #e2e8f0; background: #f1f5f9; }
.chk-tbl td { padding: 5px 8px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.chk-tbl tr.warn { background: #fef2f2; }
/* Thresholds */
.threshold-item {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 6px;
}
.threshold-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.threshold-item p { font-size: 11px; color: #374151; }
.threshold-item strong { color: #0f172a; }
/* Info box */
.info-box {
background: #ecfeff; border: 1px solid #a5f3fc; border-left: 4px solid #06b6d4;
border-radius: 4px; padding: 12px 14px; margin-top: 12px;
}
.info-box h4 { font-size: 11px; font-weight: 700; color: #0891b2; margin-bottom: 4px; }
.info-box p { font-size: 11px; color: #0e7490; }
/* Footer */
.rpt-footer {
margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0;
display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8;
}
/* Print styles */
@media print {
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { padding: 10mm; font-size: 10pt; }
.no-print { display: none !important; }
.container { max-width: 100%; padding: 0; }
/* Prevent page breaks inside elements */
.section { page-break-inside: avoid; break-inside: avoid; }
.exec-box { page-break-inside: avoid; break-inside: avoid; }
.card { page-break-inside: avoid; break-inside: avoid; }
.threshold-item { page-break-inside: avoid; break-inside: avoid; }
.info-box { page-break-inside: avoid; break-inside: avoid; }
.chk-tbl { page-break-inside: avoid; break-inside: avoid; }
.latency-gauge { page-break-inside: avoid; break-inside: avoid; }
.latency-range { page-break-inside: avoid; break-inside: avoid; }
/* Force page breaks before major sections if needed */
.section { page-break-before: auto; }
/* Keep headers with their content */
.section-title { page-break-after: avoid; break-after: avoid; }
/* Ensure grids don't break awkwardly */
.grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; }
/* Table rows - try to keep together */
.chk-tbl tr { page-break-inside: avoid; break-inside: avoid; }
.chk-tbl thead { display: table-header-group; }
/* Footer always at bottom */
.rpt-footer {
page-break-inside: avoid; break-inside: avoid;
margin-top: 20px;
}
/* Reduce spacing for print */
.section { margin-bottom: 15px; }
.exec-box { padding: 12px; }
/* Ensure SVG charts print correctly */
svg { max-width: 100%; height: auto; }
}
/* NOTE: No mobile-specific print overrides — print layout is always A4/desktop
regardless of the device generating the PDF. The @media print block above
handles all necessary print adjustments. */
</style>
</head>
<body>
<!-- Top bar for screen -->
<div class="top-bar no-print">
<div class="top-bar-left">
<div>
<div class="top-bar-title">ProxMenux Network Latency Report</div>
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
</div>
</div>
<button onclick="window.print()">Print / Save as PDF</button>
</div>
<!-- Header -->
<div class="rpt-header">
<div class="rpt-header-left">
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
<div>
<h1>Network Latency Report</h1>
<p>ProxMenux Monitor - Network Performance Analysis</p>
</div>
</div>
<div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div>
<div><strong>Target:</strong> ${report.targetLabel}</div>
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
</div>
</div>
<!-- 1. Executive Summary -->
<div class="section">
<div class="section-title">1. Executive Summary</div>
<div class="exec-box">
<div class="latency-gauge">
<svg viewBox="0 0 120 90" width="160" height="120">
<!-- Gauge background arc -->
<path d="M 10 70 A 50 50 0 0 1 110 70" fill="none" stroke="#e2e8f0" stroke-width="8" stroke-linecap="round"/>
<!-- Colored segments: Excellent (green), Good (green), Fair (yellow), Poor (red) -->
<path d="M 10 70 A 50 50 0 0 1 35 28" fill="none" stroke="#16a34a" stroke-width="8" stroke-linecap="round"/>
<path d="M 35 28 A 50 50 0 0 1 60 20" fill="none" stroke="#22c55e" stroke-width="8"/>
<path d="M 60 20 A 50 50 0 0 1 85 28" fill="none" stroke="#ca8a04" stroke-width="8"/>
<path d="M 85 28 A 50 50 0 0 1 110 70" fill="none" stroke="#dc2626" stroke-width="8" stroke-linecap="round"/>
<!-- Needle -->
<line x1="60" y1="70" x2="${60 + 40 * Math.cos(Math.PI - (Math.min(300, report.isRealtime ? (realtimeStats?.avg ?? 0) : parseFloat(String(report.stats.avg))) / 300) * Math.PI)}" y2="${70 - 40 * Math.sin(Math.PI - (Math.min(300, report.isRealtime ? (realtimeStats?.avg ?? 0) : parseFloat(String(report.stats.avg))) / 300) * Math.PI)}" stroke="${statusColor}" stroke-width="3" stroke-linecap="round"/>
<circle cx="60" cy="70" r="6" fill="${statusColor}"/>
<!-- Labels -->
<text x="8" y="87" font-size="7" fill="#64748b">0</text>
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
</svg>
<div class="gauge-value" style="color:${statusColor};">
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
<span class="gauge-unit">ms</span>
</div>
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
</div>
<div class="exec-text">
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
<p>
${report.isRealtime
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
<strong>${report.data.length} samples</strong> analyzed.
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
}
</p>
<div class="latency-range">
<div class="range-item">
<span class="range-label">Minimum</span>
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
</div>
<div class="range-item">
<span class="range-label">Average</span>
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
</div>
<div class="range-item">
<span class="range-label">Maximum</span>
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Statistics -->
<div class="section">
<div class="section-title">2. Latency Statistics</div>
<div class="grid-4">
<div class="card card-c">
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Current</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Minimum</div>
</div>
<div class="card card-c">
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Average</div>
</div>
<div class="card card-c">
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
<div class="card-label">Maximum</div>
</div>
</div>
<div class="grid-3">
<div class="card">
<div class="card-label">Sample Count</div>
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
</div>
<div class="card">
<div class="card-label">Packet Loss (Avg)</div>
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
</div>
</div>
<div class="card">
<div class="card-label">Test Period</div>
<div class="card-value" style="font-size:11px;">
${report.isRealtime
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
: timeframeLabel}
</div>
</div>
</div>
</div>
<!-- 3. Latency Graph (always section 3) -->
<div class="section">
<div class="section-title">3. Latency Graph</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
${chartSvg}
</div>
</div>
<!-- 4. Performance Thresholds (always section 4) -->
<div class="section">
<div class="section-title">4. Performance Thresholds</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Excellent (&lt; 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#16a34a;"></div>
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#ca8a04;"></div>
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
</div>
<div class="threshold-item">
<div class="threshold-dot" style="background:#dc2626;"></div>
<p><strong>Poor (&gt; 200ms):</strong> Significant latency. Investigation recommended.</p>
</div>
</div>
${report.isRealtime && report.realtimeResults.length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<div class="section">
<div class="section-title">5. Detailed Test Results</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${realtimeTableRows}
</tbody>
</table>
</div>
` : ''}
${!report.isRealtime && report.data.length > 0 ? `
<!-- 5. Detailed History (for Gateway) -->
<div class="section">
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
<table class="chk-tbl">
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${historyTableRows}
</tbody>
</table>
</div>
` : ''}
<!-- Methodology -->
<div class="section">
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
<div class="grid-2">
<div class="card">
<div class="card-label">Test Method</div>
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
</div>
<div class="card">
<div class="card-label">Samples per Test</div>
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
</div>
<div class="card">
<div class="card-label">Target</div>
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
</div>
<div class="card">
<div class="card-label">Target IP</div>
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
</div>
</div>
<div class="info-box">
<h4>Performance Assessment</h4>
<p>${
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.'
}</p>
</div>
</div>
<!-- Footer -->
<div class="rpt-footer">
<div>ProxMenux Monitor - Network Performance Report</div>
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
</div>
</body>
</html>`
// Use Blob URL for Safari-safe preview
const blob = new Blob([html], { type: "text/html" })
const url = URL.createObjectURL(blob)
window.open(url, "_blank")
}
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
const [timeframe, setTimeframe] = useState("hour")
const [target, setTarget] = useState("gateway")
const [data, setData] = useState<LatencyHistoryPoint[]>([])
const [stats, setStats] = useState<LatencyStats>({ min: 0, max: 0, avg: 0, current: 0 })
const [loading, setLoading] = useState(true)
const [realtimeResults, setRealtimeResults] = useState<RealtimeResult[]>([])
const [realtimeTesting, setRealtimeTesting] = useState(false)
const [testProgress, setTestProgress] = useState(0) // 0-100 percentage
const [testStartTime, setTestStartTime] = useState<number | null>(null)
const testIntervalRef = useRef<NodeJS.Timeout | null>(null)
const isMobile = useIsMobile()
const isRealtime = TARGET_OPTIONS.find(t => t.value === target)?.realtime ?? false
// Cleanup on unmount or close
useEffect(() => {
if (!open) {
stopRealtimeTest()
}
return () => {
stopRealtimeTest()
}
}, [open])
// Fetch history for gateway
useEffect(() => {
if (open && target === "gateway") {
fetchHistory()
}
}, [open, timeframe, target])
// Auto-start test when switching to realtime target
useEffect(() => {
if (open && isRealtime) {
// Clear previous results and start new test
setRealtimeResults([])
startRealtimeTest()
} else {
stopRealtimeTest()
}
}, [open, 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
} finally {
setLoading(false)
}
}
const runSingleTest = useCallback(async () => {
try {
const result = await fetchApi<RealtimeResult>(`/api/network/latency/current?target=${target}`)
if (result) {
const baseTime = Date.now()
// Expand each ping result into 3 individual samples (min, avg, max) with slightly different timestamps
// This ensures the graph shows all actual measured values, not just averages
const samples: RealtimeResult[] = []
if (result.latency_min !== null) {
samples.push({
...result,
latency_avg: result.latency_min,
timestamp: baseTime - 200, // Slightly earlier
})
}
if (result.latency_avg !== null && result.latency_avg !== result.latency_min && result.latency_avg !== result.latency_max) {
samples.push({
...result,
latency_avg: result.latency_avg,
timestamp: baseTime,
})
}
if (result.latency_max !== null) {
samples.push({
...result,
latency_avg: result.latency_max,
timestamp: baseTime + 200, // Slightly later
})
}
// Fallback if no valid samples
if (samples.length === 0 && result.latency_avg !== null) {
samples.push({ ...result, timestamp: baseTime })
}
setRealtimeResults(prev => [...prev, ...samples])
}
} catch (err) {
// Silently fail
}
}, [target])
const startRealtimeTest = useCallback(() => {
if (realtimeTesting) return
setRealtimeTesting(true)
setTestProgress(0)
setTestStartTime(Date.now())
// Run first test immediately
runSingleTest()
// Set up interval for subsequent tests
const totalTests = REALTIME_TEST_DURATION / REALTIME_TEST_INTERVAL
let testCount = 1
testIntervalRef.current = setInterval(() => {
testCount++
const progress = Math.min(100, (testCount / totalTests) * 100)
setTestProgress(progress)
runSingleTest()
// Stop after duration
if (testCount >= totalTests) {
stopRealtimeTest()
}
}, REALTIME_TEST_INTERVAL * 1000)
}, [realtimeTesting, runSingleTest])
const stopRealtimeTest = useCallback(() => {
if (testIntervalRef.current) {
clearInterval(testIntervalRef.current)
testIntervalRef.current = null
}
setRealtimeTesting(false)
setTestProgress(100)
}, [])
const restartRealtimeTest = useCallback(() => {
// Don't clear results - add to existing data
startRealtimeTest()
}, [startRealtimeTest])
// Format chart data
const chartData = data.map(point => ({
...point,
time: new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
}))
// Data already expanded to individual ping values - just format for chart
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,
packet_loss: r.packet_loss
}))
// Calculate realtime stats - all values are now individual ping measurements stored in latency_avg
const validValues = realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
const realtimeStats = validValues.length > 0 ? {
current: validValues[validValues.length - 1],
min: Math.min(...validValues),
max: Math.max(...validValues),
avg: validValues.reduce((acc, v) => acc + v, 0) / validValues.length,
packetLoss: realtimeResults[realtimeResults.length - 1]?.packet_loss ?? 0,
} : null
const displayStats = isRealtime ? {
current: realtimeStats?.current ?? 0,
min: realtimeStats?.min ?? 0,
max: realtimeStats?.max ?? 0,
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
} : stats
const statusInfo = getStatusInfo(displayStats.current)
// Calculate test duration for report based on first and last result timestamps
const testDuration = realtimeResults.length >= 2
? Math.round(((realtimeResults[realtimeResults.length - 1].timestamp || Date.now()) - (realtimeResults[0].timestamp || Date.now())) / 1000)
: realtimeResults.length === 1
? 5 // Single sample = 5 seconds (one test)
: 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-background border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Wifi className="h-5 w-5 text-blue-500" />
Network Latency
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 mt-1 flex-nowrap">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
<span className="truncate">
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
</span>
</SelectTrigger>
<SelectContent>
{TARGET_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isRealtime && (
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[100px] h-8 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isRealtime && (
realtimeTesting ? (
<Button
variant="outline"
size="sm"
onClick={stopRealtimeTest}
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
>
<Square className="h-3 w-3 fill-current" />
Stop
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={restartRealtimeTest}
className="gap-1.5 shrink-0 h-8 px-3"
>
<RefreshCw className="h-3 w-3" />
Test Again
</Button>
)
)}
<Button
variant="outline"
size="sm"
onClick={() => generateLatencyReport({
target,
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
isRealtime,
stats,
realtimeResults,
data,
timeframe,
testDuration: isRealtime ? testDuration : undefined,
})}
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
className="gap-1.5 shrink-0 h-8 px-3"
>
<FileText className="h-3.5 w-3.5" />
Report
</Button>
</div>
{/* Progress bar for realtime test */}
{isRealtime && realtimeTesting && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Testing... {Math.round(testProgress)}%</span>
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500 ease-out"
style={{ width: `${testProgress}%` }}
/>
</div>
</div>
)}
{/* Stats Cards - Compact single row */}
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[10px] text-muted-foreground">Current</span>
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
{displayStats.current || '-'}
</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Min</span>
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<Minus className="h-3 w-3 shrink-0" />
<span className="text-[10px] text-muted-foreground">Avg</span>
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
<div className="flex items-center gap-1 min-w-0">
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
<span className="text-[10px] text-muted-foreground">Max</span>
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
<span className="text-[10px] text-muted-foreground">ms</span>
</div>
</div>
{/* Status Badge */}
<div className="flex items-center justify-between mb-4">
<Badge variant="outline" className={statusInfo.color}>
{statusInfo.status}
</Badge>
{isRealtime && (
<span className="text-xs text-muted-foreground">
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
</span>
)}
</div>
{/* Chart */}
<div className="h-[250px] sm:h-[300px] w-full">
{isRealtime ? (
realtimeChartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={realtimeChartData}>
<defs>
<linearGradient id="latencyRealtimeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
<XAxis
dataKey="time"
stroke="#6b7280"
fontSize={10}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
stroke="#6b7280"
fontSize={10}
tickLine={false}
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={2}
fill="url(#latencyRealtimeGradient)"
dot={{ fill: '#3b82f6', strokeWidth: 0, r: 3 }}
activeDot={{ r: 5, fill: '#3b82f6' }}
baseValue="dataMin"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
</p>
</div>
)
) : loading ? (
<div className="h-full flex items-center justify-center">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
<XAxis
dataKey="time"
stroke="#6b7280"
fontSize={10}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
stroke="#6b7280"
fontSize={10}
tickLine={false}
domain={['dataMin - 1', 'dataMax + 2']}
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
/>
<Tooltip content={<CustomTooltip />} />
{/* For longer timeframes (6h+), show max values to preserve spikes.
For 1 hour view, show avg values since there's no downsampling */}
<Area
type="monotone"
dataKey={timeframe === "hour" ? "value" : "max"}
stroke="#3b82f6"
strokeWidth={2}
fill="url(#latencyGradient)"
baseValue="dataMin"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Activity className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">No latency data available for this period</p>
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
</div>
)}
</div>
{/* Info for realtime mode */}
{isRealtime && (
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400">
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
Click "Test Again" to add more samples. All data is included in the report.
</p>
</div>
)}
</DialogContent>
</Dialog>
)
}