"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" import { useT } from "../lib/i18n/provider" type TFunction = (key: string, params?: Record) => string const TIMEFRAME_OPTIONS = [ { value: "hour", labelKey: "network.latency.timeframes.hour" }, { value: "6hour", labelKey: "network.latency.timeframes.sixHours" }, { value: "day", labelKey: "network.latency.timeframes.day" }, { value: "3day", labelKey: "network.latency.timeframes.threeDays" }, { value: "week", labelKey: "network.latency.timeframes.week" }, ] const TARGET_OPTIONS = [ { value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false }, { value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true }, { value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", 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 getLatencyTimeframeLabel = (value: string, t: TFunction): string => TIMEFRAME_OPTIONS.find((option) => option.value === value) ? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey) : value const getLatencyTargetLabel = (value: string, t: TFunction): string => TARGET_OPTIONS.find((option) => option.value === value) ? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey) : value const getLatencyTargetShortLabel = (value: string, t: TFunction): string => TARGET_OPTIONS.find((option) => option.value === value) ? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey) : value const CustomTooltip = ({ active, payload, label, t }: 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 (

{label}

{hasMinMax ? ( // Show min/avg/max when downsampled data is available <>
{t("network.labels.min")}: {data.min} ms
{t("network.labels.avg")}: {data.value} ms
{t("network.labels.max")}: {data.max} ms
) : ( // Simple latency display for single data points
{t("network.labels.latency")}: {entry.value} ms
)} {packetLoss !== undefined && packetLoss > 0 && (
{t("network.labels.packetLossShort")}: {packetLoss}%
)}
) } return null } const getStatusColor = (latency: number) => { if (latency >= 200) return "#ef4444" if (latency >= 100) return "#f59e0b" return "#22c55e" } const getStatusInfo = (latency: number | null, t: TFunction) => { if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" } if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" } if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" } } const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => { if (latency === null || latency === 0) return "na" if (latency < 50) return "excellent" if (latency < 100) return "good" if (latency < 200) return "fair" return "poor" } const getStatusText = (latency: number | null, t: TFunction): string => { const key = getStatusKey(latency) return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`) } const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => { if (!seconds || seconds <= 0) { return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod") } if (seconds < 60) { return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds }) } const minutes = Math.max(1, Math.round(seconds / 60)) return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes }) } interface ReportData { target: string targetLabel: string isRealtime: boolean stats: LatencyStats realtimeResults: RealtimeResult[] data: LatencyHistoryPoint[] timeframe: string testDuration?: number } const generateLatencyReport = (report: ReportData, t: TFunction) => { const now = new Date().toLocaleString() const logoUrl = `${window.location.origin}/images/proxmenux-logo.png` const htmlLang = document.documentElement.lang || "en" // 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, t) : getStatusText(report.stats.current, t) // Colors matching Lynis report const statusColorMap: Record = { excellent: "#16a34a", good: "#16a34a", fair: "#ca8a04", poor: "#dc2626", na: "#64748b", } const statusKey = report.isRealtime ? getStatusKey(realtimeStats?.current ?? null) : getStatusKey(report.stats.current) const statusColor = statusColorMap[statusKey] || "#64748b" const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t) const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}` const notAvailable = t("common.notAvailable") const modeLabel = report.isRealtime ? t("network.latency.report.realTimeTest") : t("network.latency.report.historicalAnalysis") const realtimeDurationText = formatReportDuration(report.testDuration, t) const realtimePacketLossText = realtimeStats && realtimeStats.avgPacketLoss > 0 ? `${t("network.latency.report.averagePacketLoss", { value: realtimeStats.avgPacketLoss.toFixed(1), })}` : `${t("network.latency.report.noPacketLoss")}` const testPeriodValue = report.isRealtime ? formatReportDuration(report.testDuration, t, true) : timeframeLabel const targetIpLabel = report.target === "gateway" ? t("network.latency.report.defaultGateway") : report.target === "cloudflare" ? "1.1.1.1" : "8.8.8.8" const detailSectionNumber = (report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? "6" : "5" // Build test results table for realtime mode - each row is now an individual ping measurement const realtimeTableRows = report.realtimeResults.map((r, i) => ` 0 ? ' class="warn"' : ''}> ${i + 1} ${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} ${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")} 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}% ${getStatusText(r.latency_avg, t)} `).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) => ` 0 ? ' class="warn"' : ''}> ${i + 1} ${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} ${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")} 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}% ${getStatusText(d.value, t)} `).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 = `

${t("network.latency.report.notEnoughData")}

` 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 = ` ${Math.round(maxVal)}ms ${Math.round((minVal + maxVal) / 2)}ms ${Math.round(minVal)}ms ${t("network.latency.report.samples", { count: chartData.length })} ` } const html = ` ${t("network.latency.report.title")} - ${report.targetLabel}
${t("network.latency.report.topBarTitle")}
${t("network.latency.report.topBarSubtitle")}
ProxMenux

${t("network.latency.report.title")}

${t("network.latency.report.subtitle")}

${t("network.latency.report.date")}: ${now}
${t("network.latency.report.target")}: ${report.targetLabel}
${t("network.latency.report.mode")}: ${modeLabel}
ID: ${reportId}
1. ${t("network.latency.report.executiveSummary")}
0 300+
${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg} ms
${statusText}

${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}

${report.isRealtime ? `${t("network.latency.report.realtimeSummary", { target: `${report.targetLabel}`, count: `${report.realtimeResults.length}`, duration: realtimeDurationText, avg: `${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms`, })} ${realtimePacketLossText}` : `${t("network.latency.report.historicalSummary", { target: t("network.latency.targets.gatewayShort"), timeframe: timeframeLabel.toLowerCase(), count: report.data.length, avg: report.stats.avg, })}` }

${t("network.labels.minimum")} ${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms
${t("network.labels.average")} ${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms
${t("network.labels.maximum")} ${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms
2. ${t("network.latency.report.latencyStatistics")}
${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current} ms
${t("network.labels.current")}
${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms
${t("network.labels.minimum")}
${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms
${t("network.labels.average")}
${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms
${t("network.labels.maximum")}
${t("network.latency.report.sampleCount")}
${report.isRealtime ? report.realtimeResults.length : report.data.length}
${t("network.latency.report.packetLossAvg")}
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
${t("network.latency.report.testPeriodLabel")}
${testPeriodValue}
3. ${t("network.latency.report.latencyGraph")}
${chartSvg}
4. ${t("network.latency.report.performanceThresholds")}

${t("network.latency.status.excellent")} (< 50ms): ${t("network.latency.report.thresholdExcellent")}

${t("network.latency.status.good")} (50-100ms): ${t("network.latency.report.thresholdGood")}

${t("network.latency.status.fair")} (100-200ms): ${t("network.latency.report.thresholdFair")}

${t("network.latency.status.poor")} (> 200ms): ${t("network.latency.report.thresholdPoor")}

${report.isRealtime && report.realtimeResults.length > 0 ? `
5. ${t("network.latency.report.detailedTestResults")}
${realtimeTableRows}
# ${t("network.labels.time")} ${t("network.labels.latency")} ${t("network.labels.packetLoss")} ${t("network.labels.status")}
` : ''} ${!report.isRealtime && report.data.length > 0 ? `
5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}
${historyTableRows}
# ${t("network.labels.time")} ${t("network.labels.latency")} ${t("network.labels.packetLoss")} ${t("network.labels.status")}
` : ''}
${detailSectionNumber}. ${t("network.latency.report.methodology")}
${t("network.latency.report.testMethod")}
${t("network.latency.report.icmpEchoRequest")}
${t("network.latency.report.samplesPerTest")}
${t("network.latency.report.threeConsecutivePings")}
${t("network.latency.report.target")}
${report.targetLabel}
${t("network.latency.report.targetIp")}
${targetIpLabel}

${t("network.latency.report.performanceAssessment")}

${ statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") : statusKey === 'good' ? t("network.latency.report.assessmentGood") : statusKey === 'fair' ? t("network.latency.report.assessmentFair") : statusKey === 'poor' ? t("network.latency.report.assessmentPoor") : t("network.latency.report.assessmentUnknown") }

` // 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 t = useT() const [timeframe, setTimeframe] = useState("hour") const [target, setTarget] = useState("gateway") const [data, setData] = useState([]) const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 }) const [loading, setLoading] = useState(true) const [realtimeResults, setRealtimeResults] = useState([]) const [realtimeTesting, setRealtimeTesting] = useState(false) const [testProgress, setTestProgress] = useState(0) // 0-100 percentage const [testStartTime, setTestStartTime] = useState(null) const testIntervalRef = useRef(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(`/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, t) // 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 ( {t("network.cards.latency")}
{!isRealtime && ( )} {isRealtime && ( realtimeTesting ? ( ) : ( ) )}
{/* Progress bar for realtime test */} {isRealtime && realtimeTesting && (
{t("network.latency.testingProgress", { percent: Math.round(testProgress) })} {t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}
)} {/* Stats Cards - Compact single row */}
{t("network.labels.current")} {displayStats.current || '-'} ms
{t("network.labels.min")} {displayStats.min || '-'} ms
{t("network.labels.avg")} {displayStats.avg || '-'} ms
{t("network.labels.max")} {displayStats.max || '-'} ms
{/* Status Badge */}
{statusInfo.status} {isRealtime && ( {t("network.latency.samplesCollected", { count: realtimeResults.length })} {realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""} )}
{/* Chart */}
{isRealtime ? ( realtimeChartData.length > 0 ? ( `${Number(v).toFixed(1)}ms`} /> } /> ) : (

{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}

) ) : loading ? (
) : chartData.length > 0 ? ( `${Number(v).toFixed(1)}ms`} /> } /> {/* For longer timeframes (6h+), show max values to preserve spikes. For 1 hour view, show avg values since there's no downsampling */} ) : (

{t("network.latency.noDataForPeriod")}

{t("network.latency.collectionInterval")}

)}
{/* Info for realtime mode */} {isRealtime && (

{t("network.latency.realTimeMode")}: {t("network.latency.realTimeModeDescription")}

)}
) }