diff --git a/studio/src/components/analytics/data-table-faceted-filter.tsx b/studio/src/components/analytics/data-table-faceted-filter.tsx index 052b279a2f..0672a20133 100644 --- a/studio/src/components/analytics/data-table-faceted-filter.tsx +++ b/studio/src/components/analytics/data-table-faceted-filter.tsx @@ -19,7 +19,7 @@ import { PlusCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons"; import { Column } from "@tanstack/react-table"; import { CustomOptions } from "@wundergraph/cosmo-connect/dist/platform/v1/platform_pb"; -import { ComponentType, useEffect, useState } from "react"; +import { ComponentType, useEffect, useMemo, useState } from "react"; import { MdTextRotationNone } from "react-icons/md"; import { Input } from "../ui/input"; import { Slider } from "../ui/slider"; @@ -37,6 +37,7 @@ interface DataTableFacetedFilter { column?: Column; id: string; onSelect?: (value?: any) => void; + validateSelection?: (value: string[]) => boolean; // Returns true if valid, false if invalid selectedOptions?: string[]; title?: string; options: Option[]; @@ -177,32 +178,21 @@ const regularFilter = (value: string, search: string) => { return 0; }; -const areAllFilteredOptionsSelected = ({ - selectedValues, - filteredOptions, - options, -}: { - selectedValues: Set; - filteredOptions: Option[]; - options: Option[]; -}) => { - if ( - filteredOptions.length === options.length && - selectedValues.size === filteredOptions.length - ) { - return true; - } - return filteredOptions.every((option) => selectedValues.has(option.value)); -}; - export function DataTableFilterCommands({ onSelect, + validateSelection, selectedOptions, title, options, customOptions, }: DataTableFacetedFilter) { - const selectedValues = new Set(selectedOptions); + // Memoized Set for efficient operations and automatic deduplication + // - Use selectedValues (Set) for: display checks (size, has), iteration (Array.from) + // - Use selectedOptions (Array) for: building new filter arrays to pass to onSelect + const selectedValues = useMemo( + () => new Set(selectedOptions ?? []), + [selectedOptions], + ); const [input, setInput] = useState(""); const [range, setRange] = useState<{ start: number; end: number }>({ start: 0, @@ -210,8 +200,6 @@ export function DataTableFilterCommands({ }); let content: React.ReactNode; - // the options are filtered based on the search input - const [filteredOptions, setFilteredOptions] = useState(options); const [shouldPrefixSearch, setShouldPrefixSearch] = useState(false); const [searchValue, setSearchValue] = useState(undefined); @@ -239,26 +227,23 @@ export function DataTableFilterCommands({ }: { rangeValue: { start: number; end: number }; }) => { - selectedValues.clear(); setRange({ start: rangeValue.start, end: rangeValue.end, }); - selectedValues.add( + // Create new filter values without mutating selectedValues + const filterValues = [ JSON.stringify({ label: (rangeValue.start * 10 ** 9).toString(), value: (rangeValue.start * 10 ** 9).toString(), operator: 4, }), - ); - selectedValues.add( JSON.stringify({ label: (rangeValue.end * 10 ** 9).toString(), value: (rangeValue.end * 10 ** 9).toString(), operator: 5, }), - ); - const filterValues = Array.from(selectedValues); + ]; onSelect?.(filterValues); }; @@ -280,14 +265,28 @@ export function DataTableFilterCommands({ className="flex-shrink-0" disabled={!input} onClick={() => { - selectedValues.add( - JSON.stringify({ - label: input, - value: input, - operator: 0, - }), - ); - const filterValues = Array.from(selectedValues); + const newValue = JSON.stringify({ + label: input, + value: input, + operator: 0, + }); + + // Check if already exists using Set for O(1) lookup + if (selectedValues.has(newValue)) { + setInput(""); // Clear input for duplicate + return; // Already exists, don't add duplicate + } + + // Build new filter array from selectedOptions (source of truth) + const filterValues = [...(selectedOptions ?? []), newValue]; + + // Validate BEFORE calling onSelect + if (validateSelection && !validateSelection(filterValues)) { + // Don't clear input - let user see what they tried to add + // The parent will show a toast with the error message + return; // Validation failed + } + onSelect?.(filterValues); setInput(""); }} @@ -315,8 +314,12 @@ export function DataTableFilterCommands({ variant="ghost" className="flex-shrink-0 text-muted-foreground" onClick={() => { - selectedValues.delete(JSON.stringify(selected)); - const filterValues = Array.from(selectedValues); + // Build new filter array by removing the item from selectedOptions + const filterValues = (selectedOptions ?? []).filter( + (opt) => opt !== val, + ); + + // Removal doesn't need validation (always allowed) onSelect?.( filterValues.length ? filterValues : undefined, ); @@ -375,19 +378,6 @@ export function DataTableFilterCommands({ break; } - useEffect(() => { - if (!searchValue) { - setFilteredOptions(options); - return; - } - const filtered = options.filter((option) => - shouldPrefixSearch - ? option.label.toLowerCase().startsWith(searchValue.toLowerCase()) - : option.label.toLowerCase().includes(searchValue.toLowerCase()), - ); - setFilteredOptions(filtered); - }, [options, searchValue, shouldPrefixSearch]); - return ( ({ { - if (isSelected) { - selectedValues.delete(option.value); - } else { - selectedValues.add(option.value); + // Build new filter array from selectedOptions (source of truth) + const filterValues = isSelected + ? (selectedOptions ?? []).filter( + (v) => v !== option.value, + ) + : [...(selectedOptions ?? []), option.value]; + + // Validate BEFORE calling onSelect to prevent optimistic UI updates + if ( + filterValues.length > 0 && + validateSelection && + !validateSelection(filterValues) + ) { + // Validation failed - don't call onSelect, UI stays unchanged + return; } - const filterValues = Array.from(selectedValues); + onSelect?.( filterValues.length ? filterValues : undefined, ); @@ -468,40 +469,10 @@ export function DataTableFilterCommands({ )} <> - -
- - - {selectedValues.size > 0 && ( - <> - + {selectedValues.size > 0 && ( + <> + +
- - )} -
+
+ + )} )} @@ -525,6 +496,7 @@ export function DataTableFilterCommands({ export function DataTableFacetedFilter({ id, onSelect, + validateSelection, selectedOptions, title, options, @@ -577,6 +549,7 @@ export function DataTableFacetedFilter({ ({ tableRef, @@ -153,6 +166,37 @@ export function AnalyticsDataTable({ }; const applyNewParams = useApplyParams(); + const { toast } = useToast(); + + // Safety net: Validate URL on initial load (e.g., user pastes malicious URL in browser) + // While onColumnFiltersChange (below) catches most cases via useSyncTableWithQuery, + // this catches the edge case where page loads with bad URL before table is fully initialized + useEffect(() => { + if (!router.isReady || !router.query.filterState) return; + + const { exceeded, reason } = checkFilterLimits( + router, + router.query.filterState as string, + ); + + if (exceeded) { + toast({ + title: "Filter limit reached", + description: `${reason}. Filters have been reset.`, + }); + + // Reset to clean URL by removing filterState entirely + const { filterState, ...cleanQuery } = router.query; + router.replace( + { + query: cleanQuery, + }, + undefined, + { shallow: true }, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, router.query.filterState]); const table = useReactTable({ data, @@ -180,12 +224,44 @@ export function AnalyticsDataTable({ onColumnFiltersChange: (t) => { if (typeof t === "function") { const newVal = functionalUpdate(t, state.columnFilters); + + // Check if we're removing filters (allow) vs adding (check limit) + // Count total filter values before and after + const oldTotalValues = state.columnFilters.reduce((sum, f) => { + const val = f.value as string[] | undefined; + return sum + (val?.length ?? 0); + }, 0); + const newTotalValues = newVal.reduce((sum, f) => { + const val = f.value as string[] | undefined; + return sum + (val?.length ?? 0); + }, 0); + const isRemoving = + newTotalValues < oldTotalValues || + newVal.length < state.columnFilters.length; + let stringifiedFilters; try { stringifiedFilters = JSON.stringify(newVal); } catch { stringifiedFilters = "[]"; } + + // Check URL length and filter size before applying (only if adding/modifying, not removing) + if (!isRemoving) { + const { exceeded, reason } = checkFilterLimits( + router, + stringifiedFilters, + ); + + if (exceeded) { + toast({ + title: "Filter limit reached", + description: `${reason}. Please remove some filters before adding new ones.`, + }); + return; // Early return prevents filter from being applied + } + } + applyNewParams({ filterState: stringifiedFilters, }); @@ -254,9 +330,63 @@ export function AnalyticsDataTable({ setRefreshInterval(val); }; + // Check if current URL is at or near the limit + const currentUrlLength = useMemo(() => { + return calculateUrlLength(router, {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query, router.asPath]); + + const isUrlLimitReached = currentUrlLength >= MAX_URL_LENGTH; + const filtersList = getDataTableFilters(table, filters); const selectedFilters = table.getState().columnFilters; + // ALWAYS add validation to check if new filter would exceed URL limit + const filtersListWithValidation = useMemo(() => { + return filtersList.map((filter) => { + return { + ...filter, + // ALWAYS validate selection to check if NEW addition would exceed the limit + validateSelection: (value: string[]) => { + // Build the new filter state to check URL length + const newSelectedFilters = [...selectedFilters]; + const index = newSelectedFilters.findIndex((f) => f.id === filter.id); + + if (index >= 0) { + newSelectedFilters[index] = { id: filter.id, value: value }; + } else { + newSelectedFilters.push({ id: filter.id, value: value }); + } + + let stringifiedFilters; + try { + stringifiedFilters = JSON.stringify(newSelectedFilters); + } catch { + stringifiedFilters = "[]"; + } + + const { exceeded, reason } = checkFilterLimits( + router, + stringifiedFilters, + ); + + if (exceeded) { + toast({ + title: "Filter limit reached", + description: `${reason}. Please remove some filters before adding new ones.`, + }); + return false; // Validation failed - prevents onSelect from being called + } + + return true; // Validation passed - allows onSelect to be called + }, + onSelect: filter.onSelect, // Use original onSelect without wrapping + }; + }); + // Only filtersList and selectedFilters should trigger recalculation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersList, selectedFilters]); + useSyncTableWithQuery({ table, selectedGroup, @@ -336,7 +466,21 @@ export function AnalyticsDataTable({ onChange={onDateRangeChange} calendarDaysLimit={tracingRetention} /> - + + {isUrlLimitReached && ( + + +
+ +
+
+ + Maximum URL length of {MAX_URL_LENGTH.toLocaleString()}{" "} + characters reached. Please remove some filters before adding new + ones. + +
+ )}
({
table.resetColumnFilters()} /> diff --git a/studio/src/components/analytics/filters.tsx b/studio/src/components/analytics/filters.tsx index 7edaacc0c8..eae4a47470 100644 --- a/studio/src/components/analytics/filters.tsx +++ b/studio/src/components/analytics/filters.tsx @@ -11,6 +11,7 @@ export interface AnalyticsFilter { title: string; selectedOptions?: string[]; onSelect?: (value?: string[]) => void; + validateSelection?: (value: string[]) => boolean; // Returns true if valid, false if invalid options: Array<{ label: string; value: string; diff --git a/studio/src/components/analytics/metrics.tsx b/studio/src/components/analytics/metrics.tsx index 1bc4de76ad..9154927614 100644 --- a/studio/src/components/analytics/metrics.tsx +++ b/studio/src/components/analytics/metrics.tsx @@ -19,6 +19,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useToast } from "@/components/ui/use-toast"; import useWindowSize from "@/hooks/use-window-size"; import { formatDurationMetric, @@ -46,9 +47,11 @@ import { useRouter } from "next/router"; import React, { useCallback, useContext, + useEffect, useId, useMemo, useState, + useRef, } from "react"; import { Area, @@ -63,6 +66,154 @@ import { import { useWorkspace } from "@/hooks/use-workspace"; import { useCurrentOrganization } from "@/hooks/use-current-organization"; +export const MAX_URL_LENGTH = 10000; +export const MAX_FILTER_SIZE = 10 * 1024; // 10KB in bytes + +/** + * Calculates the byte size of a string (UTF-8 encoded) + */ +export const calculateByteSize = (str: string): number => { + return new Blob([str]).size; +}; + +/** + * Checks if filter state exceeds URL length or size limits. + * Returns an object with exceeded flag and reason message. + */ +export const checkFilterLimits = ( + router: ReturnType, + filterState: string, +): { exceeded: boolean; reason?: string } => { + const urlLength = calculateUrlLength(router, { filterState }); + const filterSize = calculateByteSize(filterState); + + if (urlLength > MAX_URL_LENGTH) { + return { + exceeded: true, + reason: `URL would exceed maximum length of ${MAX_URL_LENGTH.toLocaleString()} characters`, + }; + } + + if (filterSize > MAX_FILTER_SIZE) { + return { + exceeded: true, + reason: `Filter state would exceed maximum size of ${(MAX_FILTER_SIZE / 1024).toFixed(0)}KB`, + }; + } + + return { exceeded: false }; +}; + +/** + * Calculates the length of the URL that would be generated from the given query parameters. + * This simulates what the URL would look like after applying the new parameters. + * + * Note: We calculate the full URL including origin because browsers have limits on total URL length. + */ +export const calculateUrlLength = ( + router: ReturnType, + newParams: Record, +): number => { + // Merge existing query params with new params + // Remove params that are being set to null, keep others, and add/update new params + const mergedQuery: Record = {}; + + // First, copy existing params that aren't being replaced or removed + for (const [key, value] of Object.entries(router.query)) { + if (!newParams.hasOwnProperty(key)) { + // Keep existing param if not in newParams + if (value !== undefined && value !== null) { + mergedQuery[key] = value as string | string[]; + } + } else if (newParams[key] !== null) { + // Keep existing param if newParams[key] is not null (will be overridden below) + if (value !== undefined && value !== null) { + mergedQuery[key] = value as string | string[]; + } + } + // If newParams[key] is null, we skip it (removes the param) + } + + // Then, add/update with new params (excluding null values) + for (const [key, value] of Object.entries(newParams)) { + if (value !== null && value !== undefined) { + mergedQuery[key] = value; + } + } + + // Build the query string - Next.js formats arrays as multiple key=value pairs + const queryParts: string[] = []; + for (const [key, value] of Object.entries(mergedQuery)) { + const encodedKey = encodeURIComponent(key); + if (Array.isArray(value)) { + // Arrays become multiple query params with same key + for (const v of value) { + queryParts.push(`${encodedKey}=${encodeURIComponent(String(v))}`); + } + } else { + queryParts.push(`${encodedKey}=${encodeURIComponent(String(value))}`); + } + } + + const queryString = queryParts.join("&"); + + // Get the pathname (without existing query string) + const pathname = router.asPath.split("?")[0]; + + // Construct the full URL (origin + pathname + query string) + // Browsers limit the full URL length, so we include origin + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const fullUrl = queryString + ? `${origin}${pathname}?${queryString}` + : `${origin}${pathname}`; + + return fullUrl.length; +}; + +/** + * Checks if adding a new filter value would exceed the URL length or size limits. + * Returns true if either limit would be exceeded, false otherwise. + */ +export const wouldExceedUrlLimit = ( + router: ReturnType, + currentFilters: { id: string; value: string[] }[], + filterId: string, + newValue: string[], +): boolean => { + if (!newValue || newValue.length === 0) { + // Removing filters - always allow + return false; + } + + // Create a deep copy to avoid mutating the original + const newSelected = currentFilters.map((f) => ({ + ...f, + value: [...f.value], + })); + const index = newSelected.findIndex((f) => f.id === filterId); + + // Update or add the filter + if (index >= 0) { + newSelected[index].value = newValue; + } else { + newSelected.push({ + id: filterId, + value: newValue, + }); + } + + let stringifiedFilters; + try { + stringifiedFilters = JSON.stringify(newSelected); + } catch { + stringifiedFilters = "[]"; + } + + // Use shared validation function + const { exceeded } = checkFilterLimits(router, stringifiedFilters); + return exceeded; +}; + export const getInfoTip = (range?: number) => { switch (range) { case 72: @@ -88,6 +239,7 @@ const useTimeRange = () => { const useSelectedFilters = () => { const router = useRouter(); + const { toast } = useToast(); const selectedFilters = useMemo(() => { try { @@ -97,20 +249,51 @@ const useSelectedFilters = () => { } }, [router.query.filterState]); + // Validate URL length and filter size when filters are loaded from URL (e.g., manual manipulation) + useEffect(() => { + if (!router.isReady || !router.query.filterState) return; + + const { exceeded, reason } = checkFilterLimits( + router, + router.query.filterState as string, + ); + + if (exceeded) { + toast({ + title: "Filter limit reached", + description: `${reason}. Filters have been reset.`, + }); + + // Reset to clean URL by removing filterState entirely + const { filterState, ...cleanQuery } = router.query; + router.replace( + { + query: cleanQuery, + }, + undefined, + { shallow: true }, + ); + } + // Only depend on the specific values we check, not the whole router/toast objects + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, router.query.filterState]); + return selectedFilters as { id: string; value: string[] }[]; }; export const useMetricsFilters = (filters: AnalyticsViewResultFilter[]) => { const router = useRouter(); + const { toast } = useToast(); const applyNewParams = useCallback( (newParams: Record, unset?: string[]) => { - const q = Object.fromEntries( + const mergedParams = Object.fromEntries( Object.entries(router.query).filter(([key]) => !unset?.includes(key)), ); + router.push({ query: { - ...q, + ...mergedParams, ...newParams, }, }); @@ -125,8 +308,11 @@ export const useMetricsFilters = (filters: AnalyticsViewResultFilter[]) => { ...filter, id: filter.columnName, onSelect: (value) => { - const newSelected = [...selectedFilters]; - + // Create a deep copy to avoid mutating the original + const newSelected = selectedFilters.map((f) => ({ + ...f, + value: [...f.value], + })); const index = newSelected.findIndex((f) => f.id === filter.columnName); if (!value || value.length === 0) { @@ -148,6 +334,7 @@ export const useMetricsFilters = (filters: AnalyticsViewResultFilter[]) => { } catch { stringifiedFilters = "[]"; } + applyNewParams({ filterState: stringifiedFilters, }); @@ -172,10 +359,59 @@ export const useMetricsFilters = (filters: AnalyticsViewResultFilter[]) => { }); }; + // Check if current URL is at or near the limit + // We need to recalculate when router.query changes + const currentUrlLength = useMemo(() => { + return calculateUrlLength(router, {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query, router.asPath]); + + const isUrlLimitReached = currentUrlLength >= MAX_URL_LENGTH; + + // Use a ref to track the latest selectedFilters to avoid stale closures + const selectedFiltersRef = useRef(selectedFilters); + selectedFiltersRef.current = selectedFilters; + + const filtersListWithValidation = useMemo(() => { + return filtersList.map((filter) => { + return { + ...filter, + // ALWAYS validate selection to check if NEW addition would exceed the limit + validateSelection: (value: string[]) => { + // Use ref to get latest selectedFilters to avoid stale closure + const currentSelectedFilters = selectedFiltersRef.current; + + // Check if adding/modifying this filter would exceed the limit + if ( + wouldExceedUrlLimit( + router, + currentSelectedFilters, + filter.id, + value, + ) + ) { + toast({ + title: "Filter limit reached", + description: `Maximum URL length of ${MAX_URL_LENGTH.toLocaleString()} characters reached. Please remove some filters before adding new ones.`, + }); + return false; // Validation failed - prevents onSelect from being called + } + + return true; // Validation passed - allows onSelect to be called + }, + onSelect: filter.onSelect, // Use original onSelect without wrapping + }; + }); + + // Only filtersList should trigger recalculation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersList]); + return { - filtersList, + filtersList: filtersListWithValidation, selectedFilters, resetFilters, + isUrlLimitReached, }; }; @@ -364,12 +600,7 @@ interface SparklineProps { } const Sparkline: React.FC = (props) => { - const { - timeRange = 24, - valueFormatter, - syncId, - className, - } = props; + const { timeRange = 24, valueFormatter, syncId, className } = props; const id = useId(); const { data, ticks, domain, timeFormatter } = useChartData( diff --git a/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/analytics/index.tsx b/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/analytics/index.tsx index 07f32c9e8c..9bb8ed9049 100644 --- a/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/analytics/index.tsx +++ b/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/analytics/index.tsx @@ -25,6 +25,11 @@ import { import { Button } from "@/components/ui/button"; import { Loader } from "@/components/ui/loader"; import { Spacer } from "@/components/ui/spacer"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useFeatureLimit } from "@/hooks/use-feature-limit"; import { NextPageWithLayout } from "@/lib/page"; import { createConnectQueryKey, useQuery } from "@connectrpc/connect-query"; @@ -40,9 +45,7 @@ import { getGraphMetrics, getMetricsErrorRate, } from "@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery"; -import { - AnalyticsViewResultFilter -} from "@wundergraph/cosmo-connect/dist/platform/v1/platform_pb"; +import { AnalyticsViewResultFilter } from "@wundergraph/cosmo-connect/dist/platform/v1/platform_pb"; import { formatISO } from "date-fns"; import { useContext } from "react"; @@ -64,9 +67,8 @@ const OverviewToolbar = ({ const isFetching = useIsFetching(); - const { filtersList, selectedFilters, resetFilters } = useMetricsFilters( - filters ?? [], - ); + const { filtersList, selectedFilters, resetFilters, isUrlLimitReached } = + useMetricsFilters(filters ?? []); const applyParams = useApplyParams(); @@ -103,7 +105,7 @@ const OverviewToolbar = ({ return (
-
+
resetFilters()} /> + {isUrlLimitReached && ( + + +
+ +
+
+ + Maximum URL length of 10,000 characters reached. Please remove + some filters before adding new ones. + +
+ )}
@@ -207,7 +222,10 @@ const AnalyticsPage: NextPageWithLayout = () => {
- +
); };