diff --git a/__tests__/pages/utilities/har-file-viewer.test.tsx b/__tests__/pages/utilities/har-file-viewer.test.tsx index 60e6c6d..e36db67 100644 --- a/__tests__/pages/utilities/har-file-viewer.test.tsx +++ b/__tests__/pages/utilities/har-file-viewer.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import HARFileViewer from "../../../pages/utilities/har-file-viewer"; @@ -50,6 +50,38 @@ const mockHarData = { }; describe("HARFileViewer", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + const uploadHarFile = async (user: ReturnType) => { + const file = new File([JSON.stringify(mockHarData)], "test.har", { + type: "application/json", + }); + const fileInput = screen.getByTestId("input"); + await user.upload(fileInput, file); + }; + + const flushDebounce = async () => { + await act(async () => { + jest.advanceTimersByTime(350); + }); + }; + + const switchToTableView = async ( + user: ReturnType + ) => { + const tableTab = await screen.findByRole("tab", { name: /table view/i }); + await user.click(tableTab); + }; + test("should render the component and display the drop zone text", () => { render(); @@ -59,17 +91,11 @@ describe("HARFileViewer", () => { }); test("should list all requests after uploading a har file", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); - // Create a mock file - const file = new File([JSON.stringify(mockHarData)], "test.har", { - type: "application/json", - }); - - // Find the file input and upload the file - const fileInput = screen.getByTestId("input"); - await user.upload(fileInput, file); + await uploadHarFile(user); + await switchToTableView(user); // Wait for the requests to be displayed await screen.findByText("https://example.com/api/test"); @@ -77,17 +103,11 @@ describe("HARFileViewer", () => { }); test("should list the status code for every request", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); - // Create a mock file - const file = new File([JSON.stringify(mockHarData)], "test.har", { - type: "application/json", - }); - - // Find the file input and upload the file - const fileInput = screen.getByTestId("input"); - await user.upload(fileInput, file); + await uploadHarFile(user); + await switchToTableView(user); // Get all rows const rows = await screen.findAllByTestId("table-row"); @@ -102,17 +122,15 @@ describe("HARFileViewer", () => { }); test("should accept and process .json file extension", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); - // Create a mock file with .json extension const file = new File([JSON.stringify(mockHarData)], "test.json", { type: "application/json", }); - - // Find the file input and upload the file const fileInput = screen.getByTestId("input"); await user.upload(fileInput, file); + await switchToTableView(user); // Wait for the requests to be displayed - this verifies the file was accepted await screen.findByText("https://example.com/api/test"); @@ -125,15 +143,12 @@ describe("HARFileViewer", () => { }); test("should filter requests based on search query", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); // Upload a HAR file - const file = new File([JSON.stringify(mockHarData)], "test.har", { - type: "application/json", - }); - const fileInput = screen.getByTestId("input"); - await user.upload(fileInput, file); + await uploadHarFile(user); + await switchToTableView(user); // Wait for all requests to be displayed await screen.findByText("https://example.com/api/test"); @@ -148,7 +163,7 @@ describe("HARFileViewer", () => { await user.type(searchInput, "api"); // Wait for debounce (300ms) + rendering time - await new Promise((resolve) => setTimeout(resolve, 500)); + await flushDebounce(); // Should still see the api request const rows = screen.queryAllByTestId("table-row"); @@ -157,15 +172,12 @@ describe("HARFileViewer", () => { }); test("should clear search query when clear button is clicked", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); // Upload a HAR file - const file = new File([JSON.stringify(mockHarData)], "test.har", { - type: "application/json", - }); - const fileInput = screen.getByTestId("input"); - await user.upload(fileInput, file); + await uploadHarFile(user); + await switchToTableView(user); // Wait for requests to be displayed await screen.findByText("https://example.com/api/test"); @@ -177,11 +189,12 @@ describe("HARFileViewer", () => { await user.type(searchInput, "api"); // Wait for debounce - await new Promise((resolve) => setTimeout(resolve, 400)); + await flushDebounce(); // Find the clear button (it should appear when there's text) const clearButton = screen.getByTitle("Clear search"); await user.click(clearButton); + await flushDebounce(); // Search input should be empty expect(searchInput).toHaveValue(""); diff --git a/components/har-waterfall/HarWaterfall.tsx b/components/har-waterfall/HarWaterfall.tsx index 5401ff6..9288a52 100644 --- a/components/har-waterfall/HarWaterfall.tsx +++ b/components/har-waterfall/HarWaterfall.tsx @@ -1,16 +1,43 @@ -import React, { useRef, useState, useCallback, useMemo } from "react"; +import MatchIndicators from "@/components/MatchIndicators"; +import SearchHighlightText from "@/components/SearchHighlightText"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + AlertCircle, + ArrowLeftRight, + FileCode, + Film, + Image, + Package, + Palette, +} from "lucide-react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { - HarEntry, FilterType, getFilterType, + getMatchCategories, + HarEntry, isBase64, } from "../utils/har-utils"; -import { WaterfallCanvas } from "./WaterfallCanvas"; -import { WaterfallTooltip } from "./WaterfallTooltip"; +import { + calculateTimings, + formatDuration, + getTimingColor, + WaterfallTiming, +} from "./waterfall-utils"; import { WaterfallLegend } from "./WaterfallLegend"; import { WaterfallRequestDetails } from "./WaterfallRequestDetails"; -import { WaterfallUrlTooltip } from "./WaterfallUrlTooltip"; -import { calculateTimings, WaterfallTiming } from "./waterfall-utils"; interface HarWaterfallProps { entries: HarEntry[]; @@ -19,47 +46,111 @@ interface HarWaterfallProps { searchQuery?: string; } +type WaterfallSegment = { + key: "dns" | "connect" | "ssl" | "wait" | "receive"; + label: string; + time: number; + color: string; +}; + +type TypeMeta = { + label: string; + icon: React.ComponentType<{ className?: string }>; + className: 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"), + }, +]; + +const typeMetaMap: Record = { + All: { + label: "All", + icon: Package, + className: "bg-slate-500/10 text-slate-600 ring-slate-500/20", + }, + XHR: { + label: "XHR", + icon: ArrowLeftRight, + className: "bg-sky-500/10 text-sky-600 ring-sky-500/20", + }, + JS: { + label: "JS", + icon: FileCode, + className: "bg-amber-500/10 text-amber-600 ring-amber-500/20", + }, + CSS: { + label: "CSS", + icon: Palette, + className: "bg-indigo-500/10 text-indigo-600 ring-indigo-500/20", + }, + Img: { + label: "Image", + icon: Image, + className: "bg-emerald-500/10 text-emerald-600 ring-emerald-500/20", + }, + Media: { + label: "Media", + icon: Film, + className: "bg-pink-500/10 text-pink-600 ring-pink-500/20", + }, + Other: { + label: "Other", + icon: Package, + className: "bg-slate-500/10 text-slate-600 ring-slate-500/20", + }, + Errors: { + label: "Error", + icon: AlertCircle, + className: "bg-red-500/10 text-red-600 ring-red-500/20", + }, +}; + 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; + const [expandedIndex, setExpandedIndex] = useState(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 [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 +158,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 +165,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,134 +189,460 @@ 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); - } + 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 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.28) 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)}.`; + }; + + 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`); + }); }, - [filteredEntries, timings, scrollOffset] + [clamp] ); - const handleMouseLeave = useCallback(() => { - setHoveredEntry(null); - setHoveredUrl(null); - }, []); + const formatHoverTime = useCallback( + (position: number, width: number) => { + if (width <= 0) return "0ms"; + const ratio = clamp(position / width, 0, 1); + return formatDuration(ratio * timeRange); + }, + [timeRange] + ); - // 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], - }); - } + 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 + ); }, - [filteredEntries, timings, scrollOffset] + [formatHoverTime, scheduleHoverUpdate] ); + const handlePointerLeave = useCallback(() => { + scheduleHoverUpdate(0, 0, 0, 0, ""); + }, [scheduleHoverUpdate]); + + useEffect(() => { + setExpandedIndex(null); + }, [activeFilter, searchQuery, entries]); + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + }; + }, []); + 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 overflow-y-visible" + role="region" + aria-label="HAR waterfall timeline" > - -
+
+
+
+
Status
+
Type
+
Started
+
Request
+
+ Waterfall timeline + {ticks.map((tick, index) => ( + + {tick.label} + + ))} +
+
Total
+
+
+ + {filteredEntries.length === 0 ? ( +
+ No requests match your current filters. +
+ ) : ( + +
+ +
); }; diff --git a/components/har-waterfall/TruncatedText.tsx b/components/har-waterfall/TruncatedText.tsx index d257a19..a334838 100644 --- a/components/har-waterfall/TruncatedText.tsx +++ b/components/har-waterfall/TruncatedText.tsx @@ -1,13 +1,14 @@ +import { cn } from "@/lib/utils"; +import { AlertCircle, Check, Copy } from "lucide-react"; 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; + showCopy?: boolean; } export const TruncatedText: React.FC = ({ @@ -15,6 +16,7 @@ export const TruncatedText: React.FC = ({ maxLength = 300, className = "", showWarning = true, + showCopy = true, }) => { const [copied, setCopied] = useState(false); const isTruncated = text.length > maxLength; @@ -34,27 +36,28 @@ export const TruncatedText: React.FC = ({
{displayText} - + {showCopy && ( + + )}
{showWarning && ( -
- - - This content is very long ({text.length.toLocaleString()}{" "} - characters). Copy and paste into your editor to view the full - content. +
+ + + This content is long ({text.length.toLocaleString()} characters). + Copy the value to view the full content.
)} diff --git a/components/har-waterfall/WaterfallLegend.tsx b/components/har-waterfall/WaterfallLegend.tsx index 1e32b97..2f110ca 100644 --- a/components/har-waterfall/WaterfallLegend.tsx +++ b/components/har-waterfall/WaterfallLegend.tsx @@ -4,21 +4,25 @@ import { getTimingColor } from "./waterfall-utils"; const timingTypes = [ { key: "dns", label: "DNS Lookup" }, { key: "connect", label: "Initial Connection" }, - { key: "ssl", label: "SSL/TLS Negotiation" }, + { key: "ssl", label: "SSL/TLS" }, { key: "wait", label: "Waiting (TTFB)" }, { key: "receive", label: "Content Download" }, ] as const; export const WaterfallLegend: React.FC = () => { return ( -
+
+ + Legend + {timingTypes.map(({ key, label }) => (
))}
diff --git a/components/har-waterfall/WaterfallRequestDetails.tsx b/components/har-waterfall/WaterfallRequestDetails.tsx index 58d5d32..1c592f1 100644 --- a/components/har-waterfall/WaterfallRequestDetails.tsx +++ b/components/har-waterfall/WaterfallRequestDetails.tsx @@ -1,79 +1,35 @@ -import React, { useState, useCallback } from "react"; -import { HarEntry } from "../utils/har-utils"; +import { cn } from "@/lib/utils"; +import Editor, { BeforeMount } from "@monaco-editor/react"; +import { Check, Clock, Copy } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "../ds/ButtonComponent"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ds/TabsComponent"; +import SearchHighlightText from "../SearchHighlightText"; +import { HarEntry, getMatchCategories } from "../utils/har-utils"; +import { TruncatedText } from "./TruncatedText"; 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; + searchQuery?: string; } -const Section: React.FC = ({ - title, - icon, - defaultOpen = false, - children, - timingChart, -}) => { - const [isOpen, setIsOpen] = useState(defaultOpen); +type DetailTabKey = + | "request-headers" + | "request-body" + | "response-headers" + | "response-content"; - return ( -
- - {isOpen &&
{children}
} -
- ); -}; - -const CopyButton: React.FC<{ text: string }> = ({ text }) => { +const CopyButton: React.FC<{ + text: string; + className?: string; + label?: string; +}> = ({ text, className, label }) => { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -86,8 +42,10 @@ const CopyButton: React.FC<{ text: string }> = ({ text }) => {
+
); }; diff --git a/pages/utilities/har-file-viewer.tsx b/pages/utilities/har-file-viewer.tsx index 7586dfa..7e60403 100644 --- a/pages/utilities/har-file-viewer.tsx +++ b/pages/utilities/har-file-viewer.tsx @@ -1,35 +1,8 @@ -import { useCallback, useMemo, useState, Fragment, useEffect } from "react"; -import { BeforeMount, Editor } from "@monaco-editor/react"; -import { - FilterType, - getColorForTime, - getFilterType, - HarData, - HarEntry, - HarTableProps, - isBase64, - tryParseJSON, - getMatchCategories, - MatchCategory, -} from "@/components/utils/har-utils"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ds/ButtonComponent"; -import Meta from "@/components/Meta"; -import Header from "@/components/Header"; +import CallToActionGrid from "@/components/CallToActionGrid"; import { CMDK } from "@/components/CMDK"; +import { Button } from "@/components/ds/ButtonComponent"; import { Card } from "@/components/ds/CardComponent"; -import UploadIcon from "@/components/icons/UploadIcon"; -import PageHeader from "@/components/PageHeader"; -import CallToActionGrid from "@/components/CallToActionGrid"; -import HarFileViewerSEO from "@/components/seo/HarFileViewerSEO"; -import { HarWaterfall } from "@/components/har-waterfall"; -import { Table, BarChart3, Filter, Search, X } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ds/PopoverComponent"; -import { Input } from "@/components/ds/InputComponent"; +import { Checkbox } from "@/components/ds/CheckboxComponent"; import { Command, CommandEmpty, @@ -38,10 +11,38 @@ import { CommandItem, CommandList, } from "@/components/ds/CommandMenu"; -import { Checkbox } from "@/components/ds/CheckboxComponent"; -import SearchHighlightText from "@/components/SearchHighlightText"; +import { Input } from "@/components/ds/InputComponent"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ds/PopoverComponent"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ds/TabsComponent"; +import { HarWaterfall } from "@/components/har-waterfall"; +import Header from "@/components/Header"; +import UploadIcon from "@/components/icons/UploadIcon"; import MatchIndicators from "@/components/MatchIndicators"; import MatchSummaryPills from "@/components/MatchSummaryPills"; +import Meta from "@/components/Meta"; +import PageHeader from "@/components/PageHeader"; +import SearchHighlightText from "@/components/SearchHighlightText"; +import HarFileViewerSEO from "@/components/seo/HarFileViewerSEO"; +import { + FilterType, + getColorForTime, + getFilterType, + getMatchCategories, + HarData, + HarEntry, + HarTableProps, + isBase64, + MatchCategory, + tryParseJSON, +} from "@/components/utils/har-utils"; +import { cn } from "@/lib/utils"; +import { BeforeMount, Editor } from "@monaco-editor/react"; +import { BarChart3, Filter, Search, Table, X } from "lucide-react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; interface MultiSelectComboboxProps { data: { value: string; label: string }[]; @@ -114,7 +115,7 @@ export default function HARFileViewer() { ); const [harData, setHarData] = useState(null); const [activeFilter, setActiveFilter] = useState("All"); - const [viewMode, setViewMode] = useState<"table" | "waterfall">("table"); + const [viewMode, setViewMode] = useState<"table" | "waterfall">("waterfall"); const [statusFilter, setStatusFilter] = useState([]); const [isInitialized, setIsInitialized] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -385,29 +386,23 @@ export default function HARFileViewer() { ))}
-
- -