2026-03-06 18:44:27 +01:00
"use client"
2026-03-06 20:02:49 +01:00
import { useState , useEffect , useCallback , useRef } from "react"
2026-03-06 18:44:27 +01:00
import { Dialog , DialogContent , DialogHeader , DialogTitle } from "./ui/dialog"
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "./ui/select"
2026-03-06 19:32:10 +01:00
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
2026-03-06 20:02:49 +01:00
import { Activity , TrendingDown , TrendingUp , Minus , RefreshCw , Wifi , FileText , Square } from "lucide-react"
2026-03-06 19:32:10 +01:00
import { AreaChart , Area , XAxis , YAxis , CartesianGrid , Tooltip , ResponsiveContainer , LineChart , Line } from "recharts"
2026-03-06 18:44:27 +01:00
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 = [
2026-03-07 17:57:57 +01:00
{ 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 },
]
2026-03-06 18:44:27 +01:00
2026-03-06 20:02:49 +01:00
// Realtime test configuration
const REALTIME_TEST_DURATION = 120 // 2 minutes in seconds
const REALTIME_TEST_INTERVAL = 5 // 5 seconds between tests
2026-03-06 18:44:27 +01:00
interface LatencyHistoryPoint {
timestamp : number
value : number
min? : number
max? : number
packet_loss? : number
}
interface LatencyStats {
min : number
max : number
avg : number
current : number
}
2026-03-06 19:32:10 +01:00
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
}
2026-03-06 18:44:27 +01:00
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 ]
2026-03-16 18:19:19 +01:00
const data = entry ? . payload
const packetLoss = data ? . packet_loss
const hasMinMax = data ? . min !== undefined && data ? . max !== undefined && data ? . min !== data ? . max
2026-03-06 18:44:27 +01:00
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" >
2026-03-16 18:19:19 +01:00
{ 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 >
)}
2026-03-06 18:44:27 +01:00
{ packetLoss !== undefined && packetLoss > 0 && (
< div className = "flex items-center gap-2" >
2026-03-16 18:19:19 +01:00
< div className = "w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
2026-03-06 18:44:27 +01:00
< span className = "text-xs text-gray-300 min-w-[60px]" > Pkt Loss : </ span >
2026-03-16 18:19:19 +01:00
< span className = "text-sm font-semibold text-orange-400" >{ packetLoss } % </ span >
2026-03-06 18:44:27 +01:00
</ div >
)}
</ div >
</ div >
)
}
return null
}
const getStatusColor = ( latency : number ) => {
if ( latency >= 200 ) return "#ef4444"
if ( latency >= 100 ) return "#f59e0b"
return "#22c55e"
}
2026-03-06 19:32:10 +01:00
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" }
2026-03-06 18:44:27 +01:00
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" }
}
2026-03-06 19:32:10 +01:00
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
2026-03-06 20:02:49 +01:00
testDuration? : number
2026-03-06 19:32:10 +01:00
}
const generateLatencyReport = ( report : ReportData ) => {
const now = new Date (). toLocaleString ()
2026-03-06 20:02:49 +01:00
const logoUrl = ` ${ window . location . origin } /images/proxmenux-logo.png`
2026-03-08 16:33:52 +01:00
// 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 ,
2026-03-08 10:15:55 +01:00
avgPacketLoss : report.realtimeResults.reduce (( acc , r ) => acc + ( r . packet_loss || 0 ), 0 ) / report . realtimeResults . length ,
} : null
2026-03-06 20:02:49 +01:00
2026-03-06 19:32:10 +01:00
const statusText = report . isRealtime
2026-03-06 20:02:49 +01:00
? getStatusText ( realtimeStats ? . current ?? null )
2026-03-06 19:32:10 +01:00
: getStatusText ( report . stats . current )
2026-03-06 20:02:49 +01:00
// Colors matching Lynis report
2026-03-06 19:32:10 +01:00
const statusColorMap : Record < string , string > = {
2026-03-06 20:02:49 +01:00
"Excellent" : "#16a34a" ,
"Good" : "#16a34a" ,
"Fair" : "#ca8a04" ,
"Poor" : "#dc2626" ,
"N/A" : "#64748b"
2026-03-06 19:32:10 +01:00
}
2026-03-06 20:02:49 +01:00
const statusColor = statusColorMap [ statusText ] || "#64748b"
2026-03-06 19:32:10 +01:00
const timeframeLabel = TIMEFRAME_OPTIONS . find ( t => t . value === report . timeframe ) ? . label || report . timeframe
2026-03-08 16:33:52 +01:00
// Build test results table for realtime mode - each row is now an individual ping measurement
2026-03-06 19:32:10 +01:00
const realtimeTableRows = report . realtimeResults . map (( r , i ) => `
2026-03-06 20:02:49 +01:00
<tr ${ r . packet_loss > 0 ? ' class="warn"' : '' } >
2026-03-06 19:32:10 +01:00
<td> ${ i + 1 } </td>
2026-03-08 16:33:52 +01:00
<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>
2026-03-06 20:02:49 +01:00
<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>
2026-03-06 19:32:10 +01:00
</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
2026-03-08 18:15:36 +01:00
// Build history table rows for gateway mode (last 20 records)
const historyTableRows = report . data . slice ( - 20 ). map (( d , i ) => `
2026-03-08 16:33:52 +01:00
<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
2026-03-07 19:27:31 +01:00
const chartData = report . isRealtime
2026-03-08 16:33:52 +01:00
? report . realtimeResults . filter ( r => r . latency_avg !== null ). map ( r => r . latency_avg ! )
2026-03-06 20:34:59 +01:00
: 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 ) {
2026-03-06 20:54:40 +01:00
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
2026-03-06 20:34:59 +01:00
const width = 700
const height = 120
2026-03-06 20:54:40 +01:00
const padding = 40
const chartHeight = height - padding * 2
const chartWidth = width - padding * 2
2026-03-06 20:34:59 +01:00
const points = chartData . map (( val , i ) => {
2026-03-06 20:54:40 +01:00
const x = padding + ( i / ( chartData . length - 1 )) * chartWidth
const y = padding + chartHeight - (( val - minVal ) / range ) * chartHeight
2026-03-06 20:34:59 +01:00
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">
2026-03-07 22:55:42 +01:00
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.05"/>
2026-03-06 20:34:59 +01:00
</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"/>
2026-03-06 20:54:40 +01:00
<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>
2026-03-06 20:34:59 +01:00
<polygon points=" ${ areaPoints } " fill="url(#areaGrad)"/>
2026-03-07 22:55:42 +01:00
<polyline points=" ${ points } " fill="none" stroke="#3b82f6" stroke-width="2"/>
2026-03-06 20:34:59 +01:00
<text x=" ${ width / 2 } " y=" ${ height - 5 } " font-size="9" fill="#64748b" text-anchor="middle"> ${ chartData . length } samples</text>
</svg>
`
}
2026-03-07 21:17:13 +01:00
const html = `<!DOCTYPE html>
2026-03-06 19:32:10 +01:00
<html lang="en">
<head>
2026-03-06 20:02:49 +01:00
<meta charset="UTF-8">
2026-03-07 21:17:13 +01:00
<meta name="viewport" content="width=device-width, initial-scale=1">
2026-03-06 20:02:49 +01:00
<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; }
2026-03-07 18:44:51 +01:00
@page { margin: 10mm; size: A4; }
2026-03-06 20:02:49 +01:00
@media print {
2026-03-07 18:44:51 +01:00
html, body { margin: 0 !important; padding: 0 !important; }
2026-03-07 21:17:13 +01:00
.no-print { display: none !important; }
2026-03-06 20:02:49 +01:00
.page-break { page-break-before: always; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
2026-03-07 21:17:13 +01:00
body { font-size: 11px; padding-top: 0; }
2026-03-06 20:02:49 +01:00
.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 {
2026-03-07 21:17:13 +01:00
body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; }
2026-03-06 20:02:49 +01:00
}
2026-03-07 21:17:13 +01:00
/* 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; } }
2026-03-06 20:02:49 +01:00
/* Header */
.rpt-header {
display: flex; align-items: center; justify-content: space-between;
2026-03-07 16:23:11 +01:00
padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px;
2026-03-06 20:02:49 +01:00
}
2026-03-07 16:23:11 +01:00
.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; }
2026-03-06 20:02:49 +01:00
/* Sections */
2026-03-07 16:23:11 +01:00
.section { margin-bottom: 22px; }
2026-03-06 20:02:49 +01:00
.section-title {
2026-03-07 16:23:11 +01:00
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;
2026-03-06 20:02:49 +01:00
}
/* Executive summary */
.exec-box {
2026-03-07 21:17:13 +01:00
display: flex; align-items: flex-start; gap: 20px; padding: 20px;
2026-03-07 16:23:11 +01:00
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px;
2026-03-07 21:17:13 +01:00
flex-wrap: wrap;
2026-03-06 20:02:49 +01:00
}
.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;
}
2026-03-07 16:23:11 +01:00
.score-num { font-size: 32px; font-weight: 800; line-height: 1; }
.score-unit { font-size: 14px; font-weight: 600; opacity: 0.8; }
2026-03-06 20:02:49 +01:00
.score-lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-top: 2px; }
2026-03-07 21:17:13 +01:00
.exec-text { flex: 1; min-width: 200px; }
2026-03-06 20:02:49 +01:00
.exec-text h3 { font-size: 16px; margin-bottom: 4px; }
.exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; }
2026-03-07 21:58:57 +01:00
/* Latency gauge */
2026-03-06 20:34:59 +01:00
.latency-gauge {
2026-03-07 21:58:57 +01:00
display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 160px;
2026-03-06 20:34:59 +01:00
}
2026-03-07 21:58:57 +01:00
.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; }
2026-03-06 20:34:59 +01:00
/* Latency range display */
.latency-range {
2026-03-07 21:17:13 +01:00
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; }
2026-03-07 21:37:24 +01:00
2026-03-06 20:02:49 +01:00
/* 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;
}
2026-03-06 20:34:59 +01:00
/* 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; }
}
2026-03-07 22:14:47 +01:00
/* 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. */
2026-03-06 20:02:49 +01:00
</style>
2026-03-06 19:32:10 +01:00
</head>
<body>
2026-03-07 21:17:13 +01:00
<!-- 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>
2026-03-06 21:25:14 +01:00
</div>
2026-03-06 20:02:49 +01:00
</div>
2026-03-07 23:33:13 +01:00
<button onclick="window.print()">Print / Save as PDF</button>
2026-03-07 21:17:13 +01:00
</div>
2026-03-06 19:32:10 +01:00
2026-03-06 20:02:49 +01:00
<!-- 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>
2026-03-06 19:32:10 +01:00
</div>
</div>
2026-03-06 20:02:49 +01:00
<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">
2026-03-06 20:34:59 +01:00
<div class="latency-gauge">
2026-03-07 22:55:42 +01:00
<svg viewBox="0 0 120 90" width="160" height="120">
2026-03-07 21:58:57 +01:00
<!-- 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 -->
2026-03-07 22:55:42 +01:00
<text x="8" y="87" font-size="7" fill="#64748b">0</text>
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
2026-03-07 21:58:57 +01:00
</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>
2026-03-06 20:34:59 +01:00
</div>
2026-03-07 21:58:57 +01:00
<div class="gauge-status" style="color: ${ statusColor } ;"> ${ statusText } </div>
2026-03-06 20:02:49 +01:00
</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>
2026-03-06 20:34:59 +01:00
<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>
2026-03-06 20:02:49 +01:00
</div>
2026-03-06 20:34:59 +01:00
<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>
2026-03-06 20:02:49 +01:00
</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>
2026-03-07 22:36:20 +01:00
<!-- 3. Latency Graph (always section 3) -->
2026-03-06 20:02:49 +01:00
<div class="section">
2026-03-07 22:36:20 +01:00
<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 (< 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 (> 200ms):</strong> Significant latency. Investigation recommended.</p>
</div>
</div>
2026-03-08 16:33:52 +01:00
${ report . isRealtime && report . realtimeResults . length > 0 ? `
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
<div class="section">
2026-03-07 22:36:20 +01:00
<div class="section-title">5. Detailed Test Results</div>
2026-03-06 20:02:49 +01:00
<table class="chk-tbl">
2026-03-08 16:33:52 +01:00
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Latency</th>
<th>Packet Loss</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${ realtimeTableRows }
</tbody>
2026-03-06 20:02:49 +01:00
</table>
</div>
` : '' }
2026-03-08 16:33:52 +01:00
${ ! report . isRealtime && report . data . length > 0 ? `
<!-- 5. Detailed History (for Gateway) -->
2026-03-06 20:02:49 +01:00
<div class="section">
2026-03-08 18:15:36 +01:00
<div class="section-title">5. Latency History (Last ${ Math . min ( 20 , report . data . length ) } Records)</div>
2026-03-08 16:33:52 +01:00
<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>
2026-03-06 20:02:49 +01:00
<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>
2026-03-06 20:13:31 +01:00
<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>
2026-03-06 20:34:59 +01:00
<div class="card-value" style="font-size:12px;"> ${ report . targetLabel } </div>
2026-03-06 20:13:31 +01:00
</div>
<div class="card">
<div class="card-label">Target IP</div>
2026-03-06 20:34:59 +01:00
<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>
2026-03-06 20:13:31 +01:00
</div>
</div>
<div class="info-box">
<h4>Performance Assessment</h4>
2026-03-06 20:34:59 +01:00
<p> ${
2026-03-06 20:13:31 +01:00
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">
2026-03-07 22:36:20 +01:00
<div>ProxMenux Monitor - Network Performance Report</div>
2026-03-06 20:34:59 +01:00
<div>Generated: ${ now } | Report ID: PMXL- ${ Date . now (). toString ( 36 ). toUpperCase () } </div>
2026-03-06 20:13:31 +01:00
</div>
2026-03-06 19:32:10 +01:00
</body>
</html>`
2026-03-07 21:17:13 +01:00
// Use Blob URL for Safari-safe preview
const blob = new Blob ([ html ], { type : "text/html" })
const url = URL . createObjectURL ( blob )
window . open ( url , "_blank" )
2026-03-06 19:32:10 +01:00
}
2026-03-06 18:44:27 +01:00
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 )
2026-03-06 19:32:10 +01:00
const [ realtimeResults , setRealtimeResults ] = useState < RealtimeResult [] >([])
const [ realtimeTesting , setRealtimeTesting ] = useState ( false )
2026-03-06 20:02:49 +01:00
const [ testProgress , setTestProgress ] = useState ( 0 ) // 0-100 percentage
const [ testStartTime , setTestStartTime ] = useState < number | null >( null )
const testIntervalRef = useRef < NodeJS.Timeout | null >( null )
2026-03-06 18:44:27 +01:00
const isMobile = useIsMobile ()
2026-03-06 19:32:10 +01:00
const isRealtime = TARGET_OPTIONS . find ( t => t . value === target ) ? . realtime ?? false
2026-03-06 20:02:49 +01:00
// Cleanup on unmount or close
useEffect (() => {
if ( ! open ) {
stopRealtimeTest ()
}
return () => {
stopRealtimeTest ()
}
}, [ open ])
2026-03-06 19:32:10 +01:00
// Fetch history for gateway
2026-03-06 18:44:27 +01:00
useEffect (() => {
2026-03-06 19:32:10 +01:00
if ( open && target === "gateway" ) {
2026-03-06 18:44:27 +01:00
fetchHistory ()
}
}, [ open , timeframe , target ])
2026-03-06 20:02:49 +01:00
// Auto-start test when switching to realtime target
2026-03-06 19:32:10 +01:00
useEffect (() => {
if ( open && isRealtime ) {
2026-03-06 20:02:49 +01:00
// Clear previous results and start new test
2026-03-06 19:32:10 +01:00
setRealtimeResults ([])
2026-03-06 20:02:49 +01:00
startRealtimeTest ()
} else {
stopRealtimeTest ()
2026-03-06 19:32:10 +01:00
}
}, [ open , target ])
2026-03-06 18:44:27 +01:00
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 ) {
2026-03-06 19:32:10 +01:00
// Silently fail
2026-03-06 18:44:27 +01:00
} finally {
setLoading ( false )
}
}
2026-03-06 20:02:49 +01:00
const runSingleTest = useCallback ( async () => {
2026-03-06 19:32:10 +01:00
try {
const result = await fetchApi < RealtimeResult >( `/api/network/latency/current?target= ${ target } ` )
if ( result ) {
2026-03-08 16:33:52 +01:00
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 ])
2026-03-06 19:32:10 +01:00
}
} catch ( err ) {
// Silently fail
}
2026-03-06 20:02:49 +01:00
}, [ target ])
2026-03-06 19:32:10 +01:00
2026-03-06 20:02:49 +01:00
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
2026-03-06 18:44:27 +01:00
}
2026-03-06 20:02:49 +01:00
setRealtimeTesting ( false )
setTestProgress ( 100 )
}, [])
2026-03-06 18:44:27 +01:00
2026-03-06 20:02:49 +01:00
const restartRealtimeTest = useCallback (() => {
// Don't clear results - add to existing data
startRealtimeTest ()
}, [ startRealtimeTest ])
2026-03-06 19:32:10 +01:00
2026-03-06 20:02:49 +01:00
// Format chart data
const chartData = data . map ( point => ({
... point ,
time : new Date ( point . timestamp * 1000 ). toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' }),
2026-03-06 18:44:27 +01:00
}))
2026-03-08 16:33:52 +01:00
// 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
}))
2026-03-06 19:32:10 +01:00
2026-03-08 16:33:52 +01:00
// 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 ,
2026-03-08 10:15:55 +01:00
packetLoss : realtimeResults [ realtimeResults . length - 1 ] ? . packet_loss ?? 0 ,
} : null
2026-03-06 19:32:10 +01:00
2026-03-06 20:02:49 +01:00
const displayStats = isRealtime ? {
current : realtimeStats?.current ?? 0 ,
min : realtimeStats?.min ?? 0 ,
max : realtimeStats?.max ?? 0 ,
avg : Math.round (( realtimeStats ? . avg ?? 0 ) * 10 ) / 10 ,
} : stats
2026-03-06 18:44:27 +01:00
2026-03-06 20:02:49 +01:00
const statusInfo = getStatusInfo ( displayStats . current )
2026-03-06 18:44:27 +01:00
2026-03-06 20:13:31 +01:00
// 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
2026-03-06 19:32:10 +01:00
2026-03-06 18:44:27 +01:00
return (
< Dialog open = { open } onOpenChange = { onOpenChange }>
2026-03-06 20:02:49 +01:00
< DialogContent className = "max-w-4xl max-h-[90vh] overflow-y-auto bg-background border-border" >
2026-03-06 18:44:27 +01:00
< DialogHeader >
2026-03-06 21:25:14 +01:00
< DialogTitle className = "flex items-center gap-2 text-foreground" >
< Wifi className = "h-5 w-5 text-blue-500" />
Network Latency
</ DialogTitle >
</ DialogHeader >
2026-03-07 17:57:57 +01:00
< div className = "flex items-center gap-2 mt-1 flex-nowrap" >
2026-03-07 12:02:20 +01:00
< Select value = { target } onValueChange = { setTarget }>
2026-03-07 17:57:57 +01:00
< 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 >
2026-03-07 12:02:20 +01:00
</ 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 }>
2026-03-07 16:23:11 +01:00
< SelectTrigger className = "w-[100px] h-8 text-xs shrink-0" >
2026-03-06 21:25:14 +01:00
< SelectValue />
</ SelectTrigger >
< SelectContent >
2026-03-07 12:02:20 +01:00
{ TIMEFRAME_OPTIONS . map ( opt => (
2026-03-06 21:25:14 +01:00
< SelectItem key = { opt . value } value = { opt . value } className = "text-xs" >
{ opt . label }
</ SelectItem >
))}
</ SelectContent >
</ Select >
2026-03-07 12:02:20 +01:00
)}
{ isRealtime && (
realtimeTesting ? (
< Button
variant = "outline"
size = "sm"
onClick = { stopRealtimeTest }
2026-03-07 16:23:11 +01:00
className = "gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
2026-03-07 12:02:20 +01:00
>
< Square className = "h-3 w-3 fill-current" />
2026-03-07 16:23:11 +01:00
Stop
2026-03-07 12:02:20 +01:00
</ Button >
) : (
< Button
variant = "outline"
size = "sm"
onClick = { restartRealtimeTest }
2026-03-07 16:23:11 +01:00
className = "gap-1.5 shrink-0 h-8 px-3"
2026-03-07 12:02:20 +01:00
>
< RefreshCw className = "h-3 w-3" />
2026-03-07 16:23:11 +01:00
Test Again
2026-03-07 12:02:20 +01:00
</ Button >
)
)}
2026-03-06 21:25:14 +01:00
< 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 }
2026-03-07 16:23:11 +01:00
className = "gap-1.5 shrink-0 h-8 px-3"
2026-03-06 21:25:14 +01:00
>
2026-03-07 16:23:11 +01:00
< FileText className = "h-3.5 w-3.5" />
Report
2026-03-06 21:25:14 +01:00
</ Button >
</ div >
2026-03-06 18:44:27 +01:00
2026-03-06 20:02:49 +01:00
{ /* 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 >
2026-03-06 19:32:10 +01:00
</ div >
)}
2026-03-07 12:02:20 +01:00
{ /* 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 >
2026-03-06 18:44:27 +01:00
</ div >
2026-03-07 12:02:20 +01:00
< 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 >
2026-03-06 20:02:49 +01:00
</ div >
2026-03-07 12:02:20 +01:00
< 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 >
2026-03-06 20:02:49 +01:00
</ div >
2026-03-07 12:02:20 +01:00
< 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 >
2026-03-06 20:02:49 +01:00
</ 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 >
2026-03-06 19:32:10 +01:00
)}
2026-03-06 18:44:27 +01:00
</ div >
{ /* Chart */ }
2026-03-06 20:02:49 +01:00
< div className = "h-[250px] sm:h-[300px] w-full" >
2026-03-06 19:32:10 +01:00
{ isRealtime ? (
2026-03-06 20:02:49 +01:00
realtimeChartData . length > 0 ? (
2026-03-06 19:32:10 +01:00
< ResponsiveContainer width = "100%" height = "100%" >
2026-03-07 23:05:51 +01:00
< AreaChart data = { realtimeChartData }>
< defs >
< linearGradient id = "latencyRealtimeGradient" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
2026-03-07 23:18:25 +01:00
< stop offset = "0%" stopColor = "#3b82f6" stopOpacity = { 0.4 } />
< stop offset = "100%" stopColor = "#3b82f6" stopOpacity = { 0.05 } />
2026-03-07 23:05:51 +01:00
</ linearGradient >
</ defs >
2026-03-06 20:02:49 +01:00
< CartesianGrid strokeDasharray = "3 3" stroke = "#374151" opacity = { 0.3 } />
< XAxis
dataKey = "time"
stroke = "#6b7280"
fontSize = { 10 }
tickLine = { false }
2026-03-06 19:32:10 +01:00
interval = "preserveStartEnd"
/>
2026-03-06 20:02:49 +01:00
< YAxis
stroke = "#6b7280"
fontSize = { 10 }
tickLine = { false }
2026-03-07 23:47:24 +01:00
domain = {[ 'dataMin - 1' , 'dataMax + 2' ]}
tickFormatter = {( v ) => ` ${ Number ( v ). toFixed ( 1 ) } ms` }
2026-03-06 19:32:10 +01:00
/>
< Tooltip content = {< CustomTooltip />} />
2026-03-07 23:05:51 +01:00
< Area
2026-03-06 19:32:10 +01:00
type = "monotone"
dataKey = "value"
2026-03-06 20:02:49 +01:00
stroke = "#3b82f6"
2026-03-06 19:32:10 +01:00
strokeWidth = { 2 }
2026-03-07 23:05:51 +01:00
fill = "url(#latencyRealtimeGradient)"
2026-03-06 20:02:49 +01:00
dot = {{ fill : '#3b82f6' , strokeWidth : 0 , r : 3 }}
activeDot = {{ r : 5 , fill : '#3b82f6' }}
2026-03-07 23:18:25 +01:00
baseValue = "dataMin"
2026-03-06 19:32:10 +01:00
/>
2026-03-07 23:05:51 +01:00
</ AreaChart >
2026-03-06 19:32:10 +01:00
</ ResponsiveContainer >
) : (
2026-03-06 20:02:49 +01:00
< 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 >
2026-03-06 19:32:10 +01:00
)
2026-03-06 20:02:49 +01:00
) : 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" >
2026-03-07 23:18:25 +01:00
< stop offset = "0%" stopColor = "#3b82f6" stopOpacity = { 0.4 } />
< stop offset = "100%" stopColor = "#3b82f6" stopOpacity = { 0.05 } />
2026-03-06 20:02:49 +01:00
</ 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 }
2026-03-07 23:47:24 +01:00
domain = {[ 'dataMin - 1' , 'dataMax + 2' ]}
tickFormatter = {( v ) => ` ${ Number ( v ). toFixed ( 1 ) } ms` }
2026-03-06 20:02:49 +01:00
/>
< Tooltip content = {< CustomTooltip />} />
2026-03-16 18:19:19 +01:00
{ /* For longer timeframes (6h+), show max values to preserve spikes.
For 1 hour view, show avg values since there's no downsampling */ }
2026-03-06 20:02:49 +01:00
< Area
type = "monotone"
2026-03-16 18:19:19 +01:00
dataKey = { timeframe === "hour" ? "value" : "max" }
2026-03-06 20:02:49 +01:00
stroke = "#3b82f6"
strokeWidth = { 2 }
fill = "url(#latencyGradient)"
2026-03-07 23:18:25 +01:00
baseValue = "dataMin"
2026-03-06 20:02:49 +01:00
/>
</ 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 >
2026-03-06 18:44:27 +01:00
)}
</ div >
2026-03-06 19:32:10 +01:00
2026-03-06 20:02:49 +01:00
{ /* 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 >
2026-03-06 19:32:10 +01:00
</ div >
)}
2026-03-06 18:44:27 +01:00
</ DialogContent >
</ Dialog >
)
}