From 2b815e861d65c96b5a59ae415e837a25506e18c4 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Tue, 16 Dec 2025 19:51:29 +0100 Subject: [PATCH 01/12] Add mergeStrategy, namespace, and enabled options to useURLState Enhances the useURLState hook with mergeStrategy ('replace', 'append', 'preserve-existing'), namespace, and enabled options for more flexible URL state management. Adds useURLStateReset hook to clear specific URL params and a hasQueryParams helper. Updates tests to cover new behaviors and options. --- .../src/hooks/URLState/index.test.tsx | 688 ++++++++++++++++++ .../components/src/hooks/URLState/index.tsx | 152 +++- 2 files changed, 815 insertions(+), 25 deletions(-) diff --git a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx b/ui/packages/shared/components/src/hooks/URLState/index.test.tsx index 433717c9779..f10005bd62c 100644 --- a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx +++ b/ui/packages/shared/components/src/hooks/URLState/index.test.tsx @@ -21,9 +21,11 @@ import { JSONParser, JSONSerializer, URLStateProvider, + hasQueryParams, useURLState, useURLStateBatch, useURLStateCustom, + useURLStateReset, } from './index'; // Mock the navigate function @@ -647,4 +649,690 @@ describe('URLState Hooks', () => { consoleSpy.mockRestore(); }); }); + + describe('mergeStrategy option', () => { + describe('replace strategy (default)', () => { + it('should replace existing value when no mergeStrategy is specified', async () => { + const {result} = renderHook(() => useURLState('param'), {wrapper: createWrapper()}); + + const [, setParam] = result.current; + + // Set initial value + act(() => { + setParam('initial'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('initial'); + }); + + // Replace with new value + act(() => { + setParam('replaced'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('replaced'); + expect(mockNavigateTo).toHaveBeenLastCalledWith( + '/test', + {param: 'replaced'}, + {replace: true} + ); + }); + }); + + it('should replace existing value when mergeStrategy is "replace"', async () => { + const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'replace'}), { + wrapper: createWrapper(), + }); + + const [, setParam] = result.current; + + act(() => { + setParam('initial'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('initial'); + }); + + act(() => { + setParam('replaced'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('replaced'); + }); + }); + + it('should replace array with string using replace strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'replace'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + act(() => { + setParam(['one', 'two']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['one', 'two']); + }); + + act(() => { + setParam('single'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('single'); + }); + }); + }); + + describe('preserve-existing strategy', () => { + it('should not overwrite existing value with preserve-existing strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'preserve-existing'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial value + act(() => { + setParam('existing'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('existing'); + }); + + // Try to set new value - should be ignored + act(() => { + setParam('should-be-ignored'); + }); + + await waitFor(() => { + // Value should remain unchanged + expect(result.current[0]).toBe('existing'); + }); + }); + + it('should set value when current is undefined with preserve-existing', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'preserve-existing'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set value when undefined + act(() => { + setParam('new-value'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('new-value'); + expect(mockNavigateTo).toHaveBeenCalledWith( + '/test', + {param: 'new-value'}, + {replace: true} + ); + }); + }); + + it('should set value when current is empty string with preserve-existing', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'preserve-existing'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set to empty string first + act(() => { + setParam(''); + }); + + await waitFor(() => { + expect(result.current[0]).toBe(''); + }); + + // Should overwrite empty string + act(() => { + setParam('new-value'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('new-value'); + }); + }); + + it('should set value when current is empty array with preserve-existing', async () => { + const {result} = renderHook( + () => + useURLState('param', { + mergeStrategy: 'preserve-existing', + alwaysReturnArray: true, + }), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set to empty array first + act(() => { + setParam([]); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual([]); + }); + + // Should overwrite empty array + act(() => { + setParam(['value']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['value']); + }); + }); + + it('should preserve existing array with preserve-existing strategy', async () => { + const {result} = renderHook( + () => + useURLState('param', { + mergeStrategy: 'preserve-existing', + alwaysReturnArray: true, + }), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial array + act(() => { + setParam(['existing']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['existing']); + }); + + // Try to set new array - should be ignored + act(() => { + setParam(['new']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['existing']); + }); + }); + }); + + describe('append strategy', () => { + it('should ignore undefined/null values with append strategy', async () => { + const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'append'}), { + wrapper: createWrapper(), + }); + + const [, setParam] = result.current; + + // Set initial value + act(() => { + setParam('existing'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('existing'); + }); + + // Try to append undefined - should be ignored + act(() => { + setParam(undefined); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('existing'); + }); + }); + + it('should merge two arrays and deduplicate with append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial array + act(() => { + setParam(['a', 'b']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b']); + }); + + // Append array with overlap + act(() => { + setParam(['b', 'c', 'd']); + }); + + await waitFor(() => { + // Should deduplicate 'b' + expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); + }); + }); + + it('should add string to array with append strategy (no duplicates)', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial array + act(() => { + setParam(['a', 'b']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b']); + }); + + // Append new string + act(() => { + setParam('c'); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); + }); + + it('should not add duplicate string to array with append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial array + act(() => { + setParam(['a', 'b']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b']); + }); + + // Try to append existing string + act(() => { + setParam('b'); + }); + + await waitFor(() => { + // Should remain unchanged (no duplicate) + expect(result.current[0]).toEqual(['a', 'b']); + }); + }); + + it('should merge string with array with append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial string + act(() => { + setParam('a'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('a'); + }); + + // Append array + act(() => { + setParam(['b', 'c']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); + }); + + it('should create array from two different strings with append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial string + act(() => { + setParam('first'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('first'); + }); + + // Append different string + act(() => { + setParam('second'); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['first', 'second']); + }); + }); + + it('should not create array when appending same string with append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial string + act(() => { + setParam('same'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('same'); + }); + + // Append same string (should deduplicate) + act(() => { + setParam('same'); + }); + + await waitFor(() => { + // Should remain a single string, not create array + expect(result.current[0]).toBe('same'); + }); + }); + + it('should set value when current is empty with append strategy', async () => { + const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'append'}), { + wrapper: createWrapper(), + }); + + const [, setParam] = result.current; + + // Append to undefined (should just set) + act(() => { + setParam('new-value'); + }); + + await waitFor(() => { + expect(result.current[0]).toBe('new-value'); + }); + }); + + it('should deduplicate when merging string array with overlapping values', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + + const [, setParam] = result.current; + + // Set initial array + act(() => { + setParam(['a', 'b', 'c']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c']); + }); + + // Append array with all duplicates and one new value + act(() => { + setParam(['a', 'b', 'c', 'd']); + }); + + await waitFor(() => { + // Should only add 'd' + expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); + }); + }); + }); + + describe('Real-world view defaults use case', () => { + it('should apply view defaults only when URL params are empty (preserve-existing)', async () => { + // Simulate view defaults being applied + const {result} = renderHook( + () => + useURLState('group_by', { + defaultValue: ['function_name'], + mergeStrategy: 'preserve-existing', + alwaysReturnArray: true, + }), + {wrapper: createWrapper()} + ); + + // Initial render - should use default + expect(result.current[0]).toEqual(['function_name']); + + const [, setGroupBy] = result.current; + + // User modifies the value + act(() => { + setGroupBy(['custom_label']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['custom_label']); + }); + + // Simulate view switching trying to apply defaults again (should be ignored) + act(() => { + setGroupBy(['function_name']); + }); + + await waitFor(() => { + // Should keep user's custom value + expect(result.current[0]).toEqual(['custom_label']); + }); + }); + + it('should accumulate filter values with append strategy', async () => { + // Simulate adding multiple filters + const {result} = renderHook( + () => + useURLState('filters', {mergeStrategy: 'append', alwaysReturnArray: true}), + {wrapper: createWrapper()} + ); + + const [, setFilters] = result.current; + + // Add first filter + act(() => { + setFilters(['cpu>50']); + }); + + await waitFor(() => { + expect(result.current[0]).toEqual(['cpu>50']); + }); + + // Add second filter + act(() => { + setFilters(['memory<1000']); + }); + + await waitFor(() => { + // Should append, not replace + expect(result.current[0]).toEqual(['cpu>50', 'memory<1000']); + }); + + // Try to add duplicate filter + act(() => { + setFilters(['cpu>50']); + }); + + await waitFor(() => { + // Should not add duplicate + expect(result.current[0]).toEqual(['cpu>50', 'memory<1000']); + }); + }); + }); + + describe('enabled option', () => { + it('should return undefined and no-op setter when enabled is false', async () => { + const {result} = renderHook(() => useURLState('param', {enabled: false}), { + wrapper: createWrapper(), + }); + + const [value, setParam] = result.current; + expect(value).toBeUndefined(); + + // Try to set value - should be no-op + act(() => { + setParam('should-not-work'); + }); + + await waitFor(() => { + expect(mockNavigateTo).not.toHaveBeenCalled(); + }); + }); + + it('should handle compare mode group_by use case', async () => { + const TestComponent = (): { + groupByA: string | string[] | undefined; + groupByB: string | string[] | undefined; + } => { + const [groupByA] = useURLState('group_by', {enabled: true, defaultValue: ['node']}); + const [groupByB] = useURLState('group_by', {enabled: false}); + return {groupByA, groupByB}; + }; + + const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); + + expect(result.current.groupByA).toEqual(['node']); + expect(result.current.groupByB).toBeUndefined(); + }); + }); + + describe('namespace option', () => { + it('should prefix param name with namespace', async () => { + const {result} = renderHook( + () => useURLState('setting', {namespace: 'view', defaultValue: 'default'}), + {wrapper: createWrapper()} + ); + + const [, setSetting] = result.current; + + act(() => { + setSetting('new-value'); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalledWith( + '/test', + {'view.setting': 'new-value'}, + {replace: true} + ); + }); + }); + + it('should allow multiple namespaces without conflict', async () => { + const TestComponent = (): { + setViewColor: (val: string | string[] | undefined) => void; + setAppColor: (val: string | string[] | undefined) => void; + } => { + const [, setViewColor] = useURLState('color', {namespace: 'view'}); + const [, setAppColor] = useURLState('color', {namespace: 'app'}); + return {setViewColor, setAppColor}; + }; + + const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); + + act(() => { + result.current.setViewColor('red'); + result.current.setAppColor('blue'); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenLastCalledWith( + '/test', + {'view.color': 'red', 'app.color': 'blue'}, + {replace: true} + ); + }); + }); + }); + + describe('useURLStateReset', () => { + it('should clear specified keys and preserve others', async () => { + mockLocation.search = '?param1=value1¶m2=value2¶m3=value3'; + + const TestComponent = (): { + reset: (keys: string[]) => void; + } => { + const reset = useURLStateReset(); + return {reset}; + }; + + const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); + + act(() => { + result.current.reset(['param1', 'param2']); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalledWith( + '/test', + {param1: undefined, param2: undefined, param3: 'value3'}, + {replace: true} + ); + }); + }); + + it('should throw error when used outside URLStateProvider', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useURLStateReset()); + }).toThrow('useURLStateReset must be used within a URLStateProvider'); + + consoleSpy.mockRestore(); + }); + }); + + describe('hasQueryParams helper', () => { + it('should return true/false based on params existence', () => { + expect(hasQueryParams({param1: 'value1'})).toBe(true); + expect(hasQueryParams({})).toBe(false); + expect(hasQueryParams({param1: undefined})).toBe(false); + expect(hasQueryParams({param1: ''})).toBe(false); + }); + + it('should exclude specified keys', () => { + const state = {routeParam: 'value1', queryParam: 'value2'}; + expect(hasQueryParams(state, ['routeParam'])).toBe(true); // queryParam exists + expect(hasQueryParams(state, ['routeParam', 'queryParam'])).toBe(false); // all excluded + }); + + it('should handle view switching scenario', () => { + const stateWithoutQuery = {'project-id': 'abc', 'view-slug': 'my-view'}; + expect(hasQueryParams(stateWithoutQuery, ['project-id', 'view-slug'])).toBe(false); + + const stateWithQuery = {...stateWithoutQuery, group_by: ['node']}; + expect(hasQueryParams(stateWithQuery, ['project-id', 'view-slug'])).toBe(true); + }); + }); + }); }); diff --git a/ui/packages/shared/components/src/hooks/URLState/index.tsx b/ui/packages/shared/components/src/hooks/URLState/index.tsx index d35e76c297e..b7abc5445be 100644 --- a/ui/packages/shared/components/src/hooks/URLState/index.tsx +++ b/ui/packages/shared/components/src/hooks/URLState/index.tsx @@ -210,6 +210,9 @@ interface Options { defaultValue?: string | string[]; debugLog?: boolean; alwaysReturnArray?: boolean; + mergeStrategy?: 'replace' | 'append' | 'preserve-existing'; + namespace?: string; + enabled?: boolean; } export const useURLState = ( @@ -221,78 +224,145 @@ export const useURLState = ( throw new Error('useURLState must be used within a URLStateProvider'); } - const {debugLog, defaultValue, alwaysReturnArray} = _options ?? {}; + const {debugLog, defaultValue, alwaysReturnArray, mergeStrategy, enabled, namespace} = + _options ?? {}; + + const effectiveParam = namespace != null ? `${namespace}.${param}` : param; const {state, setState} = context; + // Create no-op setter unconditionally to satisfy hooks rules + const noOpSetter = useCallback(() => {}, []); + const setParam: ParamValueSetter = useCallback( (val: ParamValue) => { if (debugLog === true) { - console.log('useURLState setParam', param, val); + console.log('useURLState setParam', effectiveParam, val); } // Just update state - Provider handles URL sync automatically! - setState(currentState => ({ - ...currentState, - [param]: val, - })); + setState(currentState => { + const currentValue = currentState[effectiveParam]; + let newValue: ParamValue; + + if (mergeStrategy === undefined || mergeStrategy === 'replace') { + newValue = val; + } else if (mergeStrategy === 'preserve-existing') { + // Only set if current is empty (including empty string) + if ( + currentValue === undefined || + currentValue === null || + currentValue === '' || + (Array.isArray(currentValue) && currentValue.length === 0) + ) { + newValue = val; + } else { + newValue = currentValue; // Keep existing + } + } else if (mergeStrategy === 'append') { + // Ignore undefined/null new values - keep current state + if (val === undefined || val === null) { + newValue = currentValue; + } else if (currentValue === undefined || currentValue === null || currentValue === '') { + // Current is empty, use new value + newValue = val; + } else if (Array.isArray(currentValue) && Array.isArray(val)) { + // Merge arrays and deduplicate + newValue = Array.from(new Set([...currentValue, ...val])); + } else if (Array.isArray(currentValue) && typeof val === 'string') { + // Add string to array if not present (deduplication) + newValue = currentValue.includes(val) ? currentValue : [...currentValue, val]; + } else if (typeof currentValue === 'string' && Array.isArray(val)) { + // Merge string with array and deduplicate + newValue = Array.from(new Set([currentValue, ...val])); + } else if (typeof currentValue === 'string' && typeof val === 'string') { + // Create array from both strings (deduplicate) + newValue = currentValue === val ? currentValue : [currentValue, val]; + } else { + // Fallback to replace for other cases + newValue = val; + } + } else { + newValue = val; + } + + return { + ...currentState, + [effectiveParam]: newValue, + }; + }); }, - [param, setState, debugLog] + [effectiveParam, setState, debugLog, mergeStrategy] ); if (debugLog === true) { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - console.log('useURLState state change', param, state[param]); + console.log('useURLState state change', effectiveParam, state[effectiveParam]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state[param]]); + }, [state[effectiveParam]]); } const value = useMemo(() => { - if (typeof state[param] === 'string') { + if (typeof state[effectiveParam] === 'string') { if (alwaysReturnArray === true) { if (debugLog === true) { - console.log('useURLState returning single string value as array for param', param, [ - state[param], - ]); + console.log( + 'useURLState returning single string value as array for param', + effectiveParam, + [state[effectiveParam]] + ); } - return [state[param]] as ParamValue; + return [state[effectiveParam]] as ParamValue; } if (debugLog === true) { - console.log('useURLState returning string value for param', param, state[param]); + console.log( + 'useURLState returning string value for param', + effectiveParam, + state[effectiveParam] + ); } - return state[param]; - } else if (state[param] != null && Array.isArray(state[param])) { - if (state[param]?.length === 1 && alwaysReturnArray !== true) { + return state[effectiveParam]; + } else if (state[effectiveParam] != null && Array.isArray(state[effectiveParam])) { + if (state[effectiveParam]?.length === 1 && alwaysReturnArray !== true) { if (debugLog === true) { console.log( 'useURLState returning first array value as string for param', - param, - state[param][0] + effectiveParam, + state[effectiveParam][0] ); } - return state[param]?.[0] as ParamValue; + return state[effectiveParam]?.[0] as ParamValue; } else { if (debugLog === true) { - console.log('useURLState returning array value for param', param, state[param]); + console.log( + 'useURLState returning array value for param', + effectiveParam, + state[effectiveParam] + ); } - return state[param]; + return state[effectiveParam]; } } - }, [state, param, alwaysReturnArray, debugLog]); + }, [state, effectiveParam, alwaysReturnArray, debugLog]); if (value == null) { if (debugLog === true) { console.log( 'useURLState returning defaultValue for param', - param, + effectiveParam, defaultValue, window.location.href ); } } + // Return early if hook is disabled (after all hooks have been called) + if (enabled === false) { + return [undefined as T, noOpSetter]; + } + return [(value ?? defaultValue) as T, setParam]; }; @@ -358,4 +428,36 @@ export const useURLStateBatch = (): ((callback: () => void) => void) => { return context.batchUpdates; }; +// Hook to reset/clear specific URL params +export const useURLStateReset = (): ((keys: string[]) => void) => { + const context = useContext(URLStateContext); + if (context === undefined) { + throw new Error('useURLStateReset must be used within a URLStateProvider'); + } + + return useCallback( + (keys: string[]) => { + context.setState(currentState => { + const newState = {...currentState}; + keys.forEach(key => { + newState[key] = undefined; + }); + return newState; + }); + }, + [context] + ); +}; + +// Helper to check if URL has query params (excluding specified keys) +export const hasQueryParams = ( + state: Record, + exclude: string[] = [] +): boolean => { + const params = Object.keys(state).filter( + k => !exclude.includes(k) && state[k] !== undefined && state[k] !== '' + ); + return params.length > 0; +}; + export default URLStateContext; From 7cf1a5351a7b0fd8ea025380c712ac0ef999e024 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Wed, 17 Dec 2025 09:53:31 +0100 Subject: [PATCH 02/12] Add view defaults and group-by handling to query state hooks Introduces support for view-specific defaults in useQueryState and related hooks, ensuring defaults are only applied when URL params are empty and do not overwrite existing values. --- .../ProfileFilters/useProfileFilters.ts | 17 ++- .../useProfileFiltersUrlState.ts | 45 +++++- .../profile/src/hooks/useQueryState.test.tsx | 119 ++++++++++++++++ .../shared/profile/src/hooks/useQueryState.ts | 133 ++++++++++++++++++ 4 files changed, 308 insertions(+), 6 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts index 50b8f471c71..76a69b90834 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts @@ -225,7 +225,14 @@ export const convertToProtoFilters = (profileFilters: ProfileFilter[]): Filter[] }); }; -export const useProfileFilters = (): { +interface UseProfileFiltersOptions { + suffix?: '_a' | '_b'; + viewDefaults?: ProfileFilter[]; +} + +export const useProfileFilters = ( + options: UseProfileFiltersOptions = {} +): { localFilters: ProfileFilter[]; appliedFilters: ProfileFilter[]; protoFilters: Filter[]; @@ -237,8 +244,13 @@ export const useProfileFilters = (): { removeFilter: (id: string) => void; updateFilter: (id: string, updates: Partial) => void; resetFilters: () => void; + applyViewDefaults: () => void; } => { - const {appliedFilters, setAppliedFilters} = useProfileFiltersUrlState(); + const {suffix, viewDefaults} = options; + const {appliedFilters, setAppliedFilters, applyViewDefaults} = useProfileFiltersUrlState({ + suffix, + viewDefaults, + }); const resetFlameGraphState = useResetFlameGraphState(); const [localFilters, setLocalFilters] = useState(appliedFilters ?? []); @@ -422,5 +434,6 @@ export const useProfileFilters = (): { removeFilter, updateFilter, resetFilters, + applyViewDefaults, }; }; diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts index b4a0f2aac06..b59fa4e1638 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts @@ -11,9 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {useMemo} from 'react'; +import {useCallback, useMemo} from 'react'; -import {useURLStateCustom, type ParamValueSetterCustom} from '@parca/components'; +import {useURLStateBatch, useURLStateCustom, type ParamValueSetterCustom} from '@parca/components'; import {safeDecode} from '@parca/utilities'; import {isPresetKey} from './filterPresets'; @@ -137,13 +137,25 @@ export const decodeProfileFilters = (encoded: string): ProfileFilter[] => { } }; -export const useProfileFiltersUrlState = (): { +interface UseProfileFiltersUrlStateOptions { + suffix?: '_a' | '_b'; + viewDefaults?: ProfileFilter[]; +} + +export const useProfileFiltersUrlState = ( + options: UseProfileFiltersUrlStateOptions = {} +): { appliedFilters: ProfileFilter[]; setAppliedFilters: ParamValueSetterCustom; + applyViewDefaults: () => void; } => { + const {suffix = '', viewDefaults} = options; + + const batchUpdates = useURLStateBatch(); + // Store applied filters in URL state for persistence using compact encoding const [appliedFilters, setAppliedFilters] = useURLStateCustom( - 'profile_filters', + `profile_filters${suffix}`, { parse: value => { return decodeProfileFilters(value as string); @@ -155,12 +167,37 @@ export const useProfileFiltersUrlState = (): { } ); + // Setter with preserve-existing strategy for applying view defaults + const [, setAppliedFiltersWithPreserve] = useURLStateCustom( + `profile_filters${suffix}`, + { + parse: value => { + return decodeProfileFilters(value as string); + }, + stringify: value => { + return encodeProfileFilters(value); + }, + defaultValue: [], + mergeStrategy: 'preserve-existing', + } + ); + const memoizedAppliedFilters = useMemo(() => { return appliedFilters ?? []; }, [appliedFilters]); + // Apply view defaults (only if URL is empty) + const applyViewDefaults = useCallback(() => { + if (viewDefaults === undefined || viewDefaults.length === 0) return; + + batchUpdates(() => { + setAppliedFiltersWithPreserve(viewDefaults); + }); + }, [viewDefaults, batchUpdates, setAppliedFiltersWithPreserve]); + return { appliedFilters: memoizedAppliedFilters, setAppliedFilters, + applyViewDefaults, }; }; diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx index b465d96db48..a80ec597c91 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx +++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx @@ -1218,4 +1218,123 @@ describe('useQueryState', () => { }); }); }); + + describe('View defaults and profile type parsing', () => { + it('should apply view defaults only when URL params are empty', async () => { + // Start with empty URL + mockLocation.search = ''; + + const viewDefaults = { + expression: 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}', + sumBy: ['function'], + groupBy: ['namespace'], + }; + + const {result} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Apply view defaults + act(() => { + result.current.applyViewDefaults(); + }); + + await waitFor(() => { + // Should set all defaults since URL is empty + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.expression).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}' + ); + expect(params.sum_by).toBe('function'); + expect(params.group_by).toBe('namespace'); + }); + + // Now set URL params manually + mockLocation.search = '?expression=custom{}&sum_by=line&group_by=pod'; + mockNavigateTo.mockClear(); + + const {result: result2} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Apply view defaults again + act(() => { + result2.current.applyViewDefaults(); + }); + + // Should NOT overwrite existing URL params (preserve-existing strategy) + // The hook shouldn't navigate since params already exist + await waitFor(() => { + // Either no navigation or navigation preserves existing values + if (mockNavigateTo.mock.calls.length > 0) { + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.expression).toBe('custom{}'); + expect(params.sum_by).toBe('line'); + expect(params.group_by).toBe('pod'); + } + }); + }); + + it('should parse profile type from expression using Query.parse()', () => { + // Test with profile type + mockLocation.search = + '?expression=process_cpu:samples:count:cpu:nanoseconds:delta{job="test"}'; + + const {result} = renderHook(() => useQueryState({}), {wrapper: createWrapper()}); + + expect(result.current.hasProfileType).toBe(true); + expect(result.current.profileTypeString).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta' + ); + expect(result.current.matchersOnly).toBe('{job="test"}'); + expect(result.current.fullExpression).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta{job="test"}' + ); + + // Test without profile type + mockLocation.search = '?expression={comm="prometheus"}'; + + const {result: result2} = renderHook(() => useQueryState({}), {wrapper: createWrapper()}); + + expect(result2.current.hasProfileType).toBe(false); + expect(result2.current.profileTypeString).toBe(''); + expect(result2.current.matchersOnly).toBe('{comm="prometheus"}'); + }); + + it('should manage group_by only for _a hook in comparison mode', async () => { + mockLocation.search = ''; + + // Render _a hook + const {result: resultA} = renderHook(() => useQueryState({suffix: '_a'}), { + wrapper: createWrapper(), + }); + + // Render _b hook + const {result: resultB} = renderHook(() => useQueryState({suffix: '_b'}), { + wrapper: createWrapper(), + }); + + // _a hook should have group-by methods + expect(resultA.current.groupBy).toBeDefined(); + expect(resultA.current.setGroupBy).toBeDefined(); + expect(resultA.current.isGroupByLoading).toBeDefined(); + + // _b hook should NOT have group-by methods + expect(resultB.current.groupBy).toBeUndefined(); + expect(resultB.current.setGroupBy).toBeUndefined(); + expect(resultB.current.isGroupByLoading).toBeUndefined(); + + // Set group_by via _a hook + act(() => { + resultA.current.setGroupBy?.(['namespace', 'pod']); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.group_by).toBe('namespace,pod'); + }); + }); + }); }); diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index bc31fceda9d..31b244758fe 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -22,6 +22,12 @@ import {useResetFlameGraphState} from '../ProfileView/hooks/useResetFlameGraphSt import {useResetStateOnProfileTypeChange} from '../ProfileView/hooks/useResetStateOnProfileTypeChange'; import {DEFAULT_EMPTY_SUM_BY, sumByToParam, useSumBy, useSumByFromParams} from '../useSumBy'; +interface ViewDefaults { + expression?: string; + sumBy?: string[]; + groupBy?: string[]; +} + interface UseQueryStateOptions { suffix?: '_a' | '_b'; // For comparison mode defaultExpression?: string; @@ -29,6 +35,8 @@ interface UseQueryStateOptions { defaultFrom?: number; defaultTo?: number; comparing?: boolean; // If true, don't auto-select for delta profiles + viewDefaults?: ViewDefaults; // View-specific defaults that don't overwrite URL params + sharedDefaults?: ViewDefaults; // Shared defaults across both comparison sides } interface UseQueryStateReturn { @@ -65,6 +73,21 @@ interface UseQueryStateReturn { // parsed query parsedQuery: Query | null; + + // Parsed expression components + hasProfileType: boolean; + profileTypeString: string; + matchersOnly: string; + fullExpression: string; + + // Group-by state (only for _a hook, undefined for _b) + groupBy?: string[]; + setGroupBy?: (groupBy: string[] | undefined) => void; + isGroupByLoading?: boolean; + + // Methods + applyViewDefaults: () => void; + resetQuery: () => void; } export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryStateReturn => { @@ -76,6 +99,8 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState defaultFrom, defaultTo, comparing = false, + viewDefaults, + sharedDefaults, } = options; const batchUpdates = useURLStateBatch(); @@ -101,6 +126,25 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const [sumByParam, setSumByParam] = useURLState(`sum_by${suffix}`); + // Group-by state - only enabled for _a hook (or when no suffix) + // This ensures only one hook manages the shared group_by param in comparison mode + const isGroupByEnabled = suffix === '' || suffix === '_a'; + const [groupByParam, setGroupByParam] = useURLState('group_by', { + enabled: isGroupByEnabled, + }); + + // Separate setters for applying view defaults with preserve-existing strategy + const [, setExpressionWithPreserve] = useURLState(`expression${suffix}`, { + mergeStrategy: 'preserve-existing', + }); + const [, setSumByWithPreserve] = useURLState(`sum_by${suffix}`, { + mergeStrategy: 'preserve-existing', + }); + const [, setGroupByWithPreserve] = useURLState('group_by', { + enabled: isGroupByEnabled, + mergeStrategy: 'preserve-existing', + }); + const [mergeFrom, setMergeFromState] = useURLState(`merge_from${suffix}`); const [mergeTo, setMergeToState] = useURLState(`merge_to${suffix}`); @@ -423,6 +467,56 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState [batchUpdates, setSelectionParam, setMergeFromState, setMergeToState] ); + // Apply view defaults to URL params (only if URL params are empty) + const applyViewDefaults = useCallback(() => { + batchUpdates(() => { + const defaults = suffix === '' || suffix === '_a' ? viewDefaults : sharedDefaults; + if (defaults === undefined) return; + + // Apply expression default using preserve-existing strategy + if (defaults.expression !== undefined) { + setExpressionWithPreserve(defaults.expression); + } + + // Apply sum_by default using preserve-existing strategy + if (defaults.sumBy !== undefined) { + setSumByWithPreserve(sumByToParam(defaults.sumBy)); + } + + // Apply group_by default only for _a hook using preserve-existing strategy + if (isGroupByEnabled && defaults.groupBy !== undefined) { + setGroupByWithPreserve(defaults.groupBy.join(',')); + } + }); + }, [ + batchUpdates, + suffix, + viewDefaults, + sharedDefaults, + setExpressionWithPreserve, + setSumByWithPreserve, + isGroupByEnabled, + setGroupByWithPreserve, + ]); + + // Reset query to default state + const resetQuery = useCallback(() => { + batchUpdates(() => { + setExpressionState(defaultExpression); + setSumByParam(undefined); + if (isGroupByEnabled) { + setGroupByParam(undefined); + } + }); + }, [ + batchUpdates, + setExpressionState, + defaultExpression, + setSumByParam, + isGroupByEnabled, + setGroupByParam, + ]); + const draftParsedQuery = useMemo(() => { try { return Query.parse(draftSelection.expression ?? ''); @@ -439,6 +533,24 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } }, [querySelection.expression]); + // Parse expression components using existing Query parser + const {hasProfileType, profileTypeString, matchersOnly, fullExpression} = useMemo(() => { + const expr = expression ?? defaultExpression; + const parsed = Query.parse(expr); + + const profileType = parsed.profileType(); + const profileTypeStr = profileType.toString(); + const hasProfile = profileTypeStr !== ''; + const matchers = `{${parsed.matchersString()}}`; + + return { + hasProfileType: hasProfile, + profileTypeString: profileTypeStr, + matchersOnly: matchers, + fullExpression: parsed.toString(), + }; + }, [expression, defaultExpression]); + return { // Current committed state querySelection, @@ -466,5 +578,26 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState draftParsedQuery, parsedQuery, + + // Parsed expression components + hasProfileType, + profileTypeString, + matchersOnly, + fullExpression, + + // Group-by state (only for _a hook) + ...(isGroupByEnabled + ? { + groupBy: groupByParam?.split(',').filter(Boolean), + setGroupBy: (groupBy: string[] | undefined) => { + setGroupByParam(groupBy?.join(',')); + }, + isGroupByLoading: false, + } + : {}), + + // Methods + applyViewDefaults, + resetQuery, }; }; From e3f4898717e80506a7ddb5956cd34c0d7e4b0913 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Sun, 21 Dec 2025 20:35:22 +0100 Subject: [PATCH 03/12] Add forceApplyFilters and improve view default handling Introduces a forceApplyFilters method to profile filter hooks for bypassing the preserve-existing strategy. Enhances useQueryState to support matchers-only view defaults by prepending the first available profile type, and ensures view defaults are reapplied when profile types finish loading. Also removes debug logging from useLabels. --- .../ProfileFilters/useProfileFilters.ts | 11 +- .../useProfileFiltersUrlState.ts | 19 ++- .../shared/profile/src/hooks/useLabels.ts | 8 -- .../shared/profile/src/hooks/useQueryState.ts | 122 +++++++++++++++++- ui/packages/shared/profile/src/index.tsx | 1 + 5 files changed, 142 insertions(+), 19 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts index 76a69b90834..cdd4df573d9 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts @@ -245,12 +245,14 @@ export const useProfileFilters = ( updateFilter: (id: string, updates: Partial) => void; resetFilters: () => void; applyViewDefaults: () => void; + forceApplyFilters: (filters: ProfileFilter[]) => void; } => { const {suffix, viewDefaults} = options; - const {appliedFilters, setAppliedFilters, applyViewDefaults} = useProfileFiltersUrlState({ - suffix, - viewDefaults, - }); + const {appliedFilters, setAppliedFilters, applyViewDefaults, forceApplyFilters} = + useProfileFiltersUrlState({ + suffix, + viewDefaults, + }); const resetFlameGraphState = useResetFlameGraphState(); const [localFilters, setLocalFilters] = useState(appliedFilters ?? []); @@ -435,5 +437,6 @@ export const useProfileFilters = ( updateFilter, resetFilters, applyViewDefaults, + forceApplyFilters, }; }; diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts index b59fa4e1638..32740fae154 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts @@ -148,6 +148,7 @@ export const useProfileFiltersUrlState = ( appliedFilters: ProfileFilter[]; setAppliedFilters: ParamValueSetterCustom; applyViewDefaults: () => void; + forceApplyFilters: (filters: ProfileFilter[]) => void; } => { const {suffix = '', viewDefaults} = options; @@ -172,12 +173,13 @@ export const useProfileFiltersUrlState = ( `profile_filters${suffix}`, { parse: value => { - return decodeProfileFilters(value as string); + const result = decodeProfileFilters(value as string); + return result; }, stringify: value => { - return encodeProfileFilters(value); + const result = encodeProfileFilters(value); + return result; }, - defaultValue: [], mergeStrategy: 'preserve-existing', } ); @@ -195,9 +197,20 @@ export const useProfileFiltersUrlState = ( }); }, [viewDefaults, batchUpdates, setAppliedFiltersWithPreserve]); + // Force apply filters (bypasses preserve-existing strategy) + const forceApplyFilters = useCallback( + (filters: ProfileFilter[]) => { + batchUpdates(() => { + setAppliedFilters(filters); + }); + }, + [batchUpdates, setAppliedFilters] + ); + return { appliedFilters: memoizedAppliedFilters, setAppliedFilters, applyViewDefaults, + forceApplyFilters, }; }; diff --git a/ui/packages/shared/profile/src/hooks/useLabels.ts b/ui/packages/shared/profile/src/hooks/useLabels.ts index ea0f34c7744..eb742b655e9 100644 --- a/ui/packages/shared/profile/src/hooks/useLabels.ts +++ b/ui/packages/shared/profile/src/hooks/useLabels.ts @@ -11,8 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {useEffect} from 'react'; - import {LabelsRequest, LabelsResponse, QueryServiceClient, ValuesRequest} from '@parca/client'; import {useGrpcMetadata} from '@parca/components'; import {millisToProtoTimestamp, sanitizeLabelValue} from '@parca/utilities'; @@ -70,10 +68,6 @@ export const useLabelNames = ( }, }); - useEffect(() => { - console.log('Label names query result:', {data, error, isLoading}); - }, [data, error, isLoading]); - return { result: {response: data, error: error as Error}, loading: isLoading, @@ -113,8 +107,6 @@ export const useLabelValues = ( }, }); - console.log('Label values query result:', {data, error, isLoading, labelName}); - return { result: {response: data ?? [], error: error as Error}, loading: isLoading, diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index 31b244758fe..cae1f77ffe4 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -13,10 +13,11 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; +import {ProfileTypesResponse} from '@parca/client'; import {DateTimeRange, useParcaContext, useURLState, useURLStateBatch} from '@parca/components'; import {Query} from '@parca/parser'; -import {QuerySelection} from '../ProfileSelector'; +import {IProfileTypesResult, QuerySelection, useProfileTypes} from '../ProfileSelector'; import {ProfileSelection, ProfileSelectionFromParams, ProfileSource} from '../ProfileSource'; import {useResetFlameGraphState} from '../ProfileView/hooks/useResetFlameGraphState'; import {useResetStateOnProfileTypeChange} from '../ProfileView/hooks/useResetStateOnProfileTypeChange'; @@ -26,6 +27,7 @@ interface ViewDefaults { expression?: string; sumBy?: string[]; groupBy?: string[]; + hasProfileFilters?: boolean; } interface UseQueryStateOptions { @@ -90,6 +92,32 @@ interface UseQueryStateReturn { resetQuery: () => void; } +/** + * Prepends the first available profile type to a matchers-only expression. + * Returns the original expression if it already has a profile type or if no profile types are available. + */ +function prependProfileTypeToMatchers( + expression: string, + profileTypesData: ProfileTypesResponse | undefined +): string { + if (!expression.trim().startsWith('{')) { + return expression; + } + + if (profileTypesData?.types == null || profileTypesData.types.length === 0) { + return expression; + } + + const firstProfileType = profileTypesData.types[0]; + const profileTypeString = `${firstProfileType.name}:${firstProfileType.sampleType}:${ + firstProfileType.sampleUnit + }:${firstProfileType.periodType}:${firstProfileType.periodUnit}${ + firstProfileType.delta ? ':delta' : '' + }`; + + return `${profileTypeString}${expression}`; +} + export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryStateReturn => { const {queryServiceClient: queryClient} = useParcaContext(); const { @@ -105,7 +133,9 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const batchUpdates = useURLStateBatch(); const resetFlameGraphState = useResetFlameGraphState(); - const resetStateOnProfileTypeChange = useResetStateOnProfileTypeChange(); + const resetStateOnProfileTypeChange = useResetStateOnProfileTypeChange({ + resetFilters: viewDefaults?.hasProfileFilters, // Don't reset filters on profile type change in views + }); // URL state hooks with appropriate suffixes const [expression, setExpressionState] = useURLState(`expression${suffix}`, { @@ -154,6 +184,33 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState // Parse sumBy from URL parameter format const sumBy = useSumByFromParams(sumByParam); + // Detect if viewDefaults contain matchers-only expression + const hasMatchersOnlyDefault = useMemo(() => { + const defaults = suffix === '' || suffix === '_a' ? viewDefaults : sharedDefaults; + if (defaults?.expression == null || defaults.expression === '') return false; + return defaults.expression.trim().startsWith('{'); + }, [viewDefaults, sharedDefaults, suffix]); + + // Get time range for profile types query + const timeRangeForProfileTypes = useMemo(() => { + return DateTimeRange.fromRangeKey( + timeSelection ?? defaultTimeSelection, + from !== undefined && from !== '' ? parseInt(from) : defaultFrom, + to !== undefined && to !== '' ? parseInt(to) : defaultTo + ); + }, [timeSelection, from, to, defaultTimeSelection, defaultFrom, defaultTo]); + + // Fetch profile types only when needed + const { + loading: profileTypesLoading, + data: profileTypesData, + error: profileTypesError, + } = useProfileTypes( + queryClient, + hasMatchersOnlyDefault ? timeRangeForProfileTypes.getFromMs() : undefined, + hasMatchersOnlyDefault ? timeRangeForProfileTypes.getToMs() : undefined + ); + // Draft state management const [draftExpression, setDraftExpression] = useState(expression ?? defaultExpression); const [draftFrom, setDraftFrom] = useState(from ?? defaultFrom?.toString() ?? ''); @@ -473,9 +530,35 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const defaults = suffix === '' || suffix === '_a' ? viewDefaults : sharedDefaults; if (defaults === undefined) return; + console.log('🚀 ~ useQueryState ~ defaults:', defaults); + // Apply expression default using preserve-existing strategy if (defaults.expression !== undefined) { - setExpressionWithPreserve(defaults.expression); + const isMatchersOnly = defaults.expression.trim().startsWith('{'); + + if (isMatchersOnly) { + if (profileTypesLoading) return; + + if ( + profileTypesError != null || + profileTypesData == null || + profileTypesData.types.length === 0 + ) { + console.warn('Cannot apply matchers-only view default: no profile types available', { + expression: defaults.expression, + error: profileTypesError, + }); + return; + } + + const fullExpression = prependProfileTypeToMatchers( + defaults.expression, + profileTypesData + ); + setExpressionWithPreserve(fullExpression); + } else { + setExpressionWithPreserve(defaults.expression); + } } // Apply sum_by default using preserve-existing strategy @@ -497,6 +580,9 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState setSumByWithPreserve, isGroupByEnabled, setGroupByWithPreserve, + profileTypesLoading, + profileTypesData, + profileTypesError, ]); // Reset query to default state @@ -533,8 +619,16 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } }, [querySelection.expression]); - // Parse expression components using existing Query parser const {hasProfileType, profileTypeString, matchersOnly, fullExpression} = useMemo(() => { + if (expression === undefined || expression === '') { + return { + hasProfileType: false, + profileTypeString: '', + matchersOnly: '{}', + fullExpression: '', + }; + } + const expr = expression ?? defaultExpression; const parsed = Query.parse(expr); @@ -551,6 +645,26 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState }; }, [expression, defaultExpression]); + // Re-apply view defaults when profile types finish loading (for matchers-only expressions) + const [profileTypesLoadedOnce, setProfileTypesLoadedOnce] = useState(false); + useEffect(() => { + if ( + hasMatchersOnlyDefault && + !profileTypesLoading && + !profileTypesLoadedOnce && + profileTypesData != null + ) { + setProfileTypesLoadedOnce(true); + applyViewDefaults(); + } + }, [ + hasMatchersOnlyDefault, + profileTypesLoading, + profileTypesLoadedOnce, + profileTypesData, + applyViewDefaults, + ]); + return { // Current committed state querySelection, diff --git a/ui/packages/shared/profile/src/index.tsx b/ui/packages/shared/profile/src/index.tsx index 6a604a1115b..a0a984df4f8 100644 --- a/ui/packages/shared/profile/src/index.tsx +++ b/ui/packages/shared/profile/src/index.tsx @@ -34,6 +34,7 @@ export * from './ProfileSource'; export { convertToProtoFilters, convertFromProtoFilters, + useProfileFilters, } from './ProfileView/components/ProfileFilters/useProfileFilters'; export * from './ProfileView'; export * from './ProfileViewWithData'; From c0503b0e9aafacb5bcafdc03f0f3ac33dbf06ae7 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Wed, 31 Dec 2025 11:05:53 +0100 Subject: [PATCH 04/12] improve groupBy handling --- .../ProfileFilters/useProfileFiltersUrlState.ts | 4 +++- .../shared/profile/src/hooks/useQueryState.ts | 13 ++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts index 32740fae154..142a21b7086 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts @@ -190,7 +190,9 @@ export const useProfileFiltersUrlState = ( // Apply view defaults (only if URL is empty) const applyViewDefaults = useCallback(() => { - if (viewDefaults === undefined || viewDefaults.length === 0) return; + if (viewDefaults === undefined || viewDefaults.length === 0) { + return; + } batchUpdates(() => { setAppliedFiltersWithPreserve(viewDefaults); diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index cae1f77ffe4..3576679d522 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -17,7 +17,7 @@ import {ProfileTypesResponse} from '@parca/client'; import {DateTimeRange, useParcaContext, useURLState, useURLStateBatch} from '@parca/components'; import {Query} from '@parca/parser'; -import {IProfileTypesResult, QuerySelection, useProfileTypes} from '../ProfileSelector'; +import {QuerySelection, useProfileTypes} from '../ProfileSelector'; import {ProfileSelection, ProfileSelectionFromParams, ProfileSource} from '../ProfileSource'; import {useResetFlameGraphState} from '../ProfileView/hooks/useResetFlameGraphState'; import {useResetStateOnProfileTypeChange} from '../ProfileView/hooks/useResetStateOnProfileTypeChange'; @@ -133,9 +133,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const batchUpdates = useURLStateBatch(); const resetFlameGraphState = useResetFlameGraphState(); - const resetStateOnProfileTypeChange = useResetStateOnProfileTypeChange({ - resetFilters: viewDefaults?.hasProfileFilters, // Don't reset filters on profile type change in views - }); + const resetStateOnProfileTypeChange = useResetStateOnProfileTypeChange(); // URL state hooks with appropriate suffixes const [expression, setExpressionState] = useURLState(`expression${suffix}`, { @@ -530,8 +528,6 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const defaults = suffix === '' || suffix === '_a' ? viewDefaults : sharedDefaults; if (defaults === undefined) return; - console.log('🚀 ~ useQueryState ~ defaults:', defaults); - // Apply expression default using preserve-existing strategy if (defaults.expression !== undefined) { const isMatchersOnly = defaults.expression.trim().startsWith('{'); @@ -702,7 +698,10 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState // Group-by state (only for _a hook) ...(isGroupByEnabled ? { - groupBy: groupByParam?.split(',').filter(Boolean), + groupBy: + groupByParam != null && typeof groupByParam === 'string' && groupByParam !== '' + ? groupByParam.split(',').filter(Boolean) + : undefined, setGroupBy: (groupBy: string[] | undefined) => { setGroupByParam(groupBy?.join(',')); }, From 75bf94e4eadd087dce5731644bf016eae8a6d257 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Wed, 31 Dec 2025 12:43:51 +0100 Subject: [PATCH 05/12] Add tests for useProfileFiltersUrlState hook --- .../useProfileFiltersUrlState.test.tsx | 635 ++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx new file mode 100644 index 00000000000..2dd1b973274 --- /dev/null +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx @@ -0,0 +1,635 @@ +// Copyright 2022 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {type ReactNode} from 'react'; + +// eslint-disable-next-line import/named +import {act, renderHook, waitFor} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {URLStateProvider} from '@parca/components'; + +import {type ProfileFilter} from './useProfileFilters'; +import {decodeProfileFilters, useProfileFiltersUrlState} from './useProfileFiltersUrlState'; + +// Mock window.location +const mockLocation = { + pathname: '/test', + search: '', +}; + +// Mock the navigate function +const mockNavigateTo = vi.fn((path: string, params: Record) => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + searchParams.set(key, value.join(',')); + } else { + searchParams.set(key, String(value)); + } + } + }); + mockLocation.search = `?${searchParams.toString()}`; +}); + +// Mock getQueryParamsFromURL +vi.mock('@parca/components/src/hooks/URLState/utils', async () => { + const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils'); + return { + ...actual, + getQueryParamsFromURL: () => { + if (mockLocation.search === '') return {}; + const params = new URLSearchParams(mockLocation.search); + const result: Record = {}; + for (const [key, value] of params.entries()) { + const decodedValue = decodeURIComponent(value); + const existing = result[key]; + if (existing !== undefined) { + result[key] = Array.isArray(existing) + ? [...existing, decodedValue] + : [existing, decodedValue]; + } else { + result[key] = decodedValue; + } + } + return result; + }, + }; +}); + +// Helper to create wrapper with URLStateProvider +const createWrapper = (): (({children}: {children: ReactNode}) => JSX.Element) => { + const Wrapper = ({children}: {children: ReactNode}): JSX.Element => ( + {children} + ); + Wrapper.displayName = 'URLStateProviderWrapper'; + return Wrapper; +}; + +describe('useProfileFiltersUrlState', () => { + beforeEach(() => { + mockNavigateTo.mockClear(); + Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, + }); + mockLocation.search = ''; + }); + + describe('decodeProfileFilters', () => { + it('should return empty array for empty string', () => { + expect(decodeProfileFilters('')).toEqual([]); + }); + + it('should return empty array for undefined', () => { + expect(decodeProfileFilters(undefined as unknown as string)).toEqual([]); + }); + + it('should decode stack filter with function_name', () => { + // Format: type:field:match:value -> s:fn:=:testFunc + const encoded = 's:fn:=:testFunc'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'stack', + field: 'function_name', + matchType: 'equal', + value: 'testFunc', + }); + }); + + it('should decode frame filter with binary', () => { + const encoded = 'f:b:!=:libc.so'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'frame', + field: 'binary', + matchType: 'not_equal', + value: 'libc.so', + }); + }); + + it('should decode filter with contains match', () => { + const encoded = 's:fn:~:runtime'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'stack', + field: 'function_name', + matchType: 'contains', + value: 'runtime', + }); + }); + + it('should decode filter with not_contains match', () => { + const encoded = 'f:b:!~:node'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'node', + }); + }); + + it('should decode filter with starts_with match', () => { + const encoded = 's:fn:^:std::'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'stack', + field: 'function_name', + matchType: 'starts_with', + value: 'std::', + }); + }); + + it('should decode filter with not_starts_with match', () => { + const encoded = 'f:fn:!^:tokio::'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'frame', + field: 'function_name', + matchType: 'not_starts_with', + value: 'tokio::', + }); + }); + + it('should decode multiple filters', () => { + const encoded = 's:fn:=:testFunc,f:b:!=:libc.so'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + type: 'stack', + field: 'function_name', + matchType: 'equal', + value: 'testFunc', + }); + expect(result[1]).toMatchObject({ + type: 'frame', + field: 'binary', + matchType: 'not_equal', + value: 'libc.so', + }); + }); + + it('should decode preset filter', () => { + const encoded = 'p:hide_libc:enabled'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'hide_libc', + value: 'enabled', + }); + }); + + it('should handle values with colons', () => { + const encoded = 'p:some_preset:value:with:colons'; + const result = decodeProfileFilters(encoded); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'some_preset', + value: 'value:with:colons', + }); + }); + + it('should decode all field types', () => { + const testCases = [ + {encoded: 's:fn:=:test', expectedField: 'function_name'}, + {encoded: 's:b:=:test', expectedField: 'binary'}, + {encoded: 's:sn:=:test', expectedField: 'system_name'}, + {encoded: 's:f:=:test', expectedField: 'filename'}, + {encoded: 's:a:=:test', expectedField: 'address'}, + {encoded: 's:ln:=:test', expectedField: 'line_number'}, + ]; + + for (const {encoded, expectedField} of testCases) { + const result = decodeProfileFilters(encoded); + expect(result[0].field).toBe(expectedField); + } + }); + + it('should return empty array for malformed input', () => { + // This should not throw - it returns empty array on error + expect(() => decodeProfileFilters('malformed')).not.toThrow(); + }); + + it('should generate unique IDs for each filter', () => { + const encoded = 's:fn:=:func1,s:fn:=:func2,s:fn:=:func3'; + const result = decodeProfileFilters(encoded); + + const ids = result.map(f => f.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + }); + + describe('Basic functionality', () => { + it('should initialize with empty filters when no URL params', () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + expect(result.current.appliedFilters).toEqual([]); + }); + + it('should read filters from URL', async () => { + mockLocation.search = '?profile_filters=s:fn:=:testFunc'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0]).toMatchObject({ + type: 'stack', + field: 'function_name', + matchType: 'equal', + value: 'testFunc', + }); + }); + }); + + it('should update URL when setting filters', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + const newFilters: ProfileFilter[] = [ + { + id: 'test-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'libc.so', + }, + ]; + + act(() => { + result.current.setAppliedFilters(newFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('f:b:!~:libc.so'); + }); + }); + + it('should clear URL param when setting empty filters', async () => { + mockLocation.search = '?profile_filters=s:fn:=:testFunc'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + act(() => { + result.current.setAppliedFilters([]); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // When filters are empty, the param is either empty string or undefined (removed) + expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true); + }); + }); + }); + + describe('View defaults', () => { + it('should provide applyViewDefaults method', () => { + const viewDefaults: ProfileFilter[] = [ + { + id: 'default-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'libc.so', + }, + ]; + + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + expect(typeof result.current.applyViewDefaults).toBe('function'); + }); + + it('should apply view defaults to empty URL', async () => { + const viewDefaults: ProfileFilter[] = [ + { + id: 'default-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'libc.so', + }, + ]; + + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.applyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('f:b:!~:libc.so'); + }); + }); + + it('should not overwrite existing filters when applying view defaults (preserve-existing)', async () => { + mockLocation.search = '?profile_filters=s:fn:=:existingFunc'; + + const viewDefaults: ProfileFilter[] = [ + { + id: 'default-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'libc.so', + }, + ]; + + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Verify existing filter is loaded + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('existingFunc'); + }); + + mockNavigateTo.mockClear(); + + act(() => { + result.current.applyViewDefaults(); + }); + + // With preserve-existing strategy, the existing value should be preserved + await waitFor(() => { + // Either no navigation (because value already exists) or value is preserved + if (mockNavigateTo.mock.calls.length > 0) { + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // The existing filter should be preserved + expect(params.profile_filters).toBe('s:fn:=:existingFunc'); + } + }); + }); + + it('should do nothing when viewDefaults is undefined', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + mockNavigateTo.mockClear(); + + act(() => { + result.current.applyViewDefaults(); + }); + + // Should not navigate since there are no defaults to apply + expect(mockNavigateTo).not.toHaveBeenCalled(); + }); + + it('should do nothing when viewDefaults is empty array', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults: []}), { + wrapper: createWrapper(), + }); + + mockNavigateTo.mockClear(); + + act(() => { + result.current.applyViewDefaults(); + }); + + // Should not navigate since defaults array is empty + expect(mockNavigateTo).not.toHaveBeenCalled(); + }); + }); + + describe('forceApplyFilters', () => { + it('should provide forceApplyFilters method', () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + expect(typeof result.current.forceApplyFilters).toBe('function'); + }); + + it('should force apply filters overwriting existing', async () => { + mockLocation.search = '?profile_filters=s:fn:=:existingFunc'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + // Verify existing filter is loaded + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + }); + + const newFilters: ProfileFilter[] = [ + { + id: 'forced-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'forcedValue', + }, + ]; + + act(() => { + result.current.forceApplyFilters(newFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('f:b:!~:forcedValue'); + }); + }); + + it('should clear filters when force applying empty array', async () => { + mockLocation.search = '?profile_filters=s:fn:=:existingFunc'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + act(() => { + result.current.forceApplyFilters([]); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // When filters are empty, the param is either empty string or undefined (removed) + expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true); + }); + }); + }); + + describe('Preset filter encoding', () => { + it('should encode preset filters correctly', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + const presetFilters: ProfileFilter[] = [ + { + id: 'preset-1', + type: 'hide_libc', + value: 'enabled', + }, + ]; + + act(() => { + result.current.setAppliedFilters(presetFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('p:hide_libc:enabled'); + }); + }); + + it('should handle mixed preset and regular filters', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + const mixedFilters: ProfileFilter[] = [ + { + id: 'preset-1', + type: 'hide_libc', + value: 'enabled', + }, + { + id: 'regular-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'node', + }, + ]; + + act(() => { + result.current.setAppliedFilters(mixedFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('p:hide_libc:enabled,f:b:!~:node'); + }); + }); + }); + + describe('URL encoding edge cases', () => { + it('should handle special characters in filter values', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + const filtersWithSpecialChars: ProfileFilter[] = [ + { + id: 'special-1', + type: 'stack', + field: 'function_name', + matchType: 'contains', + value: 'std::vector', + }, + ]; + + act(() => { + result.current.setAppliedFilters(filtersWithSpecialChars); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Value should be URL encoded + expect(params.profile_filters).toContain('std%3A%3Avector%3Cint%3E'); + }); + }); + + it('should filter out incomplete filters when encoding', async () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + const incompleteFilters: ProfileFilter[] = [ + { + id: 'complete-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'valid', + }, + { + id: 'incomplete-1', + type: 'frame', + // Missing field, matchType + value: '', + }, + { + id: 'incomplete-2', + type: undefined, + value: 'value', + }, + ]; + + act(() => { + result.current.setAppliedFilters(incompleteFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Only the complete filter should be encoded + expect(params.profile_filters).toBe('f:b:!~:valid'); + }); + }); + }); + + describe('Memoization', () => { + it('should return empty array with consistent structure when no filters', () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + // Empty filters should be an empty array (not undefined or null) + expect(Array.isArray(result.current.appliedFilters)).toBe(true); + expect(result.current.appliedFilters).toHaveLength(0); + }); + + it('should always return array (never undefined)', () => { + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + expect(Array.isArray(result.current.appliedFilters)).toBe(true); + expect(result.current.appliedFilters).toEqual([]); + }); + + it('should return correctly structured filters from URL', async () => { + mockLocation.search = '?profile_filters=s:fn:=:testFunc'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + }); + + // Verify the filter structure is correct + const filter = result.current.appliedFilters[0]; + expect(filter).toHaveProperty('id'); + expect(filter).toHaveProperty('type', 'stack'); + expect(filter).toHaveProperty('field', 'function_name'); + expect(filter).toHaveProperty('matchType', 'equal'); + expect(filter).toHaveProperty('value', 'testFunc'); + }); + }); +}); From 99965ce6f6ab9756dcc45bd7aeb26135f5fe31d1 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Wed, 31 Dec 2025 12:54:04 +0100 Subject: [PATCH 06/12] Improve filter application and add view switching tests --- .../useProfileFiltersUrlState.test.tsx | 249 ++++++++++++++++++ .../useProfileFiltersUrlState.ts | 14 +- .../shared/profile/src/hooks/useQueryState.ts | 32 ++- 3 files changed, 277 insertions(+), 18 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx index 2dd1b973274..23464559229 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx @@ -632,4 +632,253 @@ describe('useProfileFiltersUrlState', () => { expect(filter).toHaveProperty('value', 'testFunc'); }); }); + + describe('View switching scenarios', () => { + it('should completely replace filters when switching views using forceApplyFilters', async () => { + // Start with View A's filters (2 filters) + mockLocation.search = '?profile_filters=s:fn:=:viewAFunc,f:b:!=:viewABinary'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(2); + expect(result.current.appliedFilters[0].value).toBe('viewAFunc'); + expect(result.current.appliedFilters[1].value).toBe('viewABinary'); + }); + + // Switch to View B (completely different filter) + const viewBFilters: ProfileFilter[] = [ + { + id: 'viewB-1', + type: 'frame', + field: 'function_name', + matchType: 'contains', + value: 'viewBOnly', + }, + ]; + + act(() => { + result.current.forceApplyFilters(viewBFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + + // View A's filters should be completely gone + expect(params.profile_filters).not.toContain('viewAFunc'); + expect(params.profile_filters).not.toContain('viewABinary'); + + // Only View B's filter should be present + expect(params.profile_filters).toBe('f:fn:~:viewBOnly'); + }); + }); + + it('should handle sequential view switches correctly', async () => { + // Simulate: [default] -> [storage] -> [testing-view] + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + // View 1: default view (1 filter) + const defaultFilters: ProfileFilter[] = [{id: 'd-1', type: 'hide_libc', value: 'enabled'}]; + + act(() => { + result.current.forceApplyFilters(defaultFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('p:hide_libc:enabled'); + }); + + mockNavigateTo.mockClear(); + + // View 2: storage view (3 filters) + const storageFilters: ProfileFilter[] = [ + {id: 's-1', type: 'stack', field: 'function_name', matchType: 'not_contains', value: 'io'}, + {id: 's-2', type: 'frame', field: 'binary', matchType: 'not_contains', value: 'disk'}, + {id: 's-3', type: 'frame', field: 'function_name', matchType: 'contains', value: 'storage'}, + ]; + + act(() => { + result.current.forceApplyFilters(storageFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Default view's filter should be gone + expect(params.profile_filters).not.toContain('hide_libc'); + // Storage view should have 3 filters + expect(params.profile_filters).toContain('io'); + expect(params.profile_filters).toContain('disk'); + expect(params.profile_filters).toContain('storage'); + }); + + mockNavigateTo.mockClear(); + + // View 3: testing-view (2 filters) + const testingFilters: ProfileFilter[] = [ + {id: 't-1', type: 'stack', field: 'function_name', matchType: 'equal', value: 'test_main'}, + {id: 't-2', type: 'frame', field: 'binary', matchType: 'contains', value: 'test'}, + ]; + + act(() => { + result.current.forceApplyFilters(testingFilters); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Storage view's filters should be gone + expect(params.profile_filters).not.toContain('io'); + expect(params.profile_filters).not.toContain('disk'); + expect(params.profile_filters).not.toContain('storage'); + // Testing view should have its 2 filters + expect(params.profile_filters).toContain('test_main'); + expect(params.profile_filters).toContain('test'); + }); + }); + + it('should not change filters when clicking the same view tab', async () => { + // Start with existing filters + mockLocation.search = '?profile_filters=s:fn:=:existingFilter'; + + const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()}); + + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + }); + + mockNavigateTo.mockClear(); + + // Apply the same filters (simulating clicking the same view tab) + const sameFilters: ProfileFilter[] = [ + { + id: 'same-1', + type: 'stack', + field: 'function_name', + matchType: 'equal', + value: 'existingFilter', + }, + ]; + + act(() => { + result.current.forceApplyFilters(sameFilters); + }); + + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('existingFilter'); + }); + }); + }); + + describe('Page refresh persistence', () => { + it('should persist user customizations in URL after page refresh simulation', async () => { + const viewDefaults: ProfileFilter[] = [ + {id: 'default-1', type: 'hide_libc', value: 'enabled'}, + ]; + + // User has customized filters (different from defaults) + mockLocation.search = '?profile_filters=s:fn:=:userCustomFilter'; + + const {result, unmount} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Verify user's filter is loaded + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('userCustomFilter'); + }); + + // Apply view defaults - should NOT overwrite user's URL params (preserve-existing) + act(() => { + result.current.applyViewDefaults(); + }); + + // User's filter should still be preserved + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('userCustomFilter'); + }); + + // Simulate page refresh + unmount(); + mockNavigateTo.mockClear(); + + // Re-render hook (simulating page reload) + const {result: result2} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // After "refresh", filter should still be from URL + await waitFor(() => { + expect(result2.current.appliedFilters).toHaveLength(1); + expect(result2.current.appliedFilters[0].value).toBe('userCustomFilter'); + }); + }); + + it('should apply view defaults when URL is empty on page load', async () => { + const viewDefaults: ProfileFilter[] = [ + { + id: 'default-1', + type: 'frame', + field: 'binary', + matchType: 'not_contains', + value: 'libc.so', + }, + ]; + + // Empty URL (fresh page load) + mockLocation.search = ''; + + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Apply view defaults + act(() => { + result.current.applyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.profile_filters).toBe('f:b:!~:libc.so'); + }); + }); + + it('should handle shared/bookmarked URL with custom params', async () => { + const viewDefaults: ProfileFilter[] = [ + {id: 'default-1', type: 'hide_libc', value: 'enabled'}, + {id: 'default-2', type: 'hide_python_internals', value: 'enabled'}, + ]; + + // Shared URL with custom params (not matching view defaults) + mockLocation.search = '?profile_filters=s:fn:~:customSharedFilter'; + + const {result} = renderHook(() => useProfileFiltersUrlState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Verify custom params are loaded + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('customSharedFilter'); + }); + + // Apply view defaults - should NOT overwrite + act(() => { + result.current.applyViewDefaults(); + }); + + // Custom params should be honored over view defaults + await waitFor(() => { + expect(result.current.appliedFilters).toHaveLength(1); + expect(result.current.appliedFilters[0].value).toBe('customSharedFilter'); + }); + }); + }); }); diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts index 142a21b7086..0b9f859d344 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts @@ -200,10 +200,22 @@ export const useProfileFiltersUrlState = ( }, [viewDefaults, batchUpdates, setAppliedFiltersWithPreserve]); // Force apply filters (bypasses preserve-existing strategy) + // This validates filters before applying, similar to onApplyFilters in useProfileFilters. + // Use this when switching views to completely replace the current filters. const forceApplyFilters = useCallback( (filters: ProfileFilter[]) => { + // Validate filters before applying + const validFilters = filters.filter(f => { + // For preset filters, only need type and value + if (f.type != null && isPresetKey(f.type)) { + return f.value !== '' && f.type != null; + } + // For regular filters, need all fields + return f.value !== '' && f.type != null && f.field != null && f.matchType != null; + }); + batchUpdates(() => { - setAppliedFilters(filters); + setAppliedFilters(validFilters); }); }, [batchUpdates, setAppliedFilters] diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index 3576679d522..b41ddcd63f9 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -643,23 +643,21 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState // Re-apply view defaults when profile types finish loading (for matchers-only expressions) const [profileTypesLoadedOnce, setProfileTypesLoadedOnce] = useState(false); - useEffect(() => { - if ( - hasMatchersOnlyDefault && - !profileTypesLoading && - !profileTypesLoadedOnce && - profileTypesData != null - ) { - setProfileTypesLoadedOnce(true); - applyViewDefaults(); - } - }, [ - hasMatchersOnlyDefault, - profileTypesLoading, - profileTypesLoadedOnce, - profileTypesData, - applyViewDefaults, - ]); + useEffect( + () => { + if ( + hasMatchersOnlyDefault && + !profileTypesLoading && + !profileTypesLoadedOnce && + profileTypesData != null + ) { + setProfileTypesLoadedOnce(true); + applyViewDefaults(); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [hasMatchersOnlyDefault, profileTypesLoading, profileTypesLoadedOnce, profileTypesData] + ); return { // Current committed state From 5134642f80ee7d4e81e17a5df79e83aeab7796d2 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Mon, 5 Jan 2026 14:25:50 +0100 Subject: [PATCH 07/12] Refactor profile filter and query state hooks --- .../src/hooks/URLState/index.test.tsx | 725 ++---------------- .../components/src/hooks/URLState/index.tsx | 92 +-- .../ProfileFilters/useProfileFilters.ts | 4 +- .../useProfileFiltersUrlState.ts | 33 +- .../profile/src/hooks/useQueryState.test.tsx | 134 ++-- .../shared/profile/src/hooks/useQueryState.ts | 106 ++- 6 files changed, 248 insertions(+), 846 deletions(-) diff --git a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx b/ui/packages/shared/components/src/hooks/URLState/index.test.tsx index f10005bd62c..c2dff884fe7 100644 --- a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx +++ b/ui/packages/shared/components/src/hooks/URLState/index.test.tsx @@ -11,21 +11,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ReactNode} from 'react'; +import {ReactNode, act} from 'react'; // eslint-disable-next-line import/named -import {act, renderHook, waitFor} from '@testing-library/react'; +import {renderHook, waitFor} from '@testing-library/react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import { JSONParser, JSONSerializer, URLStateProvider, - hasQueryParams, useURLState, useURLStateBatch, useURLStateCustom, - useURLStateReset, } from './index'; // Mock the navigate function @@ -651,688 +649,119 @@ describe('URLState Hooks', () => { }); describe('mergeStrategy option', () => { - describe('replace strategy (default)', () => { - it('should replace existing value when no mergeStrategy is specified', async () => { - const {result} = renderHook(() => useURLState('param'), {wrapper: createWrapper()}); - - const [, setParam] = result.current; - - // Set initial value - act(() => { - setParam('initial'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('initial'); - }); - - // Replace with new value - act(() => { - setParam('replaced'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('replaced'); - expect(mockNavigateTo).toHaveBeenLastCalledWith( - '/test', - {param: 'replaced'}, - {replace: true} - ); - }); - }); - - it('should replace existing value when mergeStrategy is "replace"', async () => { - const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'replace'}), { - wrapper: createWrapper(), - }); - - const [, setParam] = result.current; - - act(() => { - setParam('initial'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('initial'); - }); - - act(() => { - setParam('replaced'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('replaced'); - }); - }); - - it('should replace array with string using replace strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'replace'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - act(() => { - setParam(['one', 'two']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['one', 'two']); - }); - - act(() => { - setParam('single'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('single'); - }); - }); - }); - - describe('preserve-existing strategy', () => { - it('should not overwrite existing value with preserve-existing strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'preserve-existing'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial value - act(() => { - setParam('existing'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('existing'); - }); - - // Try to set new value - should be ignored - act(() => { - setParam('should-be-ignored'); - }); - - await waitFor(() => { - // Value should remain unchanged - expect(result.current[0]).toBe('existing'); - }); - }); - - it('should set value when current is undefined with preserve-existing', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'preserve-existing'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set value when undefined - act(() => { - setParam('new-value'); - }); + it('should replace existing value by default', async () => { + const {result} = renderHook(() => useURLState('param'), {wrapper: createWrapper()}); + const [, setParam] = result.current; - await waitFor(() => { - expect(result.current[0]).toBe('new-value'); - expect(mockNavigateTo).toHaveBeenCalledWith( - '/test', - {param: 'new-value'}, - {replace: true} - ); - }); + act(() => { + setParam('initial'); }); - - it('should set value when current is empty string with preserve-existing', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'preserve-existing'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set to empty string first - act(() => { - setParam(''); - }); - - await waitFor(() => { - expect(result.current[0]).toBe(''); - }); - - // Should overwrite empty string - act(() => { - setParam('new-value'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('new-value'); - }); + await waitFor(() => { + expect(result.current[0]).toBe('initial'); }); - it('should set value when current is empty array with preserve-existing', async () => { - const {result} = renderHook( - () => - useURLState('param', { - mergeStrategy: 'preserve-existing', - alwaysReturnArray: true, - }), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set to empty array first - act(() => { - setParam([]); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual([]); - }); - - // Should overwrite empty array - act(() => { - setParam(['value']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['value']); - }); + act(() => { + setParam('replaced'); }); - - it('should preserve existing array with preserve-existing strategy', async () => { - const {result} = renderHook( - () => - useURLState('param', { - mergeStrategy: 'preserve-existing', - alwaysReturnArray: true, - }), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial array - act(() => { - setParam(['existing']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['existing']); - }); - - // Try to set new array - should be ignored - act(() => { - setParam(['new']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['existing']); - }); + await waitFor(() => { + expect(result.current[0]).toBe('replaced'); }); }); - describe('append strategy', () => { - it('should ignore undefined/null values with append strategy', async () => { - const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'append'}), { - wrapper: createWrapper(), - }); - - const [, setParam] = result.current; - - // Set initial value - act(() => { - setParam('existing'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('existing'); - }); - - // Try to append undefined - should be ignored - act(() => { - setParam(undefined); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('existing'); - }); - }); - - it('should merge two arrays and deduplicate with append strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial array - act(() => { - setParam(['a', 'b']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b']); - }); - - // Append array with overlap - act(() => { - setParam(['b', 'c', 'd']); - }); + it('should only set value when empty with preserve-existing strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'preserve-existing'}), + {wrapper: createWrapper()} + ); + const [, setParam] = result.current; - await waitFor(() => { - // Should deduplicate 'b' - expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); - }); + // Set when undefined - should work + act(() => { + setParam('first'); }); - - it('should add string to array with append strategy (no duplicates)', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial array - act(() => { - setParam(['a', 'b']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b']); - }); - - // Append new string - act(() => { - setParam('c'); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b', 'c']); - }); + await waitFor(() => { + expect(result.current[0]).toBe('first'); }); - it('should not add duplicate string to array with append strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial array - act(() => { - setParam(['a', 'b']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b']); - }); - - // Try to append existing string - act(() => { - setParam('b'); - }); - - await waitFor(() => { - // Should remain unchanged (no duplicate) - expect(result.current[0]).toEqual(['a', 'b']); - }); + // Try to overwrite - should be ignored + act(() => { + setParam('second'); }); - - it('should merge string with array with append strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial string - act(() => { - setParam('a'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('a'); - }); - - // Append array - act(() => { - setParam(['b', 'c']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b', 'c']); - }); + await waitFor(() => { + expect(result.current[0]).toBe('first'); }); + }); - it('should create array from two different strings with append strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial string - act(() => { - setParam('first'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('first'); - }); - - // Append different string - act(() => { - setParam('second'); - }); + it('should merge arrays with deduplication using append strategy', async () => { + const {result} = renderHook( + () => useURLState('param', {mergeStrategy: 'append'}), + {wrapper: createWrapper()} + ); + const [, setParam] = result.current; - await waitFor(() => { - expect(result.current[0]).toEqual(['first', 'second']); - }); + // Set initial array + act(() => { + setParam(['a', 'b']); }); - - it('should not create array when appending same string with append strategy', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial string - act(() => { - setParam('same'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('same'); - }); - - // Append same string (should deduplicate) - act(() => { - setParam('same'); - }); - - await waitFor(() => { - // Should remain a single string, not create array - expect(result.current[0]).toBe('same'); - }); + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b']); }); - it('should set value when current is empty with append strategy', async () => { - const {result} = renderHook(() => useURLState('param', {mergeStrategy: 'append'}), { - wrapper: createWrapper(), - }); - - const [, setParam] = result.current; - - // Append to undefined (should just set) - act(() => { - setParam('new-value'); - }); - - await waitFor(() => { - expect(result.current[0]).toBe('new-value'); - }); + // Append with overlap - should deduplicate + act(() => { + setParam(['b', 'c']); }); - - it('should deduplicate when merging string array with overlapping values', async () => { - const {result} = renderHook( - () => useURLState('param', {mergeStrategy: 'append'}), - {wrapper: createWrapper()} - ); - - const [, setParam] = result.current; - - // Set initial array - act(() => { - setParam(['a', 'b', 'c']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['a', 'b', 'c']); - }); - - // Append array with all duplicates and one new value - act(() => { - setParam(['a', 'b', 'c', 'd']); - }); - - await waitFor(() => { - // Should only add 'd' - expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); - }); + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c']); }); - }); - - describe('Real-world view defaults use case', () => { - it('should apply view defaults only when URL params are empty (preserve-existing)', async () => { - // Simulate view defaults being applied - const {result} = renderHook( - () => - useURLState('group_by', { - defaultValue: ['function_name'], - mergeStrategy: 'preserve-existing', - alwaysReturnArray: true, - }), - {wrapper: createWrapper()} - ); - - // Initial render - should use default - expect(result.current[0]).toEqual(['function_name']); - - const [, setGroupBy] = result.current; - - // User modifies the value - act(() => { - setGroupBy(['custom_label']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['custom_label']); - }); - - // Simulate view switching trying to apply defaults again (should be ignored) - act(() => { - setGroupBy(['function_name']); - }); - await waitFor(() => { - // Should keep user's custom value - expect(result.current[0]).toEqual(['custom_label']); - }); + // Append string to array + act(() => { + setParam('d'); }); - - it('should accumulate filter values with append strategy', async () => { - // Simulate adding multiple filters - const {result} = renderHook( - () => - useURLState('filters', {mergeStrategy: 'append', alwaysReturnArray: true}), - {wrapper: createWrapper()} - ); - - const [, setFilters] = result.current; - - // Add first filter - act(() => { - setFilters(['cpu>50']); - }); - - await waitFor(() => { - expect(result.current[0]).toEqual(['cpu>50']); - }); - - // Add second filter - act(() => { - setFilters(['memory<1000']); - }); - - await waitFor(() => { - // Should append, not replace - expect(result.current[0]).toEqual(['cpu>50', 'memory<1000']); - }); - - // Try to add duplicate filter - act(() => { - setFilters(['cpu>50']); - }); - - await waitFor(() => { - // Should not add duplicate - expect(result.current[0]).toEqual(['cpu>50', 'memory<1000']); - }); + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); }); - }); - describe('enabled option', () => { - it('should return undefined and no-op setter when enabled is false', async () => { - const {result} = renderHook(() => useURLState('param', {enabled: false}), { - wrapper: createWrapper(), - }); - - const [value, setParam] = result.current; - expect(value).toBeUndefined(); - - // Try to set value - should be no-op - act(() => { - setParam('should-not-work'); - }); - - await waitFor(() => { - expect(mockNavigateTo).not.toHaveBeenCalled(); - }); + // Append duplicate string - should be ignored + act(() => { + setParam('a'); }); - - it('should handle compare mode group_by use case', async () => { - const TestComponent = (): { - groupByA: string | string[] | undefined; - groupByB: string | string[] | undefined; - } => { - const [groupByA] = useURLState('group_by', {enabled: true, defaultValue: ['node']}); - const [groupByB] = useURLState('group_by', {enabled: false}); - return {groupByA, groupByB}; - }; - - const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); - - expect(result.current.groupByA).toEqual(['node']); - expect(result.current.groupByB).toBeUndefined(); + await waitFor(() => { + expect(result.current[0]).toEqual(['a', 'b', 'c', 'd']); }); }); - describe('namespace option', () => { - it('should prefix param name with namespace', async () => { - const {result} = renderHook( - () => useURLState('setting', {namespace: 'view', defaultValue: 'default'}), - {wrapper: createWrapper()} - ); - - const [, setSetting] = result.current; - - act(() => { - setSetting('new-value'); - }); - - await waitFor(() => { - expect(mockNavigateTo).toHaveBeenCalledWith( - '/test', - {'view.setting': 'new-value'}, - {replace: true} - ); - }); - }); - - it('should allow multiple namespaces without conflict', async () => { - const TestComponent = (): { - setViewColor: (val: string | string[] | undefined) => void; - setAppColor: (val: string | string[] | undefined) => void; - } => { - const [, setViewColor] = useURLState('color', {namespace: 'view'}); - const [, setAppColor] = useURLState('color', {namespace: 'app'}); - return {setViewColor, setAppColor}; - }; - - const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); - - act(() => { - result.current.setViewColor('red'); - result.current.setAppColor('blue'); - }); - - await waitFor(() => { - expect(mockNavigateTo).toHaveBeenLastCalledWith( - '/test', - {'view.color': 'red', 'app.color': 'blue'}, - {replace: true} - ); - }); + it('should return undefined and no-op setter when enabled is false', async () => { + const {result} = renderHook(() => useURLState('param', {enabled: false}), { + wrapper: createWrapper(), }); - }); - - describe('useURLStateReset', () => { - it('should clear specified keys and preserve others', async () => { - mockLocation.search = '?param1=value1¶m2=value2¶m3=value3'; - const TestComponent = (): { - reset: (keys: string[]) => void; - } => { - const reset = useURLStateReset(); - return {reset}; - }; - - const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); + const [value, setParam] = result.current; + expect(value).toBeUndefined(); - act(() => { - result.current.reset(['param1', 'param2']); - }); - - await waitFor(() => { - expect(mockNavigateTo).toHaveBeenCalledWith( - '/test', - {param1: undefined, param2: undefined, param3: 'value3'}, - {replace: true} - ); - }); + act(() => { + setParam('should-not-work'); }); - - it('should throw error when used outside URLStateProvider', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - expect(() => { - renderHook(() => useURLStateReset()); - }).toThrow('useURLStateReset must be used within a URLStateProvider'); - - consoleSpy.mockRestore(); + await waitFor(() => { + expect(mockNavigateTo).not.toHaveBeenCalled(); }); }); - describe('hasQueryParams helper', () => { - it('should return true/false based on params existence', () => { - expect(hasQueryParams({param1: 'value1'})).toBe(true); - expect(hasQueryParams({})).toBe(false); - expect(hasQueryParams({param1: undefined})).toBe(false); - expect(hasQueryParams({param1: ''})).toBe(false); - }); - - it('should exclude specified keys', () => { - const state = {routeParam: 'value1', queryParam: 'value2'}; - expect(hasQueryParams(state, ['routeParam'])).toBe(true); // queryParam exists - expect(hasQueryParams(state, ['routeParam', 'queryParam'])).toBe(false); // all excluded - }); + it('should handle compare mode with enabled option', async () => { + const TestComponent = (): { + groupByA: string | string[] | undefined; + groupByB: string | string[] | undefined; + } => { + const [groupByA] = useURLState('group_by', {enabled: true, defaultValue: ['node']}); + const [groupByB] = useURLState('group_by', {enabled: false}); + return {groupByA, groupByB}; + }; - it('should handle view switching scenario', () => { - const stateWithoutQuery = {'project-id': 'abc', 'view-slug': 'my-view'}; - expect(hasQueryParams(stateWithoutQuery, ['project-id', 'view-slug'])).toBe(false); + const {result} = renderHook(() => TestComponent(), {wrapper: createWrapper()}); - const stateWithQuery = {...stateWithoutQuery, group_by: ['node']}; - expect(hasQueryParams(stateWithQuery, ['project-id', 'view-slug'])).toBe(true); - }); + expect(result.current.groupByA).toEqual(['node']); + expect(result.current.groupByB).toBeUndefined(); }); }); }); diff --git a/ui/packages/shared/components/src/hooks/URLState/index.tsx b/ui/packages/shared/components/src/hooks/URLState/index.tsx index b7abc5445be..e4d8f2b1673 100644 --- a/ui/packages/shared/components/src/hooks/URLState/index.tsx +++ b/ui/packages/shared/components/src/hooks/URLState/index.tsx @@ -211,7 +211,6 @@ interface Options { debugLog?: boolean; alwaysReturnArray?: boolean; mergeStrategy?: 'replace' | 'append' | 'preserve-existing'; - namespace?: string; enabled?: boolean; } @@ -224,10 +223,7 @@ export const useURLState = ( throw new Error('useURLState must be used within a URLStateProvider'); } - const {debugLog, defaultValue, alwaysReturnArray, mergeStrategy, enabled, namespace} = - _options ?? {}; - - const effectiveParam = namespace != null ? `${namespace}.${param}` : param; + const {debugLog, defaultValue, alwaysReturnArray, mergeStrategy, enabled} = _options ?? {}; const {state, setState} = context; @@ -237,12 +233,12 @@ export const useURLState = ( const setParam: ParamValueSetter = useCallback( (val: ParamValue) => { if (debugLog === true) { - console.log('useURLState setParam', effectiveParam, val); + console.log('useURLState setParam', param, val); } // Just update state - Provider handles URL sync automatically! setState(currentState => { - const currentValue = currentState[effectiveParam]; + const currentValue = currentState[param]; let newValue: ParamValue; if (mergeStrategy === undefined || mergeStrategy === 'replace') { @@ -288,70 +284,60 @@ export const useURLState = ( return { ...currentState, - [effectiveParam]: newValue, + [param]: newValue, }; }); }, - [effectiveParam, setState, debugLog, mergeStrategy] + [param, setState, debugLog, mergeStrategy] ); if (debugLog === true) { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { - console.log('useURLState state change', effectiveParam, state[effectiveParam]); + console.log('useURLState state change', param, state[param]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state[effectiveParam]]); + }, [state[param]]); } const value = useMemo(() => { - if (typeof state[effectiveParam] === 'string') { + if (typeof state[param] === 'string') { if (alwaysReturnArray === true) { if (debugLog === true) { - console.log( - 'useURLState returning single string value as array for param', - effectiveParam, - [state[effectiveParam]] - ); + console.log('useURLState returning single string value as array for param', param, [ + state[param], + ]); } - return [state[effectiveParam]] as ParamValue; + return [state[param]] as ParamValue; } if (debugLog === true) { - console.log( - 'useURLState returning string value for param', - effectiveParam, - state[effectiveParam] - ); + console.log('useURLState returning string value for param', param, state[param]); } - return state[effectiveParam]; - } else if (state[effectiveParam] != null && Array.isArray(state[effectiveParam])) { - if (state[effectiveParam]?.length === 1 && alwaysReturnArray !== true) { + return state[param]; + } else if (state[param] != null && Array.isArray(state[param])) { + if (state[param]?.length === 1 && alwaysReturnArray !== true) { if (debugLog === true) { console.log( 'useURLState returning first array value as string for param', - effectiveParam, - state[effectiveParam][0] + param, + state[param][0] ); } - return state[effectiveParam]?.[0] as ParamValue; + return state[param]?.[0] as ParamValue; } else { if (debugLog === true) { - console.log( - 'useURLState returning array value for param', - effectiveParam, - state[effectiveParam] - ); + console.log('useURLState returning array value for param', param, state[param]); } - return state[effectiveParam]; + return state[param]; } } - }, [state, effectiveParam, alwaysReturnArray, debugLog]); + }, [state, param, alwaysReturnArray, debugLog]); if (value == null) { if (debugLog === true) { console.log( 'useURLState returning defaultValue for param', - effectiveParam, + param, defaultValue, window.location.href ); @@ -428,36 +414,4 @@ export const useURLStateBatch = (): ((callback: () => void) => void) => { return context.batchUpdates; }; -// Hook to reset/clear specific URL params -export const useURLStateReset = (): ((keys: string[]) => void) => { - const context = useContext(URLStateContext); - if (context === undefined) { - throw new Error('useURLStateReset must be used within a URLStateProvider'); - } - - return useCallback( - (keys: string[]) => { - context.setState(currentState => { - const newState = {...currentState}; - keys.forEach(key => { - newState[key] = undefined; - }); - return newState; - }); - }, - [context] - ); -}; - -// Helper to check if URL has query params (excluding specified keys) -export const hasQueryParams = ( - state: Record, - exclude: string[] = [] -): boolean => { - const params = Object.keys(state).filter( - k => !exclude.includes(k) && state[k] !== undefined && state[k] !== '' - ); - return params.length > 0; -}; - export default URLStateContext; diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts index cdd4df573d9..e7dce19d5db 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts @@ -226,7 +226,6 @@ export const convertToProtoFilters = (profileFilters: ProfileFilter[]): Filter[] }; interface UseProfileFiltersOptions { - suffix?: '_a' | '_b'; viewDefaults?: ProfileFilter[]; } @@ -247,10 +246,9 @@ export const useProfileFilters = ( applyViewDefaults: () => void; forceApplyFilters: (filters: ProfileFilter[]) => void; } => { - const {suffix, viewDefaults} = options; + const {viewDefaults} = options; const {appliedFilters, setAppliedFilters, applyViewDefaults, forceApplyFilters} = useProfileFiltersUrlState({ - suffix, viewDefaults, }); const resetFlameGraphState = useResetFlameGraphState(); diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts index 0b9f859d344..4f0911abf4d 100644 --- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts @@ -138,7 +138,6 @@ export const decodeProfileFilters = (encoded: string): ProfileFilter[] => { }; interface UseProfileFiltersUrlStateOptions { - suffix?: '_a' | '_b'; viewDefaults?: ProfileFilter[]; } @@ -150,13 +149,13 @@ export const useProfileFiltersUrlState = ( applyViewDefaults: () => void; forceApplyFilters: (filters: ProfileFilter[]) => void; } => { - const {suffix = '', viewDefaults} = options; + const {viewDefaults} = options; const batchUpdates = useURLStateBatch(); // Store applied filters in URL state for persistence using compact encoding const [appliedFilters, setAppliedFilters] = useURLStateCustom( - `profile_filters${suffix}`, + `profile_filters`, { parse: value => { return decodeProfileFilters(value as string); @@ -169,20 +168,17 @@ export const useProfileFiltersUrlState = ( ); // Setter with preserve-existing strategy for applying view defaults - const [, setAppliedFiltersWithPreserve] = useURLStateCustom( - `profile_filters${suffix}`, - { - parse: value => { - const result = decodeProfileFilters(value as string); - return result; - }, - stringify: value => { - const result = encodeProfileFilters(value); - return result; - }, - mergeStrategy: 'preserve-existing', - } - ); + const [, setAppliedFiltersWithPreserve] = useURLStateCustom(`profile_filters`, { + parse: value => { + const result = decodeProfileFilters(value as string); + return result; + }, + stringify: value => { + const result = encodeProfileFilters(value); + return result; + }, + mergeStrategy: 'preserve-existing', + }); const memoizedAppliedFilters = useMemo(() => { return appliedFilters ?? []; @@ -204,13 +200,10 @@ export const useProfileFiltersUrlState = ( // Use this when switching views to completely replace the current filters. const forceApplyFilters = useCallback( (filters: ProfileFilter[]) => { - // Validate filters before applying const validFilters = filters.filter(f => { - // For preset filters, only need type and value if (f.type != null && isPresetKey(f.type)) { return f.value !== '' && f.type != null; } - // For regular filters, need all fields return f.value !== '' && f.type != null && f.field != null && f.matchType != null; }); diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx index a80ec597c91..9419f117d9a 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx +++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx @@ -11,10 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ReactNode} from 'react'; +import {ReactNode, act} from 'react'; // eslint-disable-next-line import/named -import {act, renderHook, waitFor} from '@testing-library/react'; +import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import {renderHook, waitFor} from '@testing-library/react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {URLStateProvider} from '@parca/components'; @@ -103,10 +104,19 @@ vi.mock('../useSumBy', async () => { const createWrapper = ( paramPreferences = {} ): (({children}: {children: ReactNode}) => JSX.Element) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); const Wrapper = ({children}: {children: ReactNode}): JSX.Element => ( - - {children} - + + + {children} + + ); Wrapper.displayName = 'URLStateProviderWrapper'; return Wrapper; @@ -127,7 +137,7 @@ describe('useQueryState', () => { const {result} = renderHook( () => useQueryState({ - defaultExpression: 'process_cpu{}', + defaultExpression: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}', defaultTimeSelection: 'relative:hour|1', defaultFrom: 1000, defaultTo: 2000, @@ -136,7 +146,7 @@ describe('useQueryState', () => { ); const {querySelection} = result.current; - expect(querySelection.expression).toBe('process_cpu{}'); + expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'); expect(querySelection.timeSelection).toBe('relative:hour|1'); // From/to should be calculated from the range expect(querySelection.from).toBeDefined(); @@ -524,7 +534,9 @@ describe('useQueryState', () => { }); describe('Edge cases', () => { - it('should handle invalid expression gracefully', () => { + it('should handle invalid expression gracefully and log warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const {result} = renderHook( () => useQueryState({ @@ -533,8 +545,33 @@ describe('useQueryState', () => { {wrapper: createWrapper()} ); - // Should not throw error + // Should not throw error - invalid expressions are caught and logged + expect(() => result.current.querySelection).not.toThrow(); + // Should fall back to empty expression + expect(result.current.querySelection.expression).toBe('invalid{{}expression'); + // Should log a warning about the parse failure + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to parse expression', + expect.objectContaining({ + expression: 'invalid{{}expression', + }) + ); + + consoleSpy.mockRestore(); + }); + + it('should handle empty expression gracefully', () => { + const {result} = renderHook( + () => + useQueryState({ + defaultExpression: '', + }), + {wrapper: createWrapper()} + ); + + // Should not throw error with empty expression expect(() => result.current.querySelection).not.toThrow(); + expect(result.current.querySelection.expression).toBe(''); }); it('should clear merge params for non-delta profiles', async () => { @@ -585,6 +622,37 @@ describe('useQueryState', () => { expect(params.unrelated).toBe('test'); }); }); + + it('should reset query to default state', async () => { + mockLocation.search = + '?expression=memory:inuse_space:bytes:space:bytes{}&sum_by=function&group_by=namespace'; + + const {result} = renderHook( + () => + useQueryState({ + defaultExpression: 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}', + }), + {wrapper: createWrapper()} + ); + + // Verify initial state from URL + expect(result.current.querySelection.expression).toBe( + 'memory:inuse_space:bytes:space:bytes{}' + ); + + // Reset query + act(() => { + result.current.resetQuery(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + expect(params.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'); + expect(params.sum_by).toBeUndefined(); + expect(params.group_by).toBeUndefined(); + }); + }); }); describe('Commit with refreshed time range (time range re-evaluation)', () => { @@ -1191,7 +1259,8 @@ describe('useQueryState', () => { }); it('should preserve other URL params when setting ProfileSelection', async () => { - mockLocation.search = '?expression_a=process_cpu{}&other_param=value&unrelated=test'; + mockLocation.search = + '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test'; const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()}); @@ -1212,7 +1281,7 @@ describe('useQueryState', () => { expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}'); // Other params should be preserved - expect(params.expression_a).toBe('process_cpu{}'); + expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'); expect(params.other_param).toBe('value'); expect(params.unrelated).toBe('test'); }); @@ -1251,7 +1320,8 @@ describe('useQueryState', () => { }); // Now set URL params manually - mockLocation.search = '?expression=custom{}&sum_by=line&group_by=pod'; + mockLocation.search = + '?expression=memory:inuse_space:bytes:space:bytes{}&sum_by=line&group_by=pod'; mockNavigateTo.mockClear(); const {result: result2} = renderHook(() => useQueryState({viewDefaults}), { @@ -1269,7 +1339,7 @@ describe('useQueryState', () => { // Either no navigation or navigation preserves existing values if (mockNavigateTo.mock.calls.length > 0) { const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; - expect(params.expression).toBe('custom{}'); + expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}'); expect(params.sum_by).toBe('line'); expect(params.group_by).toBe('pod'); } @@ -1293,48 +1363,12 @@ describe('useQueryState', () => { ); // Test without profile type - mockLocation.search = '?expression={comm="prometheus"}'; + mockLocation.search = ''; const {result: result2} = renderHook(() => useQueryState({}), {wrapper: createWrapper()}); expect(result2.current.hasProfileType).toBe(false); expect(result2.current.profileTypeString).toBe(''); - expect(result2.current.matchersOnly).toBe('{comm="prometheus"}'); - }); - - it('should manage group_by only for _a hook in comparison mode', async () => { - mockLocation.search = ''; - - // Render _a hook - const {result: resultA} = renderHook(() => useQueryState({suffix: '_a'}), { - wrapper: createWrapper(), - }); - - // Render _b hook - const {result: resultB} = renderHook(() => useQueryState({suffix: '_b'}), { - wrapper: createWrapper(), - }); - - // _a hook should have group-by methods - expect(resultA.current.groupBy).toBeDefined(); - expect(resultA.current.setGroupBy).toBeDefined(); - expect(resultA.current.isGroupByLoading).toBeDefined(); - - // _b hook should NOT have group-by methods - expect(resultB.current.groupBy).toBeUndefined(); - expect(resultB.current.setGroupBy).toBeUndefined(); - expect(resultB.current.isGroupByLoading).toBeUndefined(); - - // Set group_by via _a hook - act(() => { - resultA.current.setGroupBy?.(['namespace', 'pod']); - }); - - await waitFor(() => { - expect(mockNavigateTo).toHaveBeenCalled(); - const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; - expect(params.group_by).toBe('namespace,pod'); - }); }); }); }); diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index b41ddcd63f9..9b69c9613e6 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -82,11 +82,6 @@ interface UseQueryStateReturn { matchersOnly: string; fullExpression: string; - // Group-by state (only for _a hook, undefined for _b) - groupBy?: string[]; - setGroupBy?: (groupBy: string[] | undefined) => void; - isGroupByLoading?: boolean; - // Methods applyViewDefaults: () => void; resetQuery: () => void; @@ -154,11 +149,8 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const [sumByParam, setSumByParam] = useURLState(`sum_by${suffix}`); - // Group-by state - only enabled for _a hook (or when no suffix) - // This ensures only one hook manages the shared group_by param in comparison mode - const isGroupByEnabled = suffix === '' || suffix === '_a'; - const [groupByParam, setGroupByParam] = useURLState('group_by', { - enabled: isGroupByEnabled, + const [, setGroupByParam] = useURLState('group_by', { + alwaysReturnArray: true, }); // Separate setters for applying view defaults with preserve-existing strategy @@ -169,7 +161,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState mergeStrategy: 'preserve-existing', }); const [, setGroupByWithPreserve] = useURLState('group_by', { - enabled: isGroupByEnabled, + alwaysReturnArray: true, mergeStrategy: 'preserve-existing', }); @@ -220,7 +212,11 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const draftQuery = useMemo(() => { try { return Query.parse(draftExpression ?? ''); - } catch { + } catch (error) { + console.warn('Failed to parse draft expression', { + expression: draftExpression, + error: error instanceof Error ? error.message : String(error), + }); return Query.parse(''); } }, [draftExpression]); @@ -228,7 +224,11 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const query = useMemo(() => { try { return Query.parse(expression ?? ''); - } catch { + } catch (error) { + console.warn('Failed to parse expression', { + expression, + error: error instanceof Error ? error.message : String(error), + }); return Query.parse(''); } }, [expression]); @@ -557,13 +557,11 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } } - // Apply sum_by default using preserve-existing strategy if (defaults.sumBy !== undefined) { setSumByWithPreserve(sumByToParam(defaults.sumBy)); } - // Apply group_by default only for _a hook using preserve-existing strategy - if (isGroupByEnabled && defaults.groupBy !== undefined) { + if (defaults.groupBy !== undefined) { setGroupByWithPreserve(defaults.groupBy.join(',')); } }); @@ -574,7 +572,6 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState sharedDefaults, setExpressionWithPreserve, setSumByWithPreserve, - isGroupByEnabled, setGroupByWithPreserve, profileTypesLoading, profileTypesData, @@ -586,23 +583,18 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState batchUpdates(() => { setExpressionState(defaultExpression); setSumByParam(undefined); - if (isGroupByEnabled) { - setGroupByParam(undefined); - } + setGroupByParam(undefined); }); - }, [ - batchUpdates, - setExpressionState, - defaultExpression, - setSumByParam, - isGroupByEnabled, - setGroupByParam, - ]); + }, [batchUpdates, setExpressionState, defaultExpression, setSumByParam, setGroupByParam]); const draftParsedQuery = useMemo(() => { try { return Query.parse(draftSelection.expression ?? ''); - } catch { + } catch (error) { + console.warn('Failed to parse draft selection expression', { + expression: draftSelection.expression, + error: error instanceof Error ? error.message : String(error), + }); return Query.parse(''); } }, [draftSelection.expression]); @@ -610,7 +602,11 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const parsedQuery = useMemo(() => { try { return Query.parse(querySelection.expression ?? ''); - } catch { + } catch (error) { + console.warn('Failed to parse query selection expression', { + expression: querySelection.expression, + error: error instanceof Error ? error.message : String(error), + }); return Query.parse(''); } }, [querySelection.expression]); @@ -626,19 +622,32 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } const expr = expression ?? defaultExpression; - const parsed = Query.parse(expr); + try { + const parsed = Query.parse(expr); - const profileType = parsed.profileType(); - const profileTypeStr = profileType.toString(); - const hasProfile = profileTypeStr !== ''; - const matchers = `{${parsed.matchersString()}}`; + const profileType = parsed.profileType(); + const profileTypeStr = profileType.toString(); + const hasProfile = profileTypeStr !== ''; + const matchers = `{${parsed.matchersString()}}`; - return { - hasProfileType: hasProfile, - profileTypeString: profileTypeStr, - matchersOnly: matchers, - fullExpression: parsed.toString(), - }; + return { + hasProfileType: hasProfile, + profileTypeString: profileTypeStr, + matchersOnly: matchers, + fullExpression: parsed.toString(), + }; + } catch (error) { + console.warn('Failed to parse expression for profile type extraction', { + expression: expr, + error: error instanceof Error ? error.message : String(error), + }); + return { + hasProfileType: false, + profileTypeString: '', + matchersOnly: '{}', + fullExpression: expr, + }; + } }, [expression, defaultExpression]); // Re-apply view defaults when profile types finish loading (for matchers-only expressions) @@ -693,21 +702,6 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState matchersOnly, fullExpression, - // Group-by state (only for _a hook) - ...(isGroupByEnabled - ? { - groupBy: - groupByParam != null && typeof groupByParam === 'string' && groupByParam !== '' - ? groupByParam.split(',').filter(Boolean) - : undefined, - setGroupBy: (groupBy: string[] | undefined) => { - setGroupByParam(groupBy?.join(',')); - }, - isGroupByLoading: false, - } - : {}), - - // Methods applyViewDefaults, resetQuery, }; From 3327f07175e2f5dc67856b52e9cd4325bc16fa80 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Tue, 6 Jan 2026 15:46:55 +0100 Subject: [PATCH 08/12] Add forceApplyViewDefaults to useQueryState hook Introduces a forceApplyViewDefaults method to the useQueryState hook, allowing view defaults to be forcibly applied and overwrite existing URL parameters. Updates tests to cover this new behavior and ensures groupBy and sumBy are applied even if profile types are loading or unavailable for matchers-only expressions. Also fixes groupBy handling in useURLState to always return an array. --- .../ProfileSelector/useAutoQuerySelector.ts | 2 +- .../profile/src/QueryControls/index.tsx | 4 +- .../profile/src/hooks/useQueryState.test.tsx | 239 ++++++++++++++++++ .../shared/profile/src/hooks/useQueryState.ts | 114 +++++++-- 4 files changed, 333 insertions(+), 26 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts b/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts index c085840c900..7ce8dcf5c04 100644 --- a/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts +++ b/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts @@ -137,7 +137,7 @@ export const useAutoQuerySelector = ({ } dispatch(setAutoQuery('true')); let profileType = profileTypesData.types.find( - type => type.name === 'parca_agent' && type.delta + type => type.name === 'parca_agent' && type.sampleType === 'samples' && type.delta ); if (profileType == null) { profileType = profileTypesData.types.find( diff --git a/ui/packages/shared/profile/src/QueryControls/index.tsx b/ui/packages/shared/profile/src/QueryControls/index.tsx index cc66510039c..9983358b39c 100644 --- a/ui/packages/shared/profile/src/QueryControls/index.tsx +++ b/ui/packages/shared/profile/src/QueryControls/index.tsx @@ -177,9 +177,7 @@ export function QueryControls({ {viewComponent?.createViewComponent} - {viewComponent?.disableExplorativeQuerying === true && - viewComponent?.labelnames !== undefined && - viewComponent?.labelnames.length >= 1 ? ( + {viewComponent?.labelnames !== undefined && viewComponent?.labelnames.length >= 1 ? ( ) : showAdvancedMode && advancedModeForQueryBrowser ? ( { }; }); +// Track profile types loading state for tests +let mockProfileTypesLoading = false; +let mockProfileTypesData: + | { + types: Array<{ + name: string; + sampleType: string; + sampleUnit: string; + periodType: string; + periodUnit: string; + delta: boolean; + }>; + } + | undefined = undefined; + +// Mock useProfileTypes to control loading state in tests +vi.mock('../ProfileSelector', async () => { + const actual = await vi.importActual('../ProfileSelector'); + return { + ...actual, + useProfileTypes: () => ({ + loading: mockProfileTypesLoading, + data: mockProfileTypesData, + error: null, + }), + }; +}); + +// Helper to set profile types loading state for tests +const setProfileTypesLoading = (loading: boolean) => { + mockProfileTypesLoading = loading; +}; + +const setProfileTypesData = (data: typeof mockProfileTypesData) => { + mockProfileTypesData = data; +}; + // Helper to create wrapper with URLStateProvider const createWrapper = ( paramPreferences = {} @@ -130,6 +167,9 @@ describe('useQueryState', () => { writable: true, }); mockLocation.search = ''; + // Reset profile types mock state + setProfileTypesLoading(false); + setProfileTypesData(undefined); }); describe('Basic functionality', () => { @@ -1370,5 +1410,204 @@ describe('useQueryState', () => { expect(result2.current.hasProfileType).toBe(false); expect(result2.current.profileTypeString).toBe(''); }); + + it('should force apply view defaults and overwrite existing URL params', async () => { + // Start with existing URL params + mockLocation.search = + '?expression=memory:inuse_space:bytes:space:bytes{}&sum_by=line&group_by=pod'; + + const viewDefaults = { + expression: 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}', + sumBy: ['function'], + groupBy: ['namespace'], + }; + + const {result} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Force apply view defaults - should overwrite existing values + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Should overwrite with view defaults, not preserve existing + expect(params.expression).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}' + ); + expect(params.sum_by).toBe('function'); + expect(params.group_by).toBe('namespace'); + }); + }); + + it('should force apply only provided view defaults', async () => { + // Start with existing URL params + mockLocation.search = + '?expression=memory:inuse_space:bytes:space:bytes{}&sum_by=line&group_by=pod'; + + // Only provide expression in viewDefaults + const viewDefaults = { + expression: 'process_cpu:samples:count:cpu:nanoseconds:delta{job="new"}', + }; + + const {result} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Should overwrite expression + expect(params.expression).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta{job="new"}' + ); + // sum_by and group_by should remain from URL since not in viewDefaults + expect(params.sum_by).toBe('line'); + expect(params.group_by).toBe('pod'); + }); + }); + + it('should force apply view defaults with suffix for comparison mode', async () => { + // Start with existing URL params for side _a + mockLocation.search = '?expression_a=memory:inuse_space:bytes:space:bytes{}&sum_by_a=line'; + + const viewDefaults = { + expression: 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}', + sumBy: ['function'], + }; + + const {result} = renderHook(() => useQueryState({suffix: '_a', viewDefaults}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Should overwrite _a suffixed params + expect(params.expression_a).toBe( + 'process_cpu:samples:count:cpu:nanoseconds:delta{job="default"}' + ); + expect(params.sum_by_a).toBe('function'); + }); + }); + + it('should not apply anything when viewDefaults is undefined', async () => { + mockLocation.search = '?expression=memory:inuse_space:bytes:space:bytes{}'; + + const {result} = renderHook(() => useQueryState({}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + // Should not navigate since there are no defaults to apply + expect(mockNavigateTo).not.toHaveBeenCalled(); + }); + + it('should use sharedDefaults for _b suffix when viewDefaults not provided', async () => { + mockLocation.search = '?expression_b=memory:inuse_space:bytes:space:bytes{}&group_by=pod'; + + const sharedDefaults = { + groupBy: ['function_name'], + }; + + const {result} = renderHook(() => useQueryState({suffix: '_b', sharedDefaults}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Should overwrite group_by with sharedDefaults value + expect(params.group_by).toBe('function_name'); + }); + }); + + it('should still apply groupBy and sumBy when profile types are loading for matchers-only expression', async () => { + // This test covers the bug where forceApplyViewDefaults would bail out entirely + // when profile types were still loading, even though groupBy and sumBy don't depend on profile types + mockLocation.search = + '?expression=parca_agent:wallclock:nanoseconds{}&group_by=old_value&sum_by=old_sum'; + + // Simulate profile types still loading + setProfileTypesLoading(true); + + const viewDefaults = { + expression: '{namespace="test"}', // Matchers-only expression that needs profile types + sumBy: ['new_sum'], + groupBy: ['new_group', 'another_group'], + }; + + const {result} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // Expression should NOT be set (profile types still loading) + // But groupBy and sumBy SHOULD still be applied + expect(params.sum_by).toBe('new_sum'); + expect(params.group_by).toBe('new_group,another_group'); + }); + }); + + it('should still apply groupBy and sumBy when profile types are unavailable for matchers-only expression', async () => { + // Similar to above but for when profile types finished loading with no data + mockLocation.search = + '?expression=process_cpu:samples:count:cpu:nanoseconds:delta{}&group_by=old_value'; + + // Simulate profile types loaded but with empty types array + // Set loading to false and data to undefined to avoid triggering the auto-apply useEffect + // (the auto-apply requires profileTypesData != null) + setProfileTypesLoading(false); + setProfileTypesData(undefined); + + const viewDefaults = { + expression: '{namespace="test"}', // Matchers-only expression that will fail to apply + groupBy: ['function_name', 'labels.cpu'], + }; + + const {result} = renderHook(() => useQueryState({viewDefaults}), { + wrapper: createWrapper(), + }); + + // Clear any calls from initial render + mockNavigateTo.mockClear(); + + act(() => { + result.current.forceApplyViewDefaults(); + }); + + await waitFor(() => { + expect(mockNavigateTo).toHaveBeenCalled(); + // The forceApplyViewDefaults call should set group_by to the new value + const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1]; + // groupBy should still be applied even though expression couldn't be + // (because profileTypesData is undefined, making the matchers-only expression fail) + expect(params.group_by).toBe('function_name,labels.cpu'); + }); + }); }); }); diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index 9b69c9613e6..9b9958a559c 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -84,6 +84,7 @@ interface UseQueryStateReturn { // Methods applyViewDefaults: () => void; + forceApplyViewDefaults: () => void; resetQuery: () => void; } @@ -160,7 +161,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const [, setSumByWithPreserve] = useURLState(`sum_by${suffix}`, { mergeStrategy: 'preserve-existing', }); - const [, setGroupByWithPreserve] = useURLState('group_by', { + const [, setGroupByWithPreserve] = useURLState('group_by', { alwaysReturnArray: true, mergeStrategy: 'preserve-existing', }); @@ -201,6 +202,10 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState hasMatchersOnlyDefault ? timeRangeForProfileTypes.getToMs() : undefined ); + // Track if we need to force-apply view defaults after profile types load + // (when forceApplyViewDefaults was called but skipped due to profile types still loading) + const [pendingForceApply, setPendingForceApply] = useState(false); + // Draft state management const [draftExpression, setDraftExpression] = useState(expression ?? defaultExpression); const [draftFrom, setDraftFrom] = useState(from ?? defaultFrom?.toString() ?? ''); @@ -533,25 +538,20 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState const isMatchersOnly = defaults.expression.trim().startsWith('{'); if (isMatchersOnly) { - if (profileTypesLoading) return; - - if ( - profileTypesError != null || - profileTypesData == null || - profileTypesData.types.length === 0 - ) { - console.warn('Cannot apply matchers-only view default: no profile types available', { - expression: defaults.expression, - error: profileTypesError, - }); - return; + // Skip if profile types are still loading - will be retried via useEffect + const canApplyMatchersOnly = + !profileTypesLoading && + profileTypesError == null && + profileTypesData != null && + profileTypesData.types.length > 0; + + if (canApplyMatchersOnly) { + const fullExpression = prependProfileTypeToMatchers( + defaults.expression, + profileTypesData + ); + setExpressionWithPreserve(fullExpression); } - - const fullExpression = prependProfileTypeToMatchers( - defaults.expression, - profileTypesData - ); - setExpressionWithPreserve(fullExpression); } else { setExpressionWithPreserve(defaults.expression); } @@ -562,7 +562,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } if (defaults.groupBy !== undefined) { - setGroupByWithPreserve(defaults.groupBy.join(',')); + setGroupByWithPreserve(defaults.groupBy); } }); }, [ @@ -578,6 +578,62 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState profileTypesError, ]); + // Force apply view defaults to URL params (overwrites existing values) + const forceApplyViewDefaults = useCallback(() => { + batchUpdates(() => { + const defaults = suffix === '' || suffix === '_a' ? viewDefaults : sharedDefaults; + if (defaults === undefined) { + return; + } + + if (defaults.expression !== undefined) { + const isMatchersOnly = defaults.expression.trim().startsWith('{'); + + if (isMatchersOnly) { + // Skip if profile types not ready - pendingForceApply triggers retry + const canApplyMatchersOnly = + !profileTypesLoading && + profileTypesError == null && + profileTypesData != null && + profileTypesData.types.length > 0; + + if (canApplyMatchersOnly) { + const fullExpression = prependProfileTypeToMatchers( + defaults.expression, + profileTypesData + ); + setExpressionState(fullExpression); + setPendingForceApply(false); + } else { + setPendingForceApply(true); + } + } else { + setExpressionState(defaults.expression); + } + } + + if (defaults.sumBy !== undefined) { + setSumByParam(sumByToParam(defaults.sumBy)); + } + + if (defaults.groupBy !== undefined) { + setGroupByParam(defaults.groupBy); + } + }); + }, [ + batchUpdates, + suffix, + viewDefaults, + sharedDefaults, + setExpressionState, + setSumByParam, + setGroupByParam, + profileTypesLoading, + profileTypesData, + profileTypesError, + setPendingForceApply, + ]); + // Reset query to default state const resetQuery = useCallback(() => { batchUpdates(() => { @@ -652,6 +708,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState // Re-apply view defaults when profile types finish loading (for matchers-only expressions) const [profileTypesLoadedOnce, setProfileTypesLoadedOnce] = useState(false); + useEffect( () => { if ( @@ -661,11 +718,23 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState profileTypesData != null ) { setProfileTypesLoadedOnce(true); - applyViewDefaults(); + // Use forceApplyViewDefaults if we had a pending force apply, otherwise use regular apply + if (pendingForceApply) { + setPendingForceApply(false); + forceApplyViewDefaults(); + } else { + applyViewDefaults(); + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [hasMatchersOnlyDefault, profileTypesLoading, profileTypesLoadedOnce, profileTypesData] + [ + hasMatchersOnlyDefault, + profileTypesLoading, + profileTypesLoadedOnce, + profileTypesData, + pendingForceApply, + ] ); return { @@ -703,6 +772,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState fullExpression, applyViewDefaults, + forceApplyViewDefaults, resetQuery, }; }; From 5e762b557cfd4b35d63c7c8d12491c6c588b5092 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Tue, 6 Jan 2026 21:43:28 +0100 Subject: [PATCH 09/12] Add profile filter defaults and profile type change handling --- .../components/src/ParcaContext/index.tsx | 1 + .../profile/src/ProfileSelector/index.tsx | 17 +++++++++- .../shared/profile/src/hooks/useQueryState.ts | 33 ++++++++----------- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/ui/packages/shared/components/src/ParcaContext/index.tsx b/ui/packages/shared/components/src/ParcaContext/index.tsx index 6ea59f6b55a..0830ef3f697 100644 --- a/ui/packages/shared/components/src/ParcaContext/index.tsx +++ b/ui/packages/shared/components/src/ParcaContext/index.tsx @@ -68,6 +68,7 @@ interface Props { disableProfileTypesDropdown?: boolean; labelnames?: string[]; disableExplorativeQuerying?: boolean; + profileFilterDefaults?: unknown[]; }; profileViewExternalMainActions?: ReactNode; profileViewExternalSubActions?: ReactNode; diff --git a/ui/packages/shared/profile/src/ProfileSelector/index.tsx b/ui/packages/shared/profile/src/ProfileSelector/index.tsx index 92d63b574a3..6abb99ee94c 100644 --- a/ui/packages/shared/profile/src/ProfileSelector/index.tsx +++ b/ui/packages/shared/profile/src/ProfileSelector/index.tsx @@ -30,6 +30,10 @@ import {TEST_IDS, testId} from '@parca/test-utils'; import {millisToProtoTimestamp, type NavigateFunction} from '@parca/utilities'; import {useMetricsGraphDimensions} from '../MetricsGraph/useMetricsGraphDimensions'; +import { + ProfileFilter, + useProfileFilters, +} from '../ProfileView/components/ProfileFilters/useProfileFilters'; import {QueryControls} from '../QueryControls'; import {LabelsQueryProvider, useLabelsQueryProvider} from '../contexts/LabelsQueryProvider'; import {UnifiedLabelsProvider} from '../contexts/UnifiedLabelsContext'; @@ -119,6 +123,17 @@ const ProfileSelector = ({ const [queryBrowserMode, setQueryBrowserMode] = useURLState('query_browser_mode'); const batchUpdates = useURLStateBatch(); + const profileFilterDefaults = viewComponent?.profileFilterDefaults as ProfileFilter[] | undefined; + const {forceApplyFilters} = useProfileFilters({ + viewDefaults: profileFilterDefaults, + }); + + const handleProfileTypeChange = useCallback(() => { + if (profileFilterDefaults != null && profileFilterDefaults.length > 0) { + forceApplyFilters(profileFilterDefaults); + } + }, [forceApplyFilters, profileFilterDefaults]); + // Use the new useQueryState hook - reads directly from URL params const { querySelection, @@ -133,7 +148,7 @@ const ProfileSelector = ({ setProfileSelection, sumByLoading, draftParsedQuery, - } = useQueryState({suffix}); + } = useQueryState({suffix, onProfileTypeChange: handleProfileTypeChange}); // Use draft state for local state instead of committed state const [timeRangeSelection, setTimeRangeSelection] = useState( diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts index 9b9958a559c..3517b960fa1 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.ts +++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts @@ -39,6 +39,7 @@ interface UseQueryStateOptions { comparing?: boolean; // If true, don't auto-select for delta profiles viewDefaults?: ViewDefaults; // View-specific defaults that don't overwrite URL params sharedDefaults?: ViewDefaults; // Shared defaults across both comparison sides + onProfileTypeChange?: () => void; // Called when profile type changes on commit, after reset } interface UseQueryStateReturn { @@ -125,6 +126,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState comparing = false, viewDefaults, sharedDefaults, + onProfileTypeChange, } = options; const batchUpdates = useURLStateBatch(); @@ -450,6 +452,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState Query.parse(querySelection.expression).profileType().toString() ) { resetStateOnProfileTypeChange(); + onProfileTypeChange?.(); } }); }, @@ -473,6 +476,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState setSelectionParam, resetFlameGraphState, resetStateOnProfileTypeChange, + onProfileTypeChange, draftProfileType, querySelection.expression, ] @@ -706,35 +710,24 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState } }, [expression, defaultExpression]); - // Re-apply view defaults when profile types finish loading (for matchers-only expressions) - const [profileTypesLoadedOnce, setProfileTypesLoadedOnce] = useState(false); - + // Handle pending force apply when profile types finish loading (for matchers-only expressions) + // This effect only handles the async case where forceApplyViewDefaults was called + // but profile types weren't ready yet useEffect( () => { if ( + pendingForceApply && hasMatchersOnlyDefault && !profileTypesLoading && - !profileTypesLoadedOnce && - profileTypesData != null + profileTypesData != null && + profileTypesData.types.length > 0 ) { - setProfileTypesLoadedOnce(true); - // Use forceApplyViewDefaults if we had a pending force apply, otherwise use regular apply - if (pendingForceApply) { - setPendingForceApply(false); - forceApplyViewDefaults(); - } else { - applyViewDefaults(); - } + setPendingForceApply(false); + forceApplyViewDefaults(); } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ - hasMatchersOnlyDefault, - profileTypesLoading, - profileTypesLoadedOnce, - profileTypesData, - pendingForceApply, - ] + [hasMatchersOnlyDefault, profileTypesLoading, profileTypesData, pendingForceApply] ); return { From 8f680f5a19d2fe4676e634a8b813779a98ceb76b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:13:36 +0000 Subject: [PATCH 10/12] [pre-commit.ci lite] apply automatic fixes --- cmd/parca/main.go | 2 +- env-jsonnet.sh | 2 +- env-local-test.sh | 2 +- env-proto.sh | 2 +- env.sh | 2 +- gen/proto/go/parca/query/v1alpha1/validate.go | 2 +- pkg/badgerlogger/badgerlogger.go | 2 +- pkg/cache/cache.go | 2 +- pkg/cache/cache_test.go | 2 +- pkg/cache/cache_with_eviction.go | 2 +- pkg/cache/cache_with_ttl.go | 2 +- pkg/cache/loading.go | 2 +- pkg/cache/loading_test.go | 2 +- pkg/cache/lru/lru.go | 2 +- pkg/cache/lru/lru_test.go | 2 +- pkg/cache/lru/lru_with_eviction_test.go | 2 +- pkg/cache/lru/metrics.go | 2 +- pkg/cache/lru/options.go | 2 +- pkg/cache/noop.go | 2 +- pkg/compactdictionary/compactdictionary.go | 2 +- pkg/config/config.go | 2 +- pkg/config/config_test.go | 2 +- pkg/config/reloader.go | 2 +- pkg/config/reloader_test.go | 2 +- pkg/config/secret.go | 2 +- pkg/config/validation.go | 2 +- pkg/debuginfo/client.go | 2 +- pkg/debuginfo/debuginfod.go | 2 +- pkg/debuginfo/debuginfod_test.go | 2 +- pkg/debuginfo/fetcher.go | 2 +- pkg/debuginfo/forwarder.go | 2 +- pkg/debuginfo/metadata.go | 2 +- pkg/debuginfo/metadata_test.go | 2 +- pkg/debuginfo/reader.go | 2 +- pkg/debuginfo/store.go | 2 +- pkg/debuginfo/store_test.go | 2 +- pkg/demangle/demangle.go | 2 +- pkg/demangle/rust_test.go | 2 +- pkg/hash/hash.go | 2 +- pkg/ingester/ingester.go | 2 +- pkg/ingester/ingester_test.go | 2 +- pkg/kv/keymaker.go | 2 +- pkg/normalizer/arrow.go | 2 +- pkg/normalizer/normalizer.go | 2 +- pkg/normalizer/normalizer_test.go | 2 +- pkg/normalizer/otel.go | 2 +- pkg/normalizer/validate.go | 2 +- pkg/parca/logger.go | 2 +- pkg/parca/parca.go | 2 +- pkg/parca/parca_test.go | 2 +- pkg/parca/testdata/pgotest.go | 2 +- pkg/parcacol/arrow.go | 2 +- pkg/parcacol/arrow_test.go | 2 +- pkg/parcacol/querier.go | 2 +- pkg/profile/decode.go | 2 +- pkg/profile/encode.go | 2 +- pkg/profile/encode_test.go | 2 +- pkg/profile/executableinfo.go | 2 +- pkg/profile/profile.go | 2 +- pkg/profile/reader.go | 2 +- pkg/profile/schema.go | 2 +- pkg/profile/uvarint.go | 2 +- pkg/profile/writer.go | 2 +- pkg/profilestore/grpc.go | 2 +- pkg/profilestore/profilecolumnstore.go | 2 +- pkg/profilestore/profilestore_test.go | 2 +- pkg/query/callgraph.go | 2 +- pkg/query/callgraph_test.go | 2 +- pkg/query/columnquery.go | 2 +- pkg/query/columnquery_test.go | 2 +- pkg/query/flamegraph.go | 2 +- pkg/query/flamegraph_arrow.go | 2 +- pkg/query/flamegraph_arrow_test.go | 2 +- pkg/query/flamegraph_flat.go | 2 +- pkg/query/flamegraph_flat_test.go | 2 +- pkg/query/flamegraph_table.go | 2 +- pkg/query/flamegraph_table_test.go | 2 +- pkg/query/multiple_filters_test.go | 2 +- pkg/query/pprof.go | 2 +- pkg/query/pprof_test.go | 2 +- pkg/query/query_test.go | 2 +- pkg/query/sources.go | 2 +- pkg/query/sources_reader.go | 2 +- pkg/query/sources_reader_test.go | 2 +- pkg/query/sources_test.go | 2 +- pkg/query/string_match_bench_test.go | 2 +- pkg/query/table.go | 2 +- pkg/query/table_test.go | 2 +- pkg/query/top.go | 2 +- pkg/query/top_test.go | 2 +- pkg/runutil/runutil.go | 2 +- pkg/scrape/manager.go | 2 +- pkg/scrape/scrape.go | 2 +- pkg/scrape/scrape_test.go | 2 +- pkg/scrape/service.go | 2 +- pkg/scrape/target.go | 2 +- pkg/scrape/target_test.go | 2 +- pkg/server/fallback.go | 2 +- pkg/server/grpc_codec.go | 2 +- pkg/server/server.go | 2 +- pkg/signedrequests/client.go | 2 +- pkg/signedrequests/gcs.go | 2 +- pkg/signedrequests/s3.go | 2 +- pkg/symbol/addr2line/doc.go | 2 +- pkg/symbol/addr2line/dwarf.go | 2 +- pkg/symbol/addr2line/dwarf_test.go | 2 +- pkg/symbol/addr2line/go.go | 2 +- pkg/symbol/addr2line/symtab.go | 2 +- pkg/symbol/addr2line/symtab_test.go | 2 +- pkg/symbol/demangle/demangle.go | 2 +- pkg/symbol/demangle/demangle_test.go | 2 +- pkg/symbol/elfutils/debuginfofile.go | 2 +- pkg/symbol/elfutils/elfutils.go | 2 +- pkg/symbol/elfutils/elfutils_test.go | 2 +- pkg/symbol/elfutils/testdata/main.go | 2 +- pkg/symbol/symbolsearcher/symbol_searcher.go | 2 +- pkg/symbolizer/cache.go | 2 +- pkg/symbolizer/decode.go | 2 +- pkg/symbolizer/encode.go | 2 +- pkg/symbolizer/encode_test.go | 2 +- pkg/symbolizer/normalize.go | 2 +- pkg/symbolizer/symbolizer.go | 2 +- pkg/symbolizer/symbolizer_test.go | 2 +- pkg/telemetry/telemetry.go | 2 +- pkg/tracer/tracer.go | 2 +- proto/buf.lock | 4 +- scripts/check-license.sh | 2 +- scripts/free_disk_space.sh | 2 +- scripts/install-minikube.sh | 2 +- scripts/local-dev.sh | 2 +- snap/hooks/configure | 2 +- snap/parca-wrapper | 2 +- ui/packages/app/web/build/keep.go | 2 +- ui/packages/app/web/public/keep.go | 31 +++++++------- ui/packages/app/web/scripts/build-preview.sh | 41 +++++++++++-------- 135 files changed, 175 insertions(+), 165 deletions(-) diff --git a/cmd/parca/main.go b/cmd/parca/main.go index 64aa5a33a20..0924f2d5036 100644 --- a/cmd/parca/main.go +++ b/cmd/parca/main.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/env-jsonnet.sh b/env-jsonnet.sh index 061d3eb8d13..c5b9c091f86 100755 --- a/env-jsonnet.sh +++ b/env-jsonnet.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copyright 2023-2025 The Parca Authors +# Copyright 2023-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/env-local-test.sh b/env-local-test.sh index 8e055147309..15f08fba7bb 100755 --- a/env-local-test.sh +++ b/env-local-test.sh @@ -1,5 +1,5 @@ #! /usr/bin/env bash -# Copyright 2023-2025 The Parca Authors +# Copyright 2023-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/env-proto.sh b/env-proto.sh index 2da60eafeff..965f2383e9b 100755 --- a/env-proto.sh +++ b/env-proto.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copyright 2023-2025 The Parca Authors +# Copyright 2023-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/env.sh b/env.sh index 4b411248015..594d1b44fb0 100755 --- a/env.sh +++ b/env.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copyright 2023-2025 The Parca Authors +# Copyright 2023-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/gen/proto/go/parca/query/v1alpha1/validate.go b/gen/proto/go/parca/query/v1alpha1/validate.go index abfb1565b32..ba35fa4dc2c 100644 --- a/gen/proto/go/parca/query/v1alpha1/validate.go +++ b/gen/proto/go/parca/query/v1alpha1/validate.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/badgerlogger/badgerlogger.go b/pkg/badgerlogger/badgerlogger.go index f68d8f8eff4..7405eed83a0 100644 --- a/pkg/badgerlogger/badgerlogger.go +++ b/pkg/badgerlogger/badgerlogger.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index eb93d3224fb..42848d3be17 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index 79d6ab2d996..062587bb5b0 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/cache_with_eviction.go b/pkg/cache/cache_with_eviction.go index b8bb5f9d4cd..40721f777e3 100644 --- a/pkg/cache/cache_with_eviction.go +++ b/pkg/cache/cache_with_eviction.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/cache_with_ttl.go b/pkg/cache/cache_with_ttl.go index 251b1c6e23d..b1c4b6685ae 100644 --- a/pkg/cache/cache_with_ttl.go +++ b/pkg/cache/cache_with_ttl.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/loading.go b/pkg/cache/loading.go index c9a7890a2db..4c21612eadc 100644 --- a/pkg/cache/loading.go +++ b/pkg/cache/loading.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/loading_test.go b/pkg/cache/loading_test.go index 1a9648ef237..b58b08f74bf 100644 --- a/pkg/cache/loading_test.go +++ b/pkg/cache/loading_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/lru/lru.go b/pkg/cache/lru/lru.go index d4c196972ba..28a4bb85001 100644 --- a/pkg/cache/lru/lru.go +++ b/pkg/cache/lru/lru.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/lru/lru_test.go b/pkg/cache/lru/lru_test.go index 4792b519f3d..bafe165e052 100644 --- a/pkg/cache/lru/lru_test.go +++ b/pkg/cache/lru/lru_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/lru/lru_with_eviction_test.go b/pkg/cache/lru/lru_with_eviction_test.go index 12d533343a5..5498acf01e9 100644 --- a/pkg/cache/lru/lru_with_eviction_test.go +++ b/pkg/cache/lru/lru_with_eviction_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/lru/metrics.go b/pkg/cache/lru/metrics.go index 9ca416c57ae..1b5e0f7934d 100644 --- a/pkg/cache/lru/metrics.go +++ b/pkg/cache/lru/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/lru/options.go b/pkg/cache/lru/options.go index acd9c4b138b..8e92dd2da0b 100644 --- a/pkg/cache/lru/options.go +++ b/pkg/cache/lru/options.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/cache/noop.go b/pkg/cache/noop.go index 803078de226..db53ee44f00 100644 --- a/pkg/cache/noop.go +++ b/pkg/cache/noop.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/compactdictionary/compactdictionary.go b/pkg/compactdictionary/compactdictionary.go index c4ab0c0767e..ca3e10c7415 100644 --- a/pkg/compactdictionary/compactdictionary.go +++ b/pkg/compactdictionary/compactdictionary.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/config.go b/pkg/config/config.go index c37927a0e86..ed2753e0241 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7e257d0ee92..06c41ea45b8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/reloader.go b/pkg/config/reloader.go index db56f7faecb..695995d439e 100644 --- a/pkg/config/reloader.go +++ b/pkg/config/reloader.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/reloader_test.go b/pkg/config/reloader_test.go index f22d59a4369..f1625de3e3b 100644 --- a/pkg/config/reloader_test.go +++ b/pkg/config/reloader_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/secret.go b/pkg/config/secret.go index d42b74b308b..9ca056b898c 100644 --- a/pkg/config/secret.go +++ b/pkg/config/secret.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/config/validation.go b/pkg/config/validation.go index 3dbbddf20ab..11fcab0aad9 100644 --- a/pkg/config/validation.go +++ b/pkg/config/validation.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/client.go b/pkg/debuginfo/client.go index 64265b20635..903411b3ba1 100644 --- a/pkg/debuginfo/client.go +++ b/pkg/debuginfo/client.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/debuginfod.go b/pkg/debuginfo/debuginfod.go index c383e1011f3..84a8fe5ee67 100644 --- a/pkg/debuginfo/debuginfod.go +++ b/pkg/debuginfo/debuginfod.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/debuginfod_test.go b/pkg/debuginfo/debuginfod_test.go index 858181a8a6c..14faa30af06 100644 --- a/pkg/debuginfo/debuginfod_test.go +++ b/pkg/debuginfo/debuginfod_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/fetcher.go b/pkg/debuginfo/fetcher.go index ed710fee269..fd0c0cbc37f 100644 --- a/pkg/debuginfo/fetcher.go +++ b/pkg/debuginfo/fetcher.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/forwarder.go b/pkg/debuginfo/forwarder.go index 731e1923c3c..fb73602cc4e 100644 --- a/pkg/debuginfo/forwarder.go +++ b/pkg/debuginfo/forwarder.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/metadata.go b/pkg/debuginfo/metadata.go index fe56c7faa12..7dfbc7a3ebc 100644 --- a/pkg/debuginfo/metadata.go +++ b/pkg/debuginfo/metadata.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/metadata_test.go b/pkg/debuginfo/metadata_test.go index b1fa53400b9..dc52f8ef944 100644 --- a/pkg/debuginfo/metadata_test.go +++ b/pkg/debuginfo/metadata_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/reader.go b/pkg/debuginfo/reader.go index 88be2e04c41..1fe73132604 100644 --- a/pkg/debuginfo/reader.go +++ b/pkg/debuginfo/reader.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/store.go b/pkg/debuginfo/store.go index 0cb99095eec..109232ecd37 100644 --- a/pkg/debuginfo/store.go +++ b/pkg/debuginfo/store.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/debuginfo/store_test.go b/pkg/debuginfo/store_test.go index 0e936135ea5..27495dd322f 100644 --- a/pkg/debuginfo/store_test.go +++ b/pkg/debuginfo/store_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/demangle/demangle.go b/pkg/demangle/demangle.go index bdafec153f2..ac62f56033f 100644 --- a/pkg/demangle/demangle.go +++ b/pkg/demangle/demangle.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/demangle/rust_test.go b/pkg/demangle/rust_test.go index 6e8d1f03964..c8e8c06cae5 100644 --- a/pkg/demangle/rust_test.go +++ b/pkg/demangle/rust_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/hash/hash.go b/pkg/hash/hash.go index 045a9130e77..f40f4de204c 100644 --- a/pkg/hash/hash.go +++ b/pkg/hash/hash.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/ingester/ingester.go b/pkg/ingester/ingester.go index 829b7aed12c..7a57ada9732 100644 --- a/pkg/ingester/ingester.go +++ b/pkg/ingester/ingester.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/ingester/ingester_test.go b/pkg/ingester/ingester_test.go index 785f8d84644..01c2bc9ebc9 100644 --- a/pkg/ingester/ingester_test.go +++ b/pkg/ingester/ingester_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/kv/keymaker.go b/pkg/kv/keymaker.go index d9663a54a61..8fc7f37035a 100644 --- a/pkg/kv/keymaker.go +++ b/pkg/kv/keymaker.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/normalizer/arrow.go b/pkg/normalizer/arrow.go index 10d15e1bbab..4f90b18402f 100644 --- a/pkg/normalizer/arrow.go +++ b/pkg/normalizer/arrow.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/normalizer/normalizer.go b/pkg/normalizer/normalizer.go index 031e0e6a876..70b17c0dd70 100644 --- a/pkg/normalizer/normalizer.go +++ b/pkg/normalizer/normalizer.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/normalizer/normalizer_test.go b/pkg/normalizer/normalizer_test.go index 1757abd48d9..3954f7d067f 100644 --- a/pkg/normalizer/normalizer_test.go +++ b/pkg/normalizer/normalizer_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/normalizer/otel.go b/pkg/normalizer/otel.go index 7578da582fd..dad12fb318f 100644 --- a/pkg/normalizer/otel.go +++ b/pkg/normalizer/otel.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/normalizer/validate.go b/pkg/normalizer/validate.go index 1383eb88c49..73755e8782b 100644 --- a/pkg/normalizer/validate.go +++ b/pkg/normalizer/validate.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parca/logger.go b/pkg/parca/logger.go index 0de5f5ec0f2..8f52f3b5958 100644 --- a/pkg/parca/logger.go +++ b/pkg/parca/logger.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parca/parca.go b/pkg/parca/parca.go index 15bb3dc7dae..41e436f8d95 100644 --- a/pkg/parca/parca.go +++ b/pkg/parca/parca.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parca/parca_test.go b/pkg/parca/parca_test.go index 4cdcf5b81ba..51f734f944e 100644 --- a/pkg/parca/parca_test.go +++ b/pkg/parca/parca_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parca/testdata/pgotest.go b/pkg/parca/testdata/pgotest.go index 30e24d51f94..6f4759c18ad 100644 --- a/pkg/parca/testdata/pgotest.go +++ b/pkg/parca/testdata/pgotest.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parcacol/arrow.go b/pkg/parcacol/arrow.go index c0b47c2bffb..8ecc62d65ce 100644 --- a/pkg/parcacol/arrow.go +++ b/pkg/parcacol/arrow.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parcacol/arrow_test.go b/pkg/parcacol/arrow_test.go index 4ca282c1f3b..e0aacd416bf 100644 --- a/pkg/parcacol/arrow_test.go +++ b/pkg/parcacol/arrow_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/parcacol/querier.go b/pkg/parcacol/querier.go index 3b80928ac58..a61608373eb 100644 --- a/pkg/parcacol/querier.go +++ b/pkg/parcacol/querier.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/decode.go b/pkg/profile/decode.go index 286368823f1..70e00b2e80a 100644 --- a/pkg/profile/decode.go +++ b/pkg/profile/decode.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/encode.go b/pkg/profile/encode.go index bc7add87ddc..7314e37c90a 100644 --- a/pkg/profile/encode.go +++ b/pkg/profile/encode.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/encode_test.go b/pkg/profile/encode_test.go index 23889db05f6..2cfddb2d5d6 100644 --- a/pkg/profile/encode_test.go +++ b/pkg/profile/encode_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/executableinfo.go b/pkg/profile/executableinfo.go index 1b9a503a72c..d689586b5f8 100644 --- a/pkg/profile/executableinfo.go +++ b/pkg/profile/executableinfo.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go index ce3cc7ba547..c8b9407d5d4 100644 --- a/pkg/profile/profile.go +++ b/pkg/profile/profile.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/reader.go b/pkg/profile/reader.go index 5a881650f33..f986b37fd9b 100644 --- a/pkg/profile/reader.go +++ b/pkg/profile/reader.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/schema.go b/pkg/profile/schema.go index 445ecf67a6c..401017bdcd1 100644 --- a/pkg/profile/schema.go +++ b/pkg/profile/schema.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/uvarint.go b/pkg/profile/uvarint.go index cfa77093b18..218b81b72ec 100644 --- a/pkg/profile/uvarint.go +++ b/pkg/profile/uvarint.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profile/writer.go b/pkg/profile/writer.go index b9eb0f113d1..41e3aca1db1 100644 --- a/pkg/profile/writer.go +++ b/pkg/profile/writer.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profilestore/grpc.go b/pkg/profilestore/grpc.go index f303ee0c5dc..0b4dab84544 100644 --- a/pkg/profilestore/grpc.go +++ b/pkg/profilestore/grpc.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profilestore/profilecolumnstore.go b/pkg/profilestore/profilecolumnstore.go index 128cc5a5744..edc9019127d 100644 --- a/pkg/profilestore/profilecolumnstore.go +++ b/pkg/profilestore/profilecolumnstore.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/profilestore/profilestore_test.go b/pkg/profilestore/profilestore_test.go index 5e25ecb606a..731e9a5ecd9 100644 --- a/pkg/profilestore/profilestore_test.go +++ b/pkg/profilestore/profilestore_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/callgraph.go b/pkg/query/callgraph.go index 3570637e318..9ac15570a20 100644 --- a/pkg/query/callgraph.go +++ b/pkg/query/callgraph.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/callgraph_test.go b/pkg/query/callgraph_test.go index 6a67695567d..51c2e9c5589 100644 --- a/pkg/query/callgraph_test.go +++ b/pkg/query/callgraph_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/columnquery.go b/pkg/query/columnquery.go index b6ec1bd7aad..62715c5ba7f 100644 --- a/pkg/query/columnquery.go +++ b/pkg/query/columnquery.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/columnquery_test.go b/pkg/query/columnquery_test.go index 7d3fd02e276..c0cafee484d 100644 --- a/pkg/query/columnquery_test.go +++ b/pkg/query/columnquery_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph.go b/pkg/query/flamegraph.go index 04b4371d137..5311d3fd524 100644 --- a/pkg/query/flamegraph.go +++ b/pkg/query/flamegraph.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_arrow.go b/pkg/query/flamegraph_arrow.go index 154d22d581f..902ffd6a955 100644 --- a/pkg/query/flamegraph_arrow.go +++ b/pkg/query/flamegraph_arrow.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_arrow_test.go b/pkg/query/flamegraph_arrow_test.go index 36c29c6877e..e2d07247302 100644 --- a/pkg/query/flamegraph_arrow_test.go +++ b/pkg/query/flamegraph_arrow_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_flat.go b/pkg/query/flamegraph_flat.go index b6633e24be4..7126b022703 100644 --- a/pkg/query/flamegraph_flat.go +++ b/pkg/query/flamegraph_flat.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_flat_test.go b/pkg/query/flamegraph_flat_test.go index 6979540f81a..b32742afb01 100644 --- a/pkg/query/flamegraph_flat_test.go +++ b/pkg/query/flamegraph_flat_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_table.go b/pkg/query/flamegraph_table.go index f8baa891d2b..2c687742c7e 100644 --- a/pkg/query/flamegraph_table.go +++ b/pkg/query/flamegraph_table.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/flamegraph_table_test.go b/pkg/query/flamegraph_table_test.go index 3c282023940..6b00c1d0752 100644 --- a/pkg/query/flamegraph_table_test.go +++ b/pkg/query/flamegraph_table_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/multiple_filters_test.go b/pkg/query/multiple_filters_test.go index a843f00ae01..9fef362a659 100644 --- a/pkg/query/multiple_filters_test.go +++ b/pkg/query/multiple_filters_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/pprof.go b/pkg/query/pprof.go index a847981f1f5..d1729e3e06c 100644 --- a/pkg/query/pprof.go +++ b/pkg/query/pprof.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/pprof_test.go b/pkg/query/pprof_test.go index 97237d2b7ea..fb353e11267 100644 --- a/pkg/query/pprof_test.go +++ b/pkg/query/pprof_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/query_test.go b/pkg/query/query_test.go index b01e0816819..361ea0c4db8 100644 --- a/pkg/query/query_test.go +++ b/pkg/query/query_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/sources.go b/pkg/query/sources.go index 89d529797c0..e0392f9aa25 100644 --- a/pkg/query/sources.go +++ b/pkg/query/sources.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/sources_reader.go b/pkg/query/sources_reader.go index 11b66ca6ec7..380e22f5ff6 100644 --- a/pkg/query/sources_reader.go +++ b/pkg/query/sources_reader.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/sources_reader_test.go b/pkg/query/sources_reader_test.go index 4b2c1224643..dbe1b6f479a 100644 --- a/pkg/query/sources_reader_test.go +++ b/pkg/query/sources_reader_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/sources_test.go b/pkg/query/sources_test.go index 1a197b0b15a..c2c87b24c72 100644 --- a/pkg/query/sources_test.go +++ b/pkg/query/sources_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/string_match_bench_test.go b/pkg/query/string_match_bench_test.go index dfa9771d5e7..68625866818 100644 --- a/pkg/query/string_match_bench_test.go +++ b/pkg/query/string_match_bench_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/table.go b/pkg/query/table.go index 9a9fea2696a..a7a70e4bbd5 100644 --- a/pkg/query/table.go +++ b/pkg/query/table.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/table_test.go b/pkg/query/table_test.go index b60a27ed60f..ac6dd52cf45 100644 --- a/pkg/query/table_test.go +++ b/pkg/query/table_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/top.go b/pkg/query/top.go index 3e28b563e03..f8c829f1abe 100644 --- a/pkg/query/top.go +++ b/pkg/query/top.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/query/top_test.go b/pkg/query/top_test.go index d34f9c02fbb..2c334fa5168 100644 --- a/pkg/query/top_test.go +++ b/pkg/query/top_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/runutil/runutil.go b/pkg/runutil/runutil.go index 6e4209362c8..bc4411585d5 100644 --- a/pkg/runutil/runutil.go +++ b/pkg/runutil/runutil.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/manager.go b/pkg/scrape/manager.go index 890cfce4e0c..49f70954091 100644 --- a/pkg/scrape/manager.go +++ b/pkg/scrape/manager.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/scrape.go b/pkg/scrape/scrape.go index 96110e2dfdf..08a4d10f019 100644 --- a/pkg/scrape/scrape.go +++ b/pkg/scrape/scrape.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/scrape_test.go b/pkg/scrape/scrape_test.go index 35ae1781433..1631b897a96 100644 --- a/pkg/scrape/scrape_test.go +++ b/pkg/scrape/scrape_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/service.go b/pkg/scrape/service.go index 7004e439a4a..05e135ee611 100644 --- a/pkg/scrape/service.go +++ b/pkg/scrape/service.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/target.go b/pkg/scrape/target.go index 942e90dace6..8a5919f39d6 100644 --- a/pkg/scrape/target.go +++ b/pkg/scrape/target.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/scrape/target_test.go b/pkg/scrape/target_test.go index 551e3df30e8..4b87c617355 100644 --- a/pkg/scrape/target_test.go +++ b/pkg/scrape/target_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/server/fallback.go b/pkg/server/fallback.go index 85a476765c9..654335370bb 100644 --- a/pkg/server/fallback.go +++ b/pkg/server/fallback.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/server/grpc_codec.go b/pkg/server/grpc_codec.go index f0808b39036..5bac48bf5b8 100644 --- a/pkg/server/grpc_codec.go +++ b/pkg/server/grpc_codec.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/server/server.go b/pkg/server/server.go index 951647e6fb2..09caba9a50e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/signedrequests/client.go b/pkg/signedrequests/client.go index 085f2c64775..18eea5b091d 100644 --- a/pkg/signedrequests/client.go +++ b/pkg/signedrequests/client.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/signedrequests/gcs.go b/pkg/signedrequests/gcs.go index 20a9e80fb6d..d0c0ba6fb83 100644 --- a/pkg/signedrequests/gcs.go +++ b/pkg/signedrequests/gcs.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/signedrequests/s3.go b/pkg/signedrequests/s3.go index 08fca9916df..cc6d0cf238e 100644 --- a/pkg/signedrequests/s3.go +++ b/pkg/signedrequests/s3.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/doc.go b/pkg/symbol/addr2line/doc.go index 1e94389712a..2255cadbdca 100644 --- a/pkg/symbol/addr2line/doc.go +++ b/pkg/symbol/addr2line/doc.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/dwarf.go b/pkg/symbol/addr2line/dwarf.go index 8e839c2041e..d9e655a26c8 100644 --- a/pkg/symbol/addr2line/dwarf.go +++ b/pkg/symbol/addr2line/dwarf.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/dwarf_test.go b/pkg/symbol/addr2line/dwarf_test.go index 89f966bb876..cc5ebfb7cc2 100644 --- a/pkg/symbol/addr2line/dwarf_test.go +++ b/pkg/symbol/addr2line/dwarf_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/go.go b/pkg/symbol/addr2line/go.go index a10708fca30..3eaff58eef2 100644 --- a/pkg/symbol/addr2line/go.go +++ b/pkg/symbol/addr2line/go.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/symtab.go b/pkg/symbol/addr2line/symtab.go index f6c692f8761..637c8b2ec1f 100644 --- a/pkg/symbol/addr2line/symtab.go +++ b/pkg/symbol/addr2line/symtab.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/addr2line/symtab_test.go b/pkg/symbol/addr2line/symtab_test.go index bb403bbaa41..574533a44d7 100644 --- a/pkg/symbol/addr2line/symtab_test.go +++ b/pkg/symbol/addr2line/symtab_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/demangle/demangle.go b/pkg/symbol/demangle/demangle.go index 024ff2b2411..316b51ebd6d 100644 --- a/pkg/symbol/demangle/demangle.go +++ b/pkg/symbol/demangle/demangle.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/demangle/demangle_test.go b/pkg/symbol/demangle/demangle_test.go index 111e9f45071..29f05ccceba 100644 --- a/pkg/symbol/demangle/demangle_test.go +++ b/pkg/symbol/demangle/demangle_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/elfutils/debuginfofile.go b/pkg/symbol/elfutils/debuginfofile.go index e95960f965f..6591e7ae547 100644 --- a/pkg/symbol/elfutils/debuginfofile.go +++ b/pkg/symbol/elfutils/debuginfofile.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/elfutils/elfutils.go b/pkg/symbol/elfutils/elfutils.go index d53e6e624b9..de4dc2d99d9 100644 --- a/pkg/symbol/elfutils/elfutils.go +++ b/pkg/symbol/elfutils/elfutils.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/elfutils/elfutils_test.go b/pkg/symbol/elfutils/elfutils_test.go index 52d54ece721..bf1dc2509d6 100644 --- a/pkg/symbol/elfutils/elfutils_test.go +++ b/pkg/symbol/elfutils/elfutils_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/elfutils/testdata/main.go b/pkg/symbol/elfutils/testdata/main.go index 14848c6a9e0..155b59076f3 100644 --- a/pkg/symbol/elfutils/testdata/main.go +++ b/pkg/symbol/elfutils/testdata/main.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbol/symbolsearcher/symbol_searcher.go b/pkg/symbol/symbolsearcher/symbol_searcher.go index d7e32c39f73..2531e0feb4a 100644 --- a/pkg/symbol/symbolsearcher/symbol_searcher.go +++ b/pkg/symbol/symbolsearcher/symbol_searcher.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/cache.go b/pkg/symbolizer/cache.go index 2a9caac10c6..f9a0527d12c 100644 --- a/pkg/symbolizer/cache.go +++ b/pkg/symbolizer/cache.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/decode.go b/pkg/symbolizer/decode.go index 8ca6575c90e..879d6012377 100644 --- a/pkg/symbolizer/decode.go +++ b/pkg/symbolizer/decode.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/encode.go b/pkg/symbolizer/encode.go index adf3caea09f..571a8399c9e 100644 --- a/pkg/symbolizer/encode.go +++ b/pkg/symbolizer/encode.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/encode_test.go b/pkg/symbolizer/encode_test.go index a386f8a6b5e..c3abeab0dc0 100644 --- a/pkg/symbolizer/encode_test.go +++ b/pkg/symbolizer/encode_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/normalize.go b/pkg/symbolizer/normalize.go index f272f63a694..3fa081f1d4e 100644 --- a/pkg/symbolizer/normalize.go +++ b/pkg/symbolizer/normalize.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Parca Authors +// Copyright 2024-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/symbolizer.go b/pkg/symbolizer/symbolizer.go index 5fa55b47b08..72a2ae69321 100644 --- a/pkg/symbolizer/symbolizer.go +++ b/pkg/symbolizer/symbolizer.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/symbolizer/symbolizer_test.go b/pkg/symbolizer/symbolizer_test.go index c4f7104f682..a2559d671fa 100644 --- a/pkg/symbolizer/symbolizer_test.go +++ b/pkg/symbolizer/symbolizer_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 6119373ad4f..38092ddaf11 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/pkg/tracer/tracer.go b/pkg/tracer/tracer.go index 8388cc4d389..b28befb243f 100644 --- a/pkg/tracer/tracer.go +++ b/pkg/tracer/tracer.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/proto/buf.lock b/proto/buf.lock index c938ccdf750..9ae79671931 100644 --- a/proto/buf.lock +++ b/proto/buf.lock @@ -9,5 +9,5 @@ deps: - remote: buf.build owner: grpc-ecosystem repository: grpc-gateway - commit: 4c5ba75caaf84e928b7137ae5c18c26a - digest: shake256:e174ad9408f3e608f6157907153ffec8d310783ee354f821f57178ffbeeb8faa6bb70b41b61099c1783c82fe16210ebd1279bc9c9ee6da5cffba9f0e675b8b99 + commit: 6467306b4f624747aaf6266762ee7a1c + digest: shake256:833d648b99b9d2c18b6882ef41aaeb113e76fc38de20dda810c588d133846e6593b4da71b388bcd921b1c7ab41c7acf8f106663d7301ae9e82ceab22cf64b1b7 diff --git a/scripts/check-license.sh b/scripts/check-license.sh index a7c4ef0e69b..14789a0fb3d 100755 --- a/scripts/check-license.sh +++ b/scripts/check-license.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright 2022-2025 The Parca Authors +# Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/scripts/free_disk_space.sh b/scripts/free_disk_space.sh index 963635b5fe3..dadbbaedeaa 100755 --- a/scripts/free_disk_space.sh +++ b/scripts/free_disk_space.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Copyright 2024-2025 The Parca Authors +# Copyright 2024-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/scripts/install-minikube.sh b/scripts/install-minikube.sh index dc2b6664b9f..b55a90d3f6b 100755 --- a/scripts/install-minikube.sh +++ b/scripts/install-minikube.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright 2022-2025 The Parca Authors +# Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/scripts/local-dev.sh b/scripts/local-dev.sh index ce80d1349bd..39d752ab6d2 100644 --- a/scripts/local-dev.sh +++ b/scripts/local-dev.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2022-2025 The Parca Authors +# Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/snap/hooks/configure b/snap/hooks/configure index 678ba872a9a..9eb239e98e8 100755 --- a/snap/hooks/configure +++ b/snap/hooks/configure @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2022-2025 The Parca Authors +# Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/snap/parca-wrapper b/snap/parca-wrapper index 31dcb706461..250097772c6 100755 --- a/snap/parca-wrapper +++ b/snap/parca-wrapper @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2022-2025 The Parca Authors +# Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at diff --git a/ui/packages/app/web/build/keep.go b/ui/packages/app/web/build/keep.go index 4917a45cd2b..b00a4b0bdbc 100644 --- a/ui/packages/app/web/build/keep.go +++ b/ui/packages/app/web/build/keep.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 The Parca Authors +// Copyright 2023-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/ui/packages/app/web/public/keep.go b/ui/packages/app/web/public/keep.go index 4917a45cd2b..e7156f1bdc1 100644 --- a/ui/packages/app/web/public/keep.go +++ b/ui/packages/app/web/public/keep.go @@ -1,16 +1,19 @@ -// Copyright 2023-2025 The Parca Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +#!/bin/bash -package build +# Copyright 2022-2026 The Parca Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. -// this file is empty and only exists so that the embed.FS variable is populated. +pnpm run build +mkdir PATH_PREFIX_VAR +cp -r ./build/* ./PATH_PREFIX_VAR/ +mv ./PATH_PREFIX_VAR ./build/ diff --git a/ui/packages/app/web/scripts/build-preview.sh b/ui/packages/app/web/scripts/build-preview.sh index 20bf5b8926a..9c0d426462d 100755 --- a/ui/packages/app/web/scripts/build-preview.sh +++ b/ui/packages/app/web/scripts/build-preview.sh @@ -1,19 +1,26 @@ -#!/bin/bash +// Copyright 2022-2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -# Copyright 2022-2025 The Parca Authors -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +package ui -pnpm run build -mkdir PATH_PREFIX_VAR -cp -r ./build/* ./PATH_PREFIX_VAR/ -mv ./PATH_PREFIX_VAR ./build/ +import "embed" + +//nolint:typecheck +//go:embed packages/app/web/build +var FS embed.FS + +// NOTICE: Static HTML export of a Next.js app contains several files prefixed with _, +// directives for all these patterns need to explicitly added. +// > If a pattern names a directory, all files in the subtree rooted at that directory are embedded (recursively), +// > except that files with names beginning with ‘.’ or ‘_’ are excluded. +// source: https://pkg.go.dev/embed From 9e64bd47e173314a5eb00ba7c5ba58fdf870b246 Mon Sep 17 00:00:00 2001 From: Yomi Eluwande Date: Wed, 7 Jan 2026 18:56:43 +0100 Subject: [PATCH 11/12] Fix lint errors and restore corrupted build script - Add return types to helper functions in useQueryState.test.tsx - Remove unnecessary undefined initialization - Restore build-preview.sh that was corrupted with Go code --- ui/packages/app/web/scripts/build-preview.sh | 41 ++++++++----------- .../profile/src/hooks/useQueryState.test.tsx | 6 +-- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/ui/packages/app/web/scripts/build-preview.sh b/ui/packages/app/web/scripts/build-preview.sh index 9c0d426462d..e7156f1bdc1 100755 --- a/ui/packages/app/web/scripts/build-preview.sh +++ b/ui/packages/app/web/scripts/build-preview.sh @@ -1,26 +1,19 @@ -// Copyright 2022-2026 The Parca Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +#!/bin/bash -package ui +# Copyright 2022-2026 The Parca Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. -import "embed" - -//nolint:typecheck -//go:embed packages/app/web/build -var FS embed.FS - -// NOTICE: Static HTML export of a Next.js app contains several files prefixed with _, -// directives for all these patterns need to explicitly added. -// > If a pattern names a directory, all files in the subtree rooted at that directory are embedded (recursively), -// > except that files with names beginning with ‘.’ or ‘_’ are excluded. -// source: https://pkg.go.dev/embed +pnpm run build +mkdir PATH_PREFIX_VAR +cp -r ./build/* ./PATH_PREFIX_VAR/ +mv ./PATH_PREFIX_VAR ./build/ diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx index 663f62adf47..92319b56ecd 100644 --- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx +++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx @@ -113,7 +113,7 @@ let mockProfileTypesData: delta: boolean; }>; } - | undefined = undefined; + | undefined; // Mock useProfileTypes to control loading state in tests vi.mock('../ProfileSelector', async () => { @@ -129,11 +129,11 @@ vi.mock('../ProfileSelector', async () => { }); // Helper to set profile types loading state for tests -const setProfileTypesLoading = (loading: boolean) => { +const setProfileTypesLoading = (loading: boolean): void => { mockProfileTypesLoading = loading; }; -const setProfileTypesData = (data: typeof mockProfileTypesData) => { +const setProfileTypesData = (data: typeof mockProfileTypesData): void => { mockProfileTypesData = data; }; From 85338158bd19b5350fbe642b9ca99190f972f89f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:01:10 +0000 Subject: [PATCH 12/12] [pre-commit.ci lite] apply automatic fixes --- ui/packages/app/web/public/keep.go | 13 +++++++++++++ ui/ui.go | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ui/packages/app/web/public/keep.go b/ui/packages/app/web/public/keep.go index e7156f1bdc1..03db4372b36 100644 --- a/ui/packages/app/web/public/keep.go +++ b/ui/packages/app/web/public/keep.go @@ -1,5 +1,18 @@ #!/bin/bash +// Copyright 2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + # Copyright 2022-2026 The Parca Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/ui/ui.go b/ui/ui.go index 33702c0b7a2..9c0d426462d 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 The Parca Authors +// Copyright 2022-2026 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at