From 0fe29ee489693430beacab33a50836525ac019f9 Mon Sep 17 00:00:00 2001 From: Rodrigo Mansueli Nunes Date: Thu, 16 Oct 2025 15:30:51 -0300 Subject: [PATCH 1/2] feat: add search --- .../pages/utilities/har-file-viewer.test.tsx | 69 +++++ components/har-waterfall/HarWaterfall.tsx | 65 ++++- pages/utilities/har-file-viewer.tsx | 260 ++++++++++++++---- 3 files changed, 341 insertions(+), 53 deletions(-) diff --git a/__tests__/pages/utilities/har-file-viewer.test.tsx b/__tests__/pages/utilities/har-file-viewer.test.tsx index 8a62239..150eba6 100644 --- a/__tests__/pages/utilities/har-file-viewer.test.tsx +++ b/__tests__/pages/utilities/har-file-viewer.test.tsx @@ -123,4 +123,73 @@ describe("HARFileViewer", () => { const row1 = within(rows[0]).getByTestId("column-status-code"); expect(row1).toHaveTextContent("200"); }); + + test("should filter requests based on search query", async () => { + const user = userEvent.setup(); + 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); + + // Wait for all requests to be displayed + await screen.findByText("https://example.com/api/test"); + await screen.findByText("https://example.com/css/style.css"); + + // Find the search input + const searchInput = screen.getByPlaceholderText( + "Search in URLs, headers, requests, and responses..." + ); + + // Search for "api" - should only show the first request + await user.type(searchInput, "api"); + + // Wait for debounce (300ms) + rendering time + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Should still see the api request + expect(screen.getByText("https://example.com/api/test")).toBeInTheDocument(); + + // Should not see the css request (it should be filtered out) + const rows = screen.queryAllByTestId("table-row"); + expect(rows.length).toBe(1); + }); + + test("should clear search query when clear button is clicked", async () => { + const user = userEvent.setup(); + 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); + + // Wait for requests to be displayed + await screen.findByText("https://example.com/api/test"); + + // Find and use the search input + const searchInput = screen.getByPlaceholderText( + "Search in URLs, headers, requests, and responses..." + ); + await user.type(searchInput, "api"); + + // Wait for debounce + await new Promise((resolve) => setTimeout(resolve, 400)); + + // Find the clear button (it should appear when there's text) + const clearButton = screen.getByTitle("Clear search"); + await user.click(clearButton); + + // Search input should be empty + expect(searchInput).toHaveValue(""); + + // Both requests should be visible again + await screen.findByText("https://example.com/api/test"); + await screen.findByText("https://example.com/css/style.css"); + }); }); diff --git a/components/har-waterfall/HarWaterfall.tsx b/components/har-waterfall/HarWaterfall.tsx index e295585..e224ab6 100644 --- a/components/har-waterfall/HarWaterfall.tsx +++ b/components/har-waterfall/HarWaterfall.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useCallback, useMemo } from "react"; -import { HarEntry, FilterType, getFilterType } from "../utils/har-utils"; +import { HarEntry, FilterType, getFilterType, isBase64 } from "../utils/har-utils"; import { WaterfallCanvas } from "./WaterfallCanvas"; import { WaterfallTooltip } from "./WaterfallTooltip"; import { WaterfallLegend } from "./WaterfallLegend"; @@ -11,12 +11,14 @@ interface HarWaterfallProps { entries: HarEntry[]; activeFilter: FilterType; className?: string; + searchQuery?: string; } export const HarWaterfall: React.FC = ({ entries, activeFilter, className = "", + searchQuery = "", }) => { const containerRef = useRef(null); const [hoveredEntry, setHoveredEntry] = useState<{ @@ -36,11 +38,64 @@ export const HarWaterfall: React.FC = ({ } | null>(null); const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 }); - // Filter entries based on active filter + // Filter entries based on active filter and search query const filteredEntries = useMemo(() => { - if (activeFilter === "All") return entries; - return entries.filter((entry) => getFilterType(entry) === activeFilter); - }, [entries, activeFilter]); + 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) || + header.value.toLowerCase().includes(query) + ); + if (requestHeaderMatch) return true; + + // Search in response headers + const responseHeaderMatch = entry.response.headers.some( + (header) => + header.name.toLowerCase().includes(query) || + header.value.toLowerCase().includes(query) + ); + 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 { + contentToSearch = atob(contentToSearch); + } catch (e) { + // If decode fails, search in original + } + } + if (contentToSearch.toLowerCase().includes(query)) return true; + } + + return false; + }); + } + + return result; + }, [entries, activeFilter, searchQuery]); // Calculate timings for all entries const timings = useMemo(() => { diff --git a/pages/utilities/har-file-viewer.tsx b/pages/utilities/har-file-viewer.tsx index 706740e..37ffda7 100644 --- a/pages/utilities/har-file-viewer.tsx +++ b/pages/utilities/har-file-viewer.tsx @@ -21,12 +21,13 @@ 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 } from "lucide-react"; +import { Table, BarChart3, Filter, Search, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ds/PopoverComponent"; +import { Input } from "@/components/ds/InputComponent"; import { Command, CommandEmpty, @@ -111,6 +112,8 @@ export default function HARFileViewer() { const [viewMode, setViewMode] = useState<"table" | "waterfall">("table"); const [statusFilter, setStatusFilter] = useState([]); const [isInitialized, setIsInitialized] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); // Load view mode from localStorage on component mount useEffect(() => { @@ -136,6 +139,15 @@ export default function HARFileViewer() { } }, [viewMode, isInitialized]); + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery]); + const handleFileUpload = useCallback((file: File | undefined) => { if (!file) { return; @@ -189,6 +201,73 @@ export default function HARFileViewer() { [] ); + // Calculate filtered results count + const getFilteredCount = useCallback(() => { + if (!harData) return 0; + + let result = harData.log.entries; + + // Apply content type filter + if (activeFilter !== "All") { + result = result.filter((entry) => getFilterType(entry) === activeFilter); + } + + // Apply status code filter + if (statusFilter.length > 0) { + result = result.filter((entry) => + statusFilter.includes(entry.response.status.toString()) + ); + } + + // Apply search filter + if (debouncedSearchQuery) { + const query = debouncedSearchQuery.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) || + header.value.toLowerCase().includes(query) + ); + if (requestHeaderMatch) return true; + + // Search in response headers + const responseHeaderMatch = entry.response.headers.some( + (header) => + header.name.toLowerCase().includes(query) || + header.value.toLowerCase().includes(query) + ); + 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) { + let contentToSearch = entry.response.content.text; + if (isBase64(contentToSearch)) { + try { + contentToSearch = atob(contentToSearch); + } catch (e) { + // If decode fails, search in original + } + } + if (contentToSearch.toLowerCase().includes(query)) return true; + } + + return false; + }); + } + + return result.length; + }, [harData, activeFilter, statusFilter, debouncedSearchQuery]); + return (
-
-
- {( - [ - "All", - "XHR", - "JS", - "CSS", - "Img", - "Media", - "Other", - "Errors", - ] as FilterType[] - ).map((type) => ( +
+ {/* Search Input */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-10" + /> + {searchQuery && ( + + )} +
+ {/* Results count */} + {(debouncedSearchQuery || activeFilter !== "All" || statusFilter.length > 0) && ( +

+ Showing {getFilteredCount()} of {harData.log.entries.length} requests +

+ )} +
+ + {/* Filters and View Mode */} +
+
+ {( + [ + "All", + "XHR", + "JS", + "CSS", + "Img", + "Media", + "Other", + "Errors", + ] as FilterType[] + ).map((type) => ( + + ))} +
+
- ))} -
-
- -