From 54e8d80ad443cf579bc7a6a24cf17cc215700e75 Mon Sep 17 00:00:00 2001 From: Petar Cirkovic Date: Sat, 7 Feb 2026 19:08:52 +0100 Subject: [PATCH 01/12] feat: migrate waterfall HAR file viewer elements from Canvas to HTML --- components/har-waterfall/HarWaterfall.tsx | 388 ++++++++++++------ components/har-waterfall/WaterfallLegend.tsx | 12 +- .../har-waterfall/WaterfallRequestDetails.tsx | 168 ++++---- 3 files changed, 348 insertions(+), 220 deletions(-) diff --git a/components/har-waterfall/HarWaterfall.tsx b/components/har-waterfall/HarWaterfall.tsx index 5401ff6..0939865 100644 --- a/components/har-waterfall/HarWaterfall.tsx +++ b/components/har-waterfall/HarWaterfall.tsx @@ -1,16 +1,20 @@ -import React, { useRef, useState, useCallback, useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { HarEntry, FilterType, getFilterType, isBase64, } 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"; +import { + calculateTimings, + formatDuration, + getTimingColor, + WaterfallTiming, +} from "./waterfall-utils"; +import { cn } from "@/lib/utils"; +import SearchHighlightText from "@/components/SearchHighlightText"; interface HarWaterfallProps { entries: HarEntry[]; @@ -19,47 +23,54 @@ interface HarWaterfallProps { searchQuery?: string; } +type WaterfallSegment = { + key: "dns" | "connect" | "ssl" | "wait" | "receive"; + label: string; + time: number; + color: string; +}; + +const segmentDefinitions: WaterfallSegment[] = [ + { key: "dns", label: "DNS", time: 0, color: getTimingColor("dns") }, + { + key: "connect", + label: "Connect", + time: 0, + color: getTimingColor("connect"), + }, + { key: "ssl", label: "SSL", time: 0, color: getTimingColor("ssl") }, + { key: "wait", label: "Wait", time: 0, color: getTimingColor("wait") }, + { + key: "receive", + label: "Receive", + time: 0, + color: getTimingColor("receive"), + }, +]; + export const HarWaterfall: React.FC = ({ entries, activeFilter, className = "", searchQuery = "", }) => { - 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 and search query const filteredEntries = useMemo(() => { let result = entries; - // Apply content type filter if (activeFilter !== "All") { result = result.filter((entry) => getFilterType(entry) === activeFilter); } - // Apply search filter if (searchQuery) { const query = searchQuery.toLowerCase(); result = result.filter((entry) => { - // Search in URL if (entry.request.url.toLowerCase().includes(query)) return true; - // Search in request headers const requestHeaderMatch = entry.request.headers.some( (header) => header.name.toLowerCase().includes(query) || @@ -67,7 +78,6 @@ export const HarWaterfall: React.FC = ({ ); if (requestHeaderMatch) return true; - // Search in response headers const responseHeaderMatch = entry.response.headers.some( (header) => header.name.toLowerCase().includes(query) || @@ -75,15 +85,12 @@ export const HarWaterfall: React.FC = ({ ); if (responseHeaderMatch) return true; - // Search in request payload if (entry.request.postData?.text) { if (entry.request.postData.text.toLowerCase().includes(query)) return true; } - // Search in response content if (entry.response.content.text) { - // For base64 content, try to decode and search let contentToSearch = entry.response.content.text; if (isBase64(contentToSearch)) { try { @@ -102,126 +109,239 @@ export const HarWaterfall: React.FC = ({ return result; }, [entries, activeFilter, searchQuery]); - // 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 timeRange = useMemo(() => { + if (timings.length === 0) return 1; + const maxTime = Math.max( + ...timings.map((timing) => timing.startTime + timing.totalTime) + ); + return maxTime > 0 ? maxTime : 1; + }, [timings]); - 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] + const tickCount = 6; + const ticks = useMemo(() => { + return Array.from({ length: tickCount + 1 }, (_, index) => { + const value = (timeRange / tickCount) * index; + return { + label: formatDuration(value), + value, + position: (index / tickCount) * 100, + }; + }); + }, [timeRange, tickCount]); + + const gridStyle = useMemo( + () => ({ + backgroundImage: + "linear-gradient(to right, rgba(148, 163, 184, 0.15) 1px, transparent 1px)", + backgroundSize: `${100 / tickCount}% 100%`, + }), + [tickCount] ); + const getSegments = (timing: WaterfallTiming) => { + const segments = segmentDefinitions + .map((segment) => ({ + ...segment, + time: timing[segment.key], + })) + .filter((segment) => segment.time > 0); + + let cursor = timing.startTime; + return segments.map((segment) => { + const left = (cursor / timeRange) * 100; + const width = (segment.time / timeRange) * 100; + cursor += segment.time; + return { + ...segment, + left, + width, + }; + }); + }; + + const getTimingLabel = (timing: WaterfallTiming) => { + const parts = segmentDefinitions + .map((segment) => ({ + ...segment, + time: timing[segment.key], + })) + .filter((segment) => segment.time > 0) + .map((segment) => `${segment.label} ${formatDuration(segment.time)}`); + + if (parts.length === 0) { + return `Total ${formatDuration(timing.totalTime)}`; + } + + return `${parts.join(", ")}. Total ${formatDuration(timing.totalTime)}.`; + }; + return ( -
- +
+
+ +
+ Timeline range {formatDuration(timeRange)} · {filteredEntries.length}{" "} + requests +
+
{ - const target = e.target as HTMLDivElement; - setScrollOffset({ x: target.scrollLeft, y: target.scrollTop }); - }} + className="border border-border rounded-xl bg-background overflow-x-auto" + role="region" + aria-label="HAR waterfall timeline" > - -
+
+
+
+
Status
+
Started
+
Request
+
+ Waterfall timeline + {ticks.map((tick, index) => ( + + {tick.label} + + ))} +
+
Total
+
+
- {hoveredUrl && ( - - )} + {filteredEntries.length === 0 ? ( +
+ No requests match your current filters. +
+ ) : ( +
+ {filteredEntries.map((entry, index) => { + const timing = timings[index]; + const url = new URL(entry.request.url); + const displayPath = url.pathname + url.search; + const segments = getSegments(timing); + const isError = entry.response.status >= 400; + const rowLabel = `${entry.request.method} ${displayPath} ${entry.response.status} ${formatDuration( + timing.totalTime + )}`; - {hoveredEntry && !hoveredUrl && ( - - )} + return ( + + ); + })} +
+ )} +
+
{selectedEntry && ( { return ( -
+
+ + Legend + {timingTypes.map(({ key, label }) => (
))}
diff --git a/components/har-waterfall/WaterfallRequestDetails.tsx b/components/har-waterfall/WaterfallRequestDetails.tsx index 58d5d32..f14fc9a 100644 --- a/components/har-waterfall/WaterfallRequestDetails.tsx +++ b/components/har-waterfall/WaterfallRequestDetails.tsx @@ -1,26 +1,26 @@ -import React, { useState, useCallback } from "react"; -import { HarEntry } from "../utils/har-utils"; -import { - WaterfallTiming, - formatDuration, - getTimingColor, -} from "./waterfall-utils"; +import { cn } from "@/lib/utils"; +import Editor, { BeforeMount } from "@monaco-editor/react"; import { + Check, ChevronDown, ChevronRight, - Copy, - Check, Clock, + Code, + Copy, + Download, FileText, Send, - Download, - Code, } from "lucide-react"; +import React, { useCallback, useId, useState } from "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"; +import { HarEntry } from "../utils/har-utils"; +import { TruncatedText } from "./TruncatedText"; +import { + WaterfallTiming, + formatDuration, + getTimingColor, +} from "./waterfall-utils"; interface WaterfallRequestDetailsProps { entry: HarEntry; @@ -44,12 +44,16 @@ const Section: React.FC = ({ timingChart, }) => { const [isOpen, setIsOpen] = useState(defaultOpen); + const contentId = useId(); return (
- {isOpen &&
{children}
} + {isOpen && ( +
+ {children} +
+ )}
); }; @@ -98,6 +106,26 @@ const CopyButton: React.FC<{ text: string }> = ({ text }) => { ); }; +const MetricCard: React.FC<{ + label: string; + value: React.ReactNode; + helper?: React.ReactNode; +}> = ({ label, value, helper }) => { + return ( +
+
+ {label} +
+
+ {value} +
+ {helper && ( +
{helper}
+ )} +
+ ); +}; + // Helper function to decode and format content const decodeContent = (content: string): string => { try { @@ -193,6 +221,21 @@ export const WaterfallRequestDetails: React.FC< WaterfallRequestDetailsProps > = ({ entry, timing, onClose }) => { const url = new URL(entry.request.url); + const startedTime = new Date(entry.startedDateTime).toLocaleTimeString( + "en-US", + { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + } + ); + const sizeLabel = `${(entry.response.content.size / 1024).toFixed(1)} KB`; + const protocolLabel = entry.request.httpVersion || "Not provided"; + const serverIpLabel = entry.serverIPAddress || "Server IP unavailable"; + const mimeTypeLabel = + entry.response.content.mimeType?.split(";")[0] || "Unknown"; const timingBreakdown = [ { label: "DNS Lookup", value: timing.dns, color: getTimingColor("dns") }, @@ -235,19 +278,18 @@ export const WaterfallRequestDetails: React.FC< return ( - + {/* Header */}
-
-
- {/* Status Badge */} -
+
+
+
= 400 ? "bg-red-500/10 text-red-500" - : "bg-green-500/10 text-green-500" + : "bg-emerald-500/10 text-emerald-500" )} >
@@ -256,15 +298,16 @@ export const WaterfallRequestDetails: React.FC< {entry.request.method} + + {protocolLabel} +
- {/* URL Section */} -
-
-

- {url.hostname} -

- +
+
+ {url.hostname} + + {serverIpLabel}
-
- - {/* 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, - } - )} - -
+
+ + Copy full URL
+ +
+ + + + + + +
From 554ee515b00a41c0e5a8d3f5f22e31a0f3752633 Mon Sep 17 00:00:00 2001 From: Petar Cirkovic Date: Sat, 7 Feb 2026 19:55:03 +0100 Subject: [PATCH 02/12] feat: show time bubble --- components/har-waterfall/HarWaterfall.tsx | 189 ++++++++++++++++++++-- 1 file changed, 173 insertions(+), 16 deletions(-) diff --git a/components/har-waterfall/HarWaterfall.tsx b/components/har-waterfall/HarWaterfall.tsx index 0939865..61f9a05 100644 --- a/components/har-waterfall/HarWaterfall.tsx +++ b/components/har-waterfall/HarWaterfall.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HarEntry, FilterType, @@ -58,6 +58,17 @@ export const HarWaterfall: React.FC = ({ entry: HarEntry; timing: WaterfallTiming; } | null>(null); + const listRef = useRef(null); + const timelineRef = useRef(null); + const hoverLabelRef = useRef(null); + const rafRef = useRef(null); + const pendingRef = useRef<{ + listX: number; + listY: number; + timelineX: number; + opacity: number; + label: string; + } | null>(null); const filteredEntries = useMemo(() => { let result = entries; @@ -136,7 +147,7 @@ export const HarWaterfall: React.FC = ({ const gridStyle = useMemo( () => ({ backgroundImage: - "linear-gradient(to right, rgba(148, 163, 184, 0.15) 1px, transparent 1px)", + "linear-gradient(to right, rgba(148, 163, 184, 0.28) 1px, transparent 1px)", backgroundSize: `${100 / tickCount}% 100%`, }), [tickCount] @@ -179,6 +190,105 @@ export const HarWaterfall: React.FC = ({ return `${parts.join(", ")}. Total ${formatDuration(timing.totalTime)}.`; }; + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + + const scheduleHoverUpdate = useCallback( + ( + listX: number, + listY: number, + timelineX: number, + opacity: number, + label: string + ) => { + pendingRef.current = { listX, listY, timelineX, opacity, label }; + if (rafRef.current !== null) return; + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + const pending = pendingRef.current; + const list = listRef.current; + const timeline = timelineRef.current; + const labelNode = hoverLabelRef.current; + if (!pending || !list || !timeline || !labelNode) return; + list.style.setProperty("--hover-x", `${pending.listX}px`); + list.style.setProperty("--hover-y", `${pending.listY}px`); + list.style.setProperty("--hover-opacity", `${pending.opacity}`); + timeline.style.setProperty("--hover-x", `${pending.timelineX}px`); + timeline.style.setProperty("--hover-opacity", `${pending.opacity}`); + labelNode.textContent = pending.opacity > 0 ? pending.label : ""; + const labelWidth = labelNode.offsetWidth; + const labelHeight = labelNode.offsetHeight; + const listWidth = list.clientWidth; + const listHeight = list.clientHeight; + const timelineLeft = timeline.offsetLeft; + const safeX = clamp( + timelineLeft + pending.timelineX, + labelWidth / 2, + Math.max(labelWidth / 2, listWidth - labelWidth / 2) + ); + const safeY = clamp( + pending.listY - 24 - labelHeight, + 0, + Math.max(0, listHeight - labelHeight) + ); + list.style.setProperty("--hover-label-x", `${safeX}px`); + list.style.setProperty("--hover-label-y", `${safeY}px`); + }); + }, + [clamp] + ); + + const formatHoverTime = useCallback( + (position: number, width: number) => { + if (width <= 0) return "0ms"; + const ratio = clamp(position / width, 0, 1); + return formatDuration(ratio * timeRange); + }, + [timeRange] + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + const list = listRef.current; + const timeline = timelineRef.current; + if (!list || !timeline) return; + + const listRect = list.getBoundingClientRect(); + const timelineRect = timeline.getBoundingClientRect(); + const listX = event.clientX - listRect.left; + const listY = event.clientY - listRect.top; + const timelineX = event.clientX - timelineRect.left; + const withinTimeline = + event.clientX >= timelineRect.left && + event.clientX <= timelineRect.right; + + const label = withinTimeline + ? formatHoverTime(timelineX, timelineRect.width) + : ""; + + scheduleHoverUpdate( + clamp(listX, 0, listRect.width), + clamp(listY, 0, listRect.height), + clamp(timelineX, 0, timelineRect.width), + withinTimeline ? 1 : 0, + label + ); + }, + [formatHoverTime, scheduleHoverUpdate] + ); + + const handlePointerLeave = useCallback(() => { + scheduleHoverUpdate(0, 0, 0, 0, ""); + }, [scheduleHoverUpdate]); + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + }; + }, []); + return (
@@ -190,17 +300,26 @@ export const HarWaterfall: React.FC = ({
-
-
-
+
+
+
Status
Started
Request
-
+
Waterfall timeline {ticks.map((tick, index) => ( = ({ No requests match your current filters.
) : ( -
+
+