diff --git a/components/har-waterfall/HarWaterfall.tsx b/components/har-waterfall/HarWaterfall.tsx new file mode 100644 index 0000000..e295585 --- /dev/null +++ b/components/har-waterfall/HarWaterfall.tsx @@ -0,0 +1,175 @@ +import React, { useRef, useState, useCallback, useMemo } from "react"; +import { HarEntry, FilterType, getFilterType } from "../utils/har-utils"; +import { WaterfallCanvas } from "./WaterfallCanvas"; +import { WaterfallTooltip } from "./WaterfallTooltip"; +import { WaterfallLegend } from "./WaterfallLegend"; +import { WaterfallRequestDetails } from "./WaterfallRequestDetails"; +import { WaterfallUrlTooltip } from "./WaterfallUrlTooltip"; +import { calculateTimings, WaterfallTiming } from "./waterfall-utils"; + +interface HarWaterfallProps { + entries: HarEntry[]; + activeFilter: FilterType; + className?: string; +} + +export const HarWaterfall: React.FC = ({ + entries, + activeFilter, + className = "", +}) => { + const containerRef = useRef(null); + const [hoveredEntry, setHoveredEntry] = useState<{ + entry: HarEntry; + timing: WaterfallTiming; + x: number; + y: number; + } | null>(null); + const [hoveredUrl, setHoveredUrl] = useState<{ + url: string; + x: number; + y: number; + } | null>(null); + const [selectedEntry, setSelectedEntry] = useState<{ + entry: HarEntry; + timing: WaterfallTiming; + } | null>(null); + const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 }); + + // Filter entries based on active filter + const filteredEntries = useMemo(() => { + if (activeFilter === "All") return entries; + return entries.filter((entry) => getFilterType(entry) === activeFilter); + }, [entries, activeFilter]); + + // Calculate timings for all entries + const timings = useMemo(() => { + return calculateTimings(filteredEntries); + }, [filteredEntries]); + + // Handle mouse move for hover detection + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top + scrollOffset.y; + + // Find which entry is being hovered (accounting for header) + const rowHeight = 30; + const headerHeight = 40; + const adjustedY = y - headerHeight; + const entryIndex = Math.floor(adjustedY / rowHeight); + + if (entryIndex >= 0 && entryIndex < filteredEntries.length) { + setHoveredEntry({ + entry: filteredEntries[entryIndex], + timing: timings[entryIndex], + x: event.clientX, + y: event.clientY, + }); + + // Check if hovering over URL area (left portion) + if (x < 300) { + setHoveredUrl({ + url: filteredEntries[entryIndex].request.url, + x: event.clientX, + y: event.clientY, + }); + } else { + setHoveredUrl(null); + } + } else { + setHoveredEntry(null); + setHoveredUrl(null); + } + }, + [filteredEntries, timings, scrollOffset] + ); + + const handleMouseLeave = useCallback(() => { + setHoveredEntry(null); + setHoveredUrl(null); + }, []); + + // Handle click on request + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const y = event.clientY - rect.top + scrollOffset.y; + + // Find which entry was clicked + const rowHeight = 30; + const headerHeight = 40; + const adjustedY = y - headerHeight; + const entryIndex = Math.floor(adjustedY / rowHeight); + + if (entryIndex >= 0 && entryIndex < filteredEntries.length) { + setSelectedEntry({ + entry: filteredEntries[entryIndex], + timing: timings[entryIndex], + }); + } + }, + [filteredEntries, timings, scrollOffset] + ); + + return ( +
+ + +
{ + const target = e.target as HTMLDivElement; + setScrollOffset({ x: target.scrollLeft, y: target.scrollTop }); + }} + > + +
+ + {hoveredUrl && ( + + )} + + {hoveredEntry && !hoveredUrl && ( + + )} + + {selectedEntry && ( + setSelectedEntry(null)} + /> + )} +
+ ); +}; diff --git a/components/har-waterfall/TruncatedText.tsx b/components/har-waterfall/TruncatedText.tsx new file mode 100644 index 0000000..d257a19 --- /dev/null +++ b/components/har-waterfall/TruncatedText.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { Button } from "../ds/ButtonComponent"; +import { Copy, Check, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface TruncatedTextProps { + text: string; + maxLength?: number; + className?: string; + showWarning?: boolean; +} + +export const TruncatedText: React.FC = ({ + text, + maxLength = 300, + className = "", + showWarning = true, +}) => { + const [copied, setCopied] = useState(false); + const isTruncated = text.length > maxLength; + const displayText = isTruncated ? text.substring(0, maxLength) + "..." : text; + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (!isTruncated) { + return {text}; + } + + return ( +
+
+ {displayText} + +
+ {showWarning && ( +
+ + + This content is very long ({text.length.toLocaleString()}{" "} + characters). Copy and paste into your editor to view the full + content. + +
+ )} +
+ ); +}; diff --git a/components/har-waterfall/WaterfallCanvas.tsx b/components/har-waterfall/WaterfallCanvas.tsx new file mode 100644 index 0000000..039e5e9 --- /dev/null +++ b/components/har-waterfall/WaterfallCanvas.tsx @@ -0,0 +1,238 @@ +import React, { useRef, useEffect } from "react"; +import { HarEntry } from "../utils/har-utils"; +import { WaterfallTiming, getTimingColor } from "./waterfall-utils"; + +interface WaterfallCanvasProps { + entries: HarEntry[]; + timings: WaterfallTiming[]; + zoomLevel: number; + width: number; + height: number; + scrollOffset: { x: number; y: number }; + hoveredIndex: number; +} + +const smallFont = "11px -apple-system, BlinkMacSystemFont, sans-serif"; +const mediumFont = "12px -apple-system, BlinkMacSystemFont, sans-serif"; + +export const WaterfallCanvas: React.FC = ({ + entries, + timings, + zoomLevel, + width, + height, + scrollOffset, + hoveredIndex, +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Set canvas size + canvas.width = width * window.devicePixelRatio; + canvas.height = height * window.devicePixelRatio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + // Configuration + const rowHeight = 30; + const headerHeight = 40; + const leftPadding = 300; // Space for URL + const rightPadding = 100; // Space for time label + const chartWidth = (width - leftPadding - rightPadding) * zoomLevel; + + // Calculate time range + const minTime = Math.min(...timings.map((t) => t.startTime)); + const maxTime = Math.max(...timings.map((t) => t.startTime + t.totalTime)); + const timeRange = maxTime - minTime; + + // Helper function to convert time to x position + const timeToX = (time: number) => { + return leftPadding + ((time - minTime) / timeRange) * chartWidth; + }; + + // Draw background grid + ctx.strokeStyle = "rgba(255, 255, 255, 0.05)"; + ctx.lineWidth = 1; + + // Vertical lines (time markers) + const timeStep = timeRange / 10; + for (let i = 0; i <= 10; i++) { + const x = timeToX(minTime + i * timeStep); + ctx.beginPath(); + ctx.moveTo(x, headerHeight); + ctx.lineTo(x, height); + ctx.stroke(); + } + + // Draw entries (offset by header height) + entries.forEach((entry, index) => { + const timing = timings[index]; + const y = index * rowHeight + headerHeight; + const isHovered = index === hoveredIndex; + + // Row background + if (isHovered) { + ctx.fillStyle = "rgba(99, 102, 241, 0.1)"; + ctx.fillRect(0, y, width, rowHeight); + // Add subtle indicator for clickable area + ctx.fillStyle = "rgba(99, 102, 241, 0.05)"; + ctx.fillRect(0, y, leftPadding, rowHeight); + } else if (index % 2 === 0) { + ctx.fillStyle = "rgba(0, 0, 0, 0.02)"; + ctx.fillRect(0, y, width, rowHeight); + } + + // Status indicator dot + const statusColor = entry.response.status >= 400 ? "#ef4444" : "#10b981"; + ctx.fillStyle = statusColor; + ctx.beginPath(); + ctx.arc(15, y + rowHeight / 2, 3, 0, Math.PI * 2); + ctx.fill(); + + // Status code + ctx.fillStyle = entry.response.status >= 400 ? "#dc2626" : "#059669"; + ctx.font = smallFont; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(entry.response.status.toString(), 25, y + rowHeight / 2); + + // Timestamp + const timestamp = new Date(entry.startedDateTime).toLocaleTimeString( + "en-US", + { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + } + ); + + ctx.fillStyle = "#9ca3af"; + ctx.font = smallFont; + ctx.fillText(timestamp, 55, y + rowHeight / 2); + + // URL text + ctx.save(); + ctx.beginPath(); + ctx.rect(120, y, leftPadding - 130, rowHeight); + ctx.clip(); + + ctx.fillStyle = entry.response.status >= 400 ? "#e11d48" : "#1f2937"; + ctx.font = mediumFont; + ctx.textBaseline = "middle"; + + const url = new URL(entry.request.url); + const displayText = url.pathname + url.search; + ctx.fillText(displayText, 125, y + rowHeight / 2); + + ctx.restore(); + + // Draw timing bars + const barHeight = 16; + const barY = y + (rowHeight - barHeight) / 2; + + // Draw each timing segment + const segments = [ + { type: "dns", time: timing.dns, color: getTimingColor("dns") }, + { + type: "connect", + time: timing.connect, + color: getTimingColor("connect"), + }, + { type: "ssl", time: timing.ssl, color: getTimingColor("ssl") }, + { type: "wait", time: timing.wait, color: getTimingColor("wait") }, + { + type: "receive", + time: timing.receive, + color: getTimingColor("receive"), + }, + ]; + + let currentX = timeToX(timing.startTime); + + segments.forEach((segment) => { + if (segment.time > 0) { + const segmentWidth = (segment.time / timeRange) * chartWidth; + + ctx.fillStyle = segment.color; + ctx.fillRect(currentX, barY, segmentWidth, barHeight); + + // Add slight border for clarity + ctx.strokeStyle = "rgba(0, 0, 0, 0.2)"; + ctx.lineWidth = 0.5; + ctx.strokeRect(currentX, barY, segmentWidth, barHeight); + + currentX += segmentWidth; + } + }); + + // Time label + ctx.fillStyle = "#9ca3af"; + ctx.font = mediumFont; + ctx.textAlign = "right"; + ctx.fillText( + `${timing.totalTime.toFixed(0)}ms`, + width - 20, + y + rowHeight / 2 + ); + ctx.textAlign = "left"; + + // Separator line + ctx.strokeStyle = "rgba(255, 255, 255, 0.05)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, y + rowHeight); + ctx.lineTo(width, y + rowHeight); + ctx.stroke(); + }); + + // Draw header background + ctx.fillStyle = "#f9fafb"; + ctx.fillRect(0, 0, width, headerHeight); + + // Header border + ctx.strokeStyle = "#e5e7eb"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, headerHeight); + ctx.lineTo(width, headerHeight); + ctx.stroke(); + + // Header labels + ctx.fillStyle = "#6b7280"; + ctx.font = mediumFont; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("Status", 15, headerHeight / 2); + ctx.fillText("Time", 65, headerHeight / 2); + ctx.fillText("Path", 125, headerHeight / 2); + + // Time scale labels + ctx.textAlign = "center"; + ctx.font = smallFont; + + for (let i = 0; i <= 10; i++) { + const time = minTime + i * timeStep; + const x = timeToX(time); + ctx.fillText(`${(time - minTime).toFixed(0)}ms`, x, headerHeight / 2); + } + }, [entries, timings, zoomLevel, width, height, scrollOffset, hoveredIndex]); + + return ( + + ); +}; diff --git a/components/har-waterfall/WaterfallControls.tsx b/components/har-waterfall/WaterfallControls.tsx new file mode 100644 index 0000000..f11ad2d --- /dev/null +++ b/components/har-waterfall/WaterfallControls.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Button } from "../ds/ButtonComponent"; +import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react"; + +interface WaterfallControlsProps { + zoomLevel: number; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomReset: () => void; +} + +export const WaterfallControls: React.FC = ({ + zoomLevel, + onZoomIn, + onZoomOut, + onZoomReset, +}) => { + return ( +
+
+ {/* Zoom controls */} +
+ + +
+ + {Math.round(zoomLevel * 100)}% + +
+ + +
+ + {/* Reset button */} + +
+ + {/* Keyboard shortcuts */} +
+ Press + + {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"} + + + + + + + + or + + − + + to zoom +
+
+ ); +}; diff --git a/components/har-waterfall/WaterfallLegend.tsx b/components/har-waterfall/WaterfallLegend.tsx new file mode 100644 index 0000000..1e32b97 --- /dev/null +++ b/components/har-waterfall/WaterfallLegend.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { getTimingColor } from "./waterfall-utils"; + +const timingTypes = [ + { key: "dns", label: "DNS Lookup" }, + { key: "connect", label: "Initial Connection" }, + { key: "ssl", label: "SSL/TLS Negotiation" }, + { key: "wait", label: "Waiting (TTFB)" }, + { key: "receive", label: "Content Download" }, +] as const; + +export const WaterfallLegend: React.FC = () => { + return ( +
+ {timingTypes.map(({ key, label }) => ( +
+
+ {label} +
+ ))} +
+ ); +}; diff --git a/components/har-waterfall/WaterfallRequestDetails.tsx b/components/har-waterfall/WaterfallRequestDetails.tsx new file mode 100644 index 0000000..58d5d32 --- /dev/null +++ b/components/har-waterfall/WaterfallRequestDetails.tsx @@ -0,0 +1,479 @@ +import React, { useState, useCallback } from "react"; +import { HarEntry } from "../utils/har-utils"; +import { + WaterfallTiming, + formatDuration, + getTimingColor, +} from "./waterfall-utils"; +import { + ChevronDown, + ChevronRight, + Copy, + Check, + Clock, + FileText, + Send, + Download, + Code, +} from "lucide-react"; +import { Button } from "../ds/ButtonComponent"; +import { cn } from "@/lib/utils"; +import { TruncatedText } from "./TruncatedText"; +import { Dialog, DialogContent } from "../ds/DialogComponent"; +import Editor, { BeforeMount } from "@monaco-editor/react"; + +interface WaterfallRequestDetailsProps { + entry: HarEntry; + timing: WaterfallTiming; + onClose: () => void; +} + +interface SectionProps { + title: string; + icon?: React.ReactNode; + defaultOpen?: boolean; + children: React.ReactNode; + timingChart?: React.ReactNode; +} + +const Section: React.FC = ({ + title, + icon, + defaultOpen = false, + children, + timingChart, +}) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +}; + +const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +}; + +// Helper function to decode and format content +const decodeContent = (content: string): string => { + try { + // Check if content is base64 encoded + if (/^[A-Za-z0-9+/]+={0,2}$/.test(content.replace(/\s/g, ""))) { + const decoded = atob(content); + try { + return JSON.stringify(JSON.parse(decoded), null, 2); + } catch { + return decoded; + } + } + // Try to parse as JSON + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch { + return content; + } + } catch { + return content; + } +}; + +// Monaco Editor component for content display +const ContentEditor: React.FC<{ + content: string; + mimeType: string; + height?: string; +}> = ({ content, mimeType, height = "400px" }) => { + const beforeMount: BeforeMount = useCallback((monaco) => { + monaco.editor.defineTheme("customTheme", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#0a0a0a", + "editor.foreground": "#e4e4e7", + "editor.lineHighlightBackground": "#18181b", + "editor.selectionBackground": "#3f3f46", + "editorCursor.foreground": "#e4e4e7", + "editorWhitespace.foreground": "#3f3f46", + }, + }); + }, []); + + const editorOptions = { + minimap: { enabled: false }, + fontSize: 12, + scrollBeyondLastLine: false, + padding: { top: 16, bottom: 16 }, + readOnly: true, + wordWrap: "on" as const, + wrappingIndent: "indent" as const, + scrollbar: { + vertical: "auto" as const, + horizontal: "auto" as const, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + }; + + // Determine language based on mime type + let language = "plaintext"; + if (mimeType.includes("json")) { + language = "json"; + } else if (mimeType.includes("xml")) { + language = "xml"; + } else if (mimeType.includes("html")) { + language = "html"; + } else if (mimeType.includes("javascript")) { + language = "javascript"; + } else if (mimeType.includes("css")) { + language = "css"; + } + + const formattedContent = decodeContent(content); + + return ( +
+ +
+ ); +}; + +export const WaterfallRequestDetails: React.FC< + WaterfallRequestDetailsProps +> = ({ entry, timing, onClose }) => { + const url = new URL(entry.request.url); + + const timingBreakdown = [ + { label: "DNS Lookup", value: timing.dns, color: getTimingColor("dns") }, + { + label: "Initial Connection", + value: timing.connect, + color: getTimingColor("connect"), + }, + { label: "SSL/TLS", value: timing.ssl, color: getTimingColor("ssl") }, + { + label: "Waiting (TTFB)", + value: timing.wait, + color: getTimingColor("wait"), + }, + { + label: "Content Download", + value: timing.receive, + color: getTimingColor("receive"), + }, + ].filter((item) => item.value > 0); + + // Create timing chart + const TimingChart = () => { + const total = timingBreakdown.reduce((sum, item) => sum + item.value, 0); + return ( +
+ {timingBreakdown.map((item, index) => ( +
+ ))} +
+ ); + }; + + return ( + + + {/* Header */} +
+
+
+ {/* Status Badge */} +
+
= 400 + ? "bg-red-500/10 text-red-500" + : "bg-green-500/10 text-green-500" + )} + > +
+ {entry.response.status} {entry.response.statusText} +
+ + {entry.request.method} + +
+ + {/* URL Section */} +
+
+

+ {url.hostname} +

+ +
+
+ +
+
+ + {/* Metrics */} +
+
+
+ + Size + + + {(entry.response.content.size / 1024).toFixed(1)} KB + +
+
+
+
+ + Duration + + + {formatDuration(timing.totalTime)} + +
+
+
+
+ + Type + + + {entry.response.content.mimeType + .split("/")[1] + ?.toUpperCase() || "Unknown"} + +
+
+
+
+ + Started + + + {new Date(entry.startedDateTime).toLocaleTimeString( + "en-US", + { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + } + )} + +
+
+
+
+
+
+ + {/* Content */} +
+ {/* Timing Breakdown */} +
} + timingChart={} + > +
+ {timingBreakdown.map((item, index) => ( +
+
+
+ + {item.label} + +
+ + {formatDuration(item.value)} + +
+ ))} +
+ Total Time + + {formatDuration(timing.totalTime)} + +
+
+
+ + {/* Request Headers */} +
} + > +
+ {entry.request.headers.map((header, index) => ( +
+ + {header.name}: + +
+ 300} + /> +
+
+ ))} +
+
+ + {/* Response Headers */} +
} + > +
+ {entry.response.headers.map((header, index) => ( +
+ + {header.name}: + +
+ 300} + /> +
+
+ ))} +
+
+ + {/* Request Body */} + {entry.request.postData && ( +
} + > +
+
+ + Type: {entry.request.postData.mimeType} + + +
+ +
+
+ )} + + {/* Response Content */} + {entry.response.content.text && ( +
} + > +
+
+ + Type: {entry.response.content.mimeType} + + +
+ {entry.response.content.mimeType.startsWith("image/") ? ( +
+ Response image +
+ ) : ( + + )} +
+
+ )} +
+ +
+ ); +}; diff --git a/components/har-waterfall/WaterfallTooltip.tsx b/components/har-waterfall/WaterfallTooltip.tsx new file mode 100644 index 0000000..081e275 --- /dev/null +++ b/components/har-waterfall/WaterfallTooltip.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { HarEntry } from "../utils/har-utils"; +import { + WaterfallTiming, + formatDuration, + getTimingColor, +} from "./waterfall-utils"; + +interface WaterfallTooltipProps { + entry: HarEntry; + timing: WaterfallTiming; + x: number; + y: number; +} + +export const WaterfallTooltip: React.FC = ({ + entry, + timing, + x, + y, +}) => { + const url = new URL(entry.request.url); + const size = entry.response.content.size; + + // Position tooltip to avoid edge overflow + const tooltipWidth = 320; + const tooltipHeight = 280; + const offsetX = + x + tooltipWidth > window.innerWidth ? -tooltipWidth - 10 : 10; + const offsetY = + y + tooltipHeight > window.innerHeight ? -tooltipHeight + 40 : 10; + + const timingBreakdown = [ + { label: "DNS Lookup", value: timing.dns, color: getTimingColor("dns") }, + { + label: "Initial Connection", + value: timing.connect, + color: getTimingColor("connect"), + }, + { label: "SSL", value: timing.ssl, color: getTimingColor("ssl") }, + { + label: "Waiting (TTFB)", + value: timing.wait, + color: getTimingColor("wait"), + }, + { + label: "Content Download", + value: timing.receive, + color: getTimingColor("receive"), + }, + ].filter((item) => item.value > 0); + + return ( +
+ {/* URL and Status */} +
+
{url.hostname}
+
+ {url.pathname} +
+
+ = 400 ? "text-red-500" : "text-green-500" + }`} + > + {entry.response.status} {entry.response.statusText} + + + {entry.request.method} + + + {(size / 1024).toFixed(1)} KB + +
+
+ + {/* Total Time */} +
+
+ Total Time + + {formatDuration(timing.totalTime)} + +
+
+ + {/* Timing Breakdown */} +
+
+ Timing Breakdown +
+ {timingBreakdown.map((item, index) => ( +
+
+
+ + {item.label} + +
+ + {formatDuration(item.value)} + +
+ ))} +
+ + {/* Resource Type */} +
+
+ Type + + {entry.response.content.mimeType} + +
+
+
+ ); +}; diff --git a/components/har-waterfall/WaterfallUrlTooltip.tsx b/components/har-waterfall/WaterfallUrlTooltip.tsx new file mode 100644 index 0000000..b788354 --- /dev/null +++ b/components/har-waterfall/WaterfallUrlTooltip.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +interface WaterfallUrlTooltipProps { + url: string; + x: number; + y: number; +} + +export const WaterfallUrlTooltip: React.FC = ({ + url, + x, + y, +}) => { + // Calculate position to avoid overflow + const tooltipWidth = 400; + const offsetX = + x + tooltipWidth > window.innerWidth ? -tooltipWidth - 10 : 10; + const offsetY = -30; // Show above cursor + + return ( +
+

{url}

+
+ ); +}; diff --git a/components/har-waterfall/index.ts b/components/har-waterfall/index.ts new file mode 100644 index 0000000..7c82898 --- /dev/null +++ b/components/har-waterfall/index.ts @@ -0,0 +1,9 @@ +export { HarWaterfall } from "./HarWaterfall"; +export { WaterfallCanvas } from "./WaterfallCanvas"; +export { WaterfallTooltip } from "./WaterfallTooltip"; +export { WaterfallControls } from "./WaterfallControls"; +export { WaterfallLegend } from "./WaterfallLegend"; +export { WaterfallRequestDetails } from "./WaterfallRequestDetails"; +export { WaterfallUrlTooltip } from "./WaterfallUrlTooltip"; +export { TruncatedText } from "./TruncatedText"; +export * from "./waterfall-utils"; diff --git a/components/har-waterfall/waterfall-utils.ts b/components/har-waterfall/waterfall-utils.ts new file mode 100644 index 0000000..1f496ab --- /dev/null +++ b/components/har-waterfall/waterfall-utils.ts @@ -0,0 +1,75 @@ +import { HarEntry } from "../utils/har-utils"; + +export interface WaterfallTiming { + startTime: number; + dns: number; + connect: number; + ssl: number; + wait: number; + receive: number; + totalTime: number; +} + +const TIMING_COLORS = { + dns: "#0070f3", // Blue + connect: "#7928ca", // Purple + ssl: "#ff0080", // Pink + wait: "#f5a623", // Orange + receive: "#50e3c2", // Teal +} as const; + +export function getTimingColor(type: keyof typeof TIMING_COLORS): string { + return TIMING_COLORS[type]; +} + +export function calculateTimings(entries: HarEntry[]): WaterfallTiming[] { + if (entries.length === 0) return []; + + // Find the earliest start time + const earliestStartTime = Math.min( + ...entries.map((entry) => new Date(entry.startedDateTime).getTime()) + ); + + return entries.map((entry) => { + const startTime = + new Date(entry.startedDateTime).getTime() - earliestStartTime; + + // Extract detailed timings (if available) + const timings = entry.timings || {}; + const dns = Math.max(0, timings.dns || 0); + const connect = Math.max(0, timings.connect || 0); + const ssl = Math.max(0, timings.ssl || 0); + const wait = Math.max(0, timings.wait || 0); + const receive = Math.max(0, timings.receive || 0); + + // If detailed timings are not available, use total time + const totalTime = entry.time || dns + connect + ssl + wait + receive; + + return { + startTime, + dns, + connect, + ssl, + wait, + receive, + totalTime, + }; + }); +} + +export function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(0)}ms`; + } + return `${(ms / 1000).toFixed(2)}s`; +} + +export function getRequestTypeColor(mimeType: string): string { + if (mimeType.includes("javascript")) return "#f7df1e"; + if (mimeType.includes("css")) return "#1572b6"; + if (mimeType.includes("html")) return "#e34c26"; + if (mimeType.includes("image")) return "#00d4ff"; + if (mimeType.includes("json") || mimeType.includes("xml")) return "#00ff88"; + if (mimeType.includes("font")) return "#ff6b6b"; + return "#888888"; +} diff --git a/components/utils/har-utils.ts b/components/utils/har-utils.ts index 40400fa..48c2336 100644 --- a/components/utils/har-utils.ts +++ b/components/utils/har-utils.ts @@ -4,12 +4,14 @@ export interface HarEntry { request: { url: string; method: string; + httpVersion?: string; headers: { name: string; value: string }[]; postData?: { mimeType: string; text: string; }; }; + serverIPAddress?: string; response: { status: number; statusText: string; @@ -21,8 +23,13 @@ export interface HarEntry { }; }; timings: { + blocked?: number; + dns?: number; + connect?: number; + send?: number; wait: number; receive: number; + ssl?: number; }; } diff --git a/pages/utilities/har-file-viewer.tsx b/pages/utilities/har-file-viewer.tsx index c0dd77f..3c72879 100644 --- a/pages/utilities/har-file-viewer.tsx +++ b/pages/utilities/har-file-viewer.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, Fragment, useEffect } from "react"; +import { useRouter } from "next/router"; import { BeforeMount, Editor } from "@monaco-editor/react"; import { FilterType, @@ -20,12 +21,51 @@ import UploadIcon from "@/components/icons/UploadIcon"; import PageHeader from "@/components/PageHeader"; import CallToActionGrid from "@/components/CallToActionGrid"; import HarFileViewerSEO from "@/components/seo/HarFileViewerSEO"; - -type Status = "idle" | "unsupported" | "hover"; +import { HarWaterfall } from "@/components/har-waterfall"; +import { Table, BarChart3 } from "lucide-react"; export default function HARFileViewer() { + const router = useRouter(); + const [status, setStatus] = useState<"idle" | "unsupported" | "hover">( + "idle" + ); const [harData, setHarData] = useState(null); - const [status, setStatus] = useState("idle"); + const [activeFilter, setActiveFilter] = useState("All"); + const [viewMode, setViewMode] = useState<"table" | "waterfall">("table"); + + // Initialize view mode from query param + useEffect(() => { + const viewParam = router.query.view as string; + if (viewParam === "waterfall") { + setViewMode("waterfall"); + } else if (!viewParam) { + // Set default view param without navigation + router.replace( + { + pathname: router.pathname, + query: { ...router.query, view: "table" }, + }, + undefined, + { shallow: true } + ); + } + }, [router.query.view]); + + // Update URL when view mode changes + const handleViewChange = useCallback( + (newView: "table" | "waterfall") => { + setViewMode(newView); + router.replace( + { + pathname: router.pathname, + query: { ...router.query, view: newView }, + }, + undefined, + { shallow: true } + ); + }, + [router] + ); const handleFileUpload = useCallback((file: File | undefined) => { if (!file) { @@ -116,9 +156,75 @@ export default function HARFileViewer() { {harData && ( -
- -
+ <> +
+
+
+ {( + [ + "All", + "XHR", + "JS", + "CSS", + "Img", + "Media", + "Other", + "Errors", + ] as FilterType[] + ).map((type) => ( + + ))} +
+
+ +