@parca/profile 0.19.142 → 0.19.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.d.ts.map +1 -1
- package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.js +22 -28
- package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
- package/dist/ProfileExplorer/ProfileExplorerCompare.js +72 -73
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.js +20 -24
- package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.js +13 -14
- package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.js +6 -5
- package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/index.js +8 -7
- package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
- package/dist/ProfileMetricsGraph/index.js +6 -8
- package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
- package/dist/ProfileSelector/MetricsGraphSection.js +48 -55
- package/dist/ProfileSelector/index.d.ts +1 -1
- package/dist/ProfileSelector/index.d.ts.map +1 -1
- package/dist/ProfileSelector/index.js +216 -210
- package/dist/ProfileSelector/useAutoQuerySelector.d.ts +1 -3
- package/dist/ProfileSelector/useAutoQuerySelector.d.ts.map +1 -1
- package/dist/ProfileSelector/useAutoQuerySelector.js +133 -104
- package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +24 -25
- package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
- package/dist/ProfileView/components/ColorStackLegend.js +3 -5
- package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
- package/dist/ProfileView/components/InvertCallStack/index.js +47 -47
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts +1 -2
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts.map +1 -1
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.js +37 -34
- package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +282 -294
- package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +7 -8
- package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
- package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/index.js +1 -1
- package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
- package/dist/ProfileView/components/ViewSelector/index.js +53 -75
- package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
- package/dist/ProfileView/context/DashboardContext.js +36 -44
- package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetFlameGraphState.js +8 -7
- package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +59 -59
- package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +37 -22
- package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
- package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useVisualizationState.js +116 -147
- package/dist/ProfileViewWithData.d.ts.map +1 -1
- package/dist/ProfileViewWithData.js +35 -45
- package/dist/Sandwich/index.d.ts.map +1 -1
- package/dist/Sandwich/index.js +6 -5
- package/dist/SourceView/index.d.ts.map +1 -1
- package/dist/SourceView/index.js +6 -4
- package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
- package/dist/SourceView/useSelectedLineRange.js +52 -76
- package/dist/Table/MoreDropdown.d.ts.map +1 -1
- package/dist/Table/MoreDropdown.js +42 -53
- package/dist/Table/TableContextMenu.d.ts.map +1 -1
- package/dist/Table/TableContextMenu.js +15 -19
- package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
- package/dist/Table/hooks/useTableConfiguration.js +107 -115
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +16 -16
- package/dist/TopTable/index.d.ts.map +1 -1
- package/dist/TopTable/index.js +112 -127
- package/dist/hooks/urlParsers.d.ts +18 -0
- package/dist/hooks/urlParsers.d.ts.map +1 -0
- package/dist/hooks/urlParsers.js +44 -0
- package/dist/hooks/useColorBy.d.ts +5 -0
- package/dist/hooks/useColorBy.d.ts.map +1 -0
- package/dist/hooks/useColorBy.js +63 -0
- package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
- package/dist/hooks/useCompareModeMeta.js +94 -138
- package/dist/hooks/useDashboardItems.d.ts +5 -0
- package/dist/hooks/useDashboardItems.d.ts.map +1 -0
- package/dist/hooks/useDashboardItems.js +68 -0
- package/dist/hooks/useQueryState.d.ts +4 -4
- package/dist/hooks/useQueryState.d.ts.map +1 -1
- package/dist/hooks/useQueryState.js +127 -122
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -12
- package/dist/useSumBy.d.ts +1 -1
- package/dist/useSumBy.d.ts.map +1 -1
- package/dist/useSumBy.js +2 -2
- package/package.json +4 -3
- package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +11 -13
- package/src/ProfileExplorer/ProfileExplorerCompare.tsx +11 -9
- package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
- package/src/ProfileFlameChart/index.tsx +21 -28
- package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +10 -9
- package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +5 -3
- package/src/ProfileFlameGraph/index.tsx +6 -9
- package/src/ProfileMetricsGraph/index.tsx +6 -8
- package/src/ProfileSelector/MetricsGraphSection.tsx +5 -10
- package/src/ProfileSelector/index.tsx +33 -33
- package/src/ProfileSelector/useAutoQuerySelector.ts +64 -42
- package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +10 -6
- package/src/ProfileView/components/ColorStackLegend.tsx +2 -4
- package/src/ProfileView/components/InvertCallStack/index.tsx +5 -4
- package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +94 -192
- package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
- package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +24 -25
- package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +4 -5
- package/src/ProfileView/components/Toolbars/index.tsx +3 -3
- package/src/ProfileView/components/ViewSelector/index.tsx +9 -16
- package/src/ProfileView/context/DashboardContext.tsx +6 -6
- package/src/ProfileView/hooks/useResetFlameGraphState.ts +6 -4
- package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +24 -26
- package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +16 -8
- package/src/ProfileView/hooks/useVisualizationState.ts +61 -69
- package/src/ProfileViewWithData.tsx +29 -35
- package/src/Sandwich/index.tsx +4 -3
- package/src/SourceView/index.tsx +4 -2
- package/src/SourceView/useSelectedLineRange.ts +34 -19
- package/src/Table/MoreDropdown.tsx +9 -11
- package/src/Table/TableContextMenu.tsx +10 -13
- package/src/Table/hooks/useTableConfiguration.tsx +3 -4
- package/src/Table/index.tsx +12 -21
- package/src/TopTable/index.tsx +3 -4
- package/src/hooks/urlParsers.ts +38 -0
- package/src/hooks/useColorBy.ts +42 -0
- package/src/hooks/useCompareModeMeta.ts +61 -91
- package/src/hooks/useDashboardItems.ts +46 -0
- package/src/hooks/useQueryState.test.tsx +275 -345
- package/src/hooks/useQueryState.ts +153 -120
- package/src/index.tsx +16 -15
- package/src/useSumBy.ts +3 -3
|
@@ -12,19 +12,20 @@
|
|
|
12
12
|
// limitations under the License.
|
|
13
13
|
|
|
14
14
|
import {Icon} from '@iconify/react';
|
|
15
|
+
import {useQueryState} from 'nuqs';
|
|
15
16
|
|
|
16
|
-
import {Button
|
|
17
|
+
import {Button} from '@parca/components';
|
|
17
18
|
import {TEST_IDS, testId} from '@parca/test-utils';
|
|
18
19
|
|
|
20
|
+
import {invertCallStackParser} from '../../../hooks/urlParsers';
|
|
19
21
|
import {useResetFlameGraphState} from '../../hooks/useResetFlameGraphState';
|
|
20
22
|
|
|
21
23
|
const InvertCallStack = (): JSX.Element => {
|
|
22
|
-
const [
|
|
23
|
-
const isInvert = invertStack === 'true';
|
|
24
|
+
const [isInvert, setInvertStack] = useQueryState('invert_call_stack', invertCallStackParser);
|
|
24
25
|
const resetFlameGraphState = useResetFlameGraphState();
|
|
25
26
|
|
|
26
27
|
const handleSetInvert = (value: boolean): void => {
|
|
27
|
-
setInvertStack(value
|
|
28
|
+
void setInvertStack(value);
|
|
28
29
|
|
|
29
30
|
resetFlameGraphState();
|
|
30
31
|
};
|
|
@@ -15,78 +15,28 @@ import {type ReactNode} from 'react';
|
|
|
15
15
|
|
|
16
16
|
// eslint-disable-next-line import/named
|
|
17
17
|
import {act, renderHook, waitFor} from '@testing-library/react';
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
18
|
+
// eslint-disable-next-line import/no-unresolved
|
|
19
|
+
import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
|
|
20
|
+
import {describe, expect, it, vi} from 'vitest';
|
|
21
21
|
|
|
22
22
|
import {type ProfileFilter} from './useProfileFilters';
|
|
23
23
|
import {decodeProfileFilters, useProfileFiltersUrlState} from './useProfileFiltersUrlState';
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Mock the navigate function
|
|
32
|
-
const mockNavigateTo = vi.fn((path: string, params: Record<string, string | string[]>) => {
|
|
33
|
-
const searchParams = new URLSearchParams();
|
|
34
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
35
|
-
if (value !== undefined && value !== null) {
|
|
36
|
-
if (Array.isArray(value)) {
|
|
37
|
-
searchParams.set(key, value.join(','));
|
|
38
|
-
} else {
|
|
39
|
-
searchParams.set(key, String(value));
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
mockLocation.search = `?${searchParams.toString()}`;
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// Mock getQueryParamsFromURL
|
|
47
|
-
vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
|
|
48
|
-
const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
|
|
49
|
-
return {
|
|
50
|
-
...actual,
|
|
51
|
-
getQueryParamsFromURL: () => {
|
|
52
|
-
if (mockLocation.search === '') return {};
|
|
53
|
-
const params = new URLSearchParams(mockLocation.search);
|
|
54
|
-
const result: Record<string, string | string[]> = {};
|
|
55
|
-
for (const [key, value] of params.entries()) {
|
|
56
|
-
const decodedValue = decodeURIComponent(value);
|
|
57
|
-
const existing = result[key];
|
|
58
|
-
if (existing !== undefined) {
|
|
59
|
-
result[key] = Array.isArray(existing)
|
|
60
|
-
? [...existing, decodedValue]
|
|
61
|
-
: [existing, decodedValue];
|
|
62
|
-
} else {
|
|
63
|
-
result[key] = decodedValue;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// Helper to create wrapper with URLStateProvider
|
|
72
|
-
const createWrapper = (): (({children}: {children: ReactNode}) => JSX.Element) => {
|
|
25
|
+
// Helper to create wrapper with NuqsTestingAdapter
|
|
26
|
+
const createWrapper = (
|
|
27
|
+
searchParams: string | Record<string, string> = {},
|
|
28
|
+
onUrlUpdate?: OnUrlUpdateFunction
|
|
29
|
+
): (({children}: {children: ReactNode}) => JSX.Element) => {
|
|
73
30
|
const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
|
|
74
|
-
<
|
|
31
|
+
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory={true}>
|
|
32
|
+
{children}
|
|
33
|
+
</NuqsTestingAdapter>
|
|
75
34
|
);
|
|
76
|
-
Wrapper.displayName = '
|
|
35
|
+
Wrapper.displayName = 'NuqsTestingWrapper';
|
|
77
36
|
return Wrapper;
|
|
78
37
|
};
|
|
79
38
|
|
|
80
39
|
describe('useProfileFiltersUrlState', () => {
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
mockNavigateTo.mockClear();
|
|
83
|
-
Object.defineProperty(window, 'location', {
|
|
84
|
-
value: mockLocation,
|
|
85
|
-
writable: true,
|
|
86
|
-
});
|
|
87
|
-
mockLocation.search = '';
|
|
88
|
-
});
|
|
89
|
-
|
|
90
40
|
describe('decodeProfileFilters', () => {
|
|
91
41
|
it('should return empty array for empty string', () => {
|
|
92
42
|
expect(decodeProfileFilters('')).toEqual([]);
|
|
@@ -255,9 +205,9 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
255
205
|
});
|
|
256
206
|
|
|
257
207
|
it('should read filters from URL', async () => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
208
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
209
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
|
|
210
|
+
});
|
|
261
211
|
|
|
262
212
|
await waitFor(() => {
|
|
263
213
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
@@ -271,7 +221,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
271
221
|
});
|
|
272
222
|
|
|
273
223
|
it('should update URL when setting filters', async () => {
|
|
274
|
-
const
|
|
224
|
+
const onUrlUpdate = vi.fn();
|
|
225
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
226
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
227
|
+
});
|
|
275
228
|
|
|
276
229
|
const newFilters: ProfileFilter[] = [
|
|
277
230
|
{
|
|
@@ -288,26 +241,26 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
288
241
|
});
|
|
289
242
|
|
|
290
243
|
await waitFor(() => {
|
|
291
|
-
expect(
|
|
292
|
-
const
|
|
293
|
-
expect(
|
|
244
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
245
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
246
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:libc.so');
|
|
294
247
|
});
|
|
295
248
|
});
|
|
296
249
|
|
|
297
250
|
it('should clear URL param when setting empty filters', async () => {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
251
|
+
const onUrlUpdate = vi.fn();
|
|
252
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
253
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}, onUrlUpdate),
|
|
254
|
+
});
|
|
301
255
|
|
|
302
256
|
act(() => {
|
|
303
257
|
result.current.setAppliedFilters([]);
|
|
304
258
|
});
|
|
305
259
|
|
|
306
260
|
await waitFor(() => {
|
|
307
|
-
expect(
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
|
|
261
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
262
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
263
|
+
expect(lastCall.searchParams.has('profile_filters')).toBe(false);
|
|
311
264
|
});
|
|
312
265
|
});
|
|
313
266
|
});
|
|
@@ -320,9 +273,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
320
273
|
});
|
|
321
274
|
|
|
322
275
|
it('should force apply filters overwriting existing', async () => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
276
|
+
const onUrlUpdate = vi.fn();
|
|
277
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
278
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
|
|
279
|
+
});
|
|
326
280
|
|
|
327
281
|
// Verify existing filter is loaded
|
|
328
282
|
await waitFor(() => {
|
|
@@ -344,33 +298,36 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
344
298
|
});
|
|
345
299
|
|
|
346
300
|
await waitFor(() => {
|
|
347
|
-
expect(
|
|
348
|
-
const
|
|
349
|
-
expect(
|
|
301
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
302
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
303
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:forcedValue');
|
|
350
304
|
});
|
|
351
305
|
});
|
|
352
306
|
|
|
353
307
|
it('should clear filters when force applying empty array', async () => {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
308
|
+
const onUrlUpdate = vi.fn();
|
|
309
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
310
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
|
|
311
|
+
});
|
|
357
312
|
|
|
358
313
|
act(() => {
|
|
359
314
|
result.current.forceApplyFilters([]);
|
|
360
315
|
});
|
|
361
316
|
|
|
362
317
|
await waitFor(() => {
|
|
363
|
-
expect(
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
|
|
318
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
319
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
320
|
+
expect(lastCall.searchParams.has('profile_filters')).toBe(false);
|
|
367
321
|
});
|
|
368
322
|
});
|
|
369
323
|
});
|
|
370
324
|
|
|
371
325
|
describe('Preset filter encoding', () => {
|
|
372
326
|
it('should encode preset filters correctly', async () => {
|
|
373
|
-
const
|
|
327
|
+
const onUrlUpdate = vi.fn();
|
|
328
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
329
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
330
|
+
});
|
|
374
331
|
|
|
375
332
|
const presetFilters: ProfileFilter[] = [
|
|
376
333
|
{
|
|
@@ -385,14 +342,17 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
385
342
|
});
|
|
386
343
|
|
|
387
344
|
await waitFor(() => {
|
|
388
|
-
expect(
|
|
389
|
-
const
|
|
390
|
-
expect(
|
|
345
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
346
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
347
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('p:hide_libc:enabled');
|
|
391
348
|
});
|
|
392
349
|
});
|
|
393
350
|
|
|
394
351
|
it('should handle mixed preset and regular filters', async () => {
|
|
395
|
-
const
|
|
352
|
+
const onUrlUpdate = vi.fn();
|
|
353
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
354
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
355
|
+
});
|
|
396
356
|
|
|
397
357
|
const mixedFilters: ProfileFilter[] = [
|
|
398
358
|
{
|
|
@@ -414,16 +374,21 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
414
374
|
});
|
|
415
375
|
|
|
416
376
|
await waitFor(() => {
|
|
417
|
-
expect(
|
|
418
|
-
const
|
|
419
|
-
expect(
|
|
377
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
378
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
379
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe(
|
|
380
|
+
'p:hide_libc:enabled,f:b:!~:node'
|
|
381
|
+
);
|
|
420
382
|
});
|
|
421
383
|
});
|
|
422
384
|
});
|
|
423
385
|
|
|
424
386
|
describe('URL encoding edge cases', () => {
|
|
425
387
|
it('should handle special characters in filter values', async () => {
|
|
426
|
-
const
|
|
388
|
+
const onUrlUpdate = vi.fn();
|
|
389
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
390
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
391
|
+
});
|
|
427
392
|
|
|
428
393
|
const filtersWithSpecialChars: ProfileFilter[] = [
|
|
429
394
|
{
|
|
@@ -440,15 +405,19 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
440
405
|
});
|
|
441
406
|
|
|
442
407
|
await waitFor(() => {
|
|
443
|
-
expect(
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
408
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
409
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
410
|
+
const filterValue = lastCall.searchParams.get('profile_filters');
|
|
411
|
+
// The value should contain the encoded special characters
|
|
412
|
+
expect(filterValue).toContain('std%3A%3Avector%3Cint%3E');
|
|
447
413
|
});
|
|
448
414
|
});
|
|
449
415
|
|
|
450
416
|
it('should filter out incomplete filters when encoding', async () => {
|
|
451
|
-
const
|
|
417
|
+
const onUrlUpdate = vi.fn();
|
|
418
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
419
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
420
|
+
});
|
|
452
421
|
|
|
453
422
|
const incompleteFilters: ProfileFilter[] = [
|
|
454
423
|
{
|
|
@@ -476,10 +445,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
476
445
|
});
|
|
477
446
|
|
|
478
447
|
await waitFor(() => {
|
|
479
|
-
expect(
|
|
480
|
-
const
|
|
448
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
449
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
481
450
|
// Only the complete filter should be encoded
|
|
482
|
-
expect(
|
|
451
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:valid');
|
|
483
452
|
});
|
|
484
453
|
});
|
|
485
454
|
});
|
|
@@ -501,9 +470,9 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
501
470
|
});
|
|
502
471
|
|
|
503
472
|
it('should return correctly structured filters from URL', async () => {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
473
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
474
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
|
|
475
|
+
});
|
|
507
476
|
|
|
508
477
|
await waitFor(() => {
|
|
509
478
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
@@ -522,15 +491,16 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
522
491
|
|
|
523
492
|
describe('View switching scenarios', () => {
|
|
524
493
|
it('should completely replace filters when switching views using forceApplyFilters', async () => {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
494
|
+
const onUrlUpdate = vi.fn();
|
|
495
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
496
|
+
wrapper: createWrapper(
|
|
497
|
+
{profile_filters: 's:fn:=:viewAFunc,f:b:!=:viewABinary'},
|
|
498
|
+
onUrlUpdate
|
|
499
|
+
),
|
|
500
|
+
});
|
|
529
501
|
|
|
530
502
|
await waitFor(() => {
|
|
531
503
|
expect(result.current.appliedFilters).toHaveLength(2);
|
|
532
|
-
expect(result.current.appliedFilters[0].value).toBe('viewAFunc');
|
|
533
|
-
expect(result.current.appliedFilters[1].value).toBe('viewABinary');
|
|
534
504
|
});
|
|
535
505
|
|
|
536
506
|
// Switch to View B (completely different filter)
|
|
@@ -549,96 +519,28 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
549
519
|
});
|
|
550
520
|
|
|
551
521
|
await waitFor(() => {
|
|
552
|
-
expect(
|
|
553
|
-
const
|
|
522
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
523
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
524
|
+
const filterValue = lastCall.searchParams.get('profile_filters');
|
|
554
525
|
|
|
555
526
|
// View A's filters should be completely gone
|
|
556
|
-
expect(
|
|
557
|
-
expect(
|
|
527
|
+
expect(filterValue).not.toContain('viewAFunc');
|
|
528
|
+
expect(filterValue).not.toContain('viewABinary');
|
|
558
529
|
|
|
559
530
|
// Only View B's filter should be present
|
|
560
|
-
expect(
|
|
561
|
-
});
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it('should handle sequential view switches correctly', async () => {
|
|
565
|
-
// Simulate: [default] -> [storage] -> [testing-view]
|
|
566
|
-
const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
|
|
567
|
-
|
|
568
|
-
// View 1: default view (1 filter)
|
|
569
|
-
const defaultFilters: ProfileFilter[] = [{id: 'd-1', type: 'hide_libc', value: 'enabled'}];
|
|
570
|
-
|
|
571
|
-
act(() => {
|
|
572
|
-
result.current.forceApplyFilters(defaultFilters);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
await waitFor(() => {
|
|
576
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
577
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
578
|
-
expect(params.profile_filters).toBe('p:hide_libc:enabled');
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
mockNavigateTo.mockClear();
|
|
582
|
-
|
|
583
|
-
// View 2: storage view (3 filters)
|
|
584
|
-
const storageFilters: ProfileFilter[] = [
|
|
585
|
-
{id: 's-1', type: 'stack', field: 'function_name', matchType: 'not_contains', value: 'io'},
|
|
586
|
-
{id: 's-2', type: 'frame', field: 'binary', matchType: 'not_contains', value: 'disk'},
|
|
587
|
-
{id: 's-3', type: 'frame', field: 'function_name', matchType: 'contains', value: 'storage'},
|
|
588
|
-
];
|
|
589
|
-
|
|
590
|
-
act(() => {
|
|
591
|
-
result.current.forceApplyFilters(storageFilters);
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
await waitFor(() => {
|
|
595
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
596
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
597
|
-
// Default view's filter should be gone
|
|
598
|
-
expect(params.profile_filters).not.toContain('hide_libc');
|
|
599
|
-
// Storage view should have 3 filters
|
|
600
|
-
expect(params.profile_filters).toContain('io');
|
|
601
|
-
expect(params.profile_filters).toContain('disk');
|
|
602
|
-
expect(params.profile_filters).toContain('storage');
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
mockNavigateTo.mockClear();
|
|
606
|
-
|
|
607
|
-
// View 3: testing-view (2 filters)
|
|
608
|
-
const testingFilters: ProfileFilter[] = [
|
|
609
|
-
{id: 't-1', type: 'stack', field: 'function_name', matchType: 'equal', value: 'test_main'},
|
|
610
|
-
{id: 't-2', type: 'frame', field: 'binary', matchType: 'contains', value: 'test'},
|
|
611
|
-
];
|
|
612
|
-
|
|
613
|
-
act(() => {
|
|
614
|
-
result.current.forceApplyFilters(testingFilters);
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
await waitFor(() => {
|
|
618
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
619
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
620
|
-
// Storage view's filters should be gone
|
|
621
|
-
expect(params.profile_filters).not.toContain('io');
|
|
622
|
-
expect(params.profile_filters).not.toContain('disk');
|
|
623
|
-
expect(params.profile_filters).not.toContain('storage');
|
|
624
|
-
// Testing view should have its 2 filters
|
|
625
|
-
expect(params.profile_filters).toContain('test_main');
|
|
626
|
-
expect(params.profile_filters).toContain('test');
|
|
531
|
+
expect(filterValue).toBe('f:fn:~:viewBOnly');
|
|
627
532
|
});
|
|
628
533
|
});
|
|
629
534
|
|
|
630
535
|
it('should not change filters when clicking the same view tab', async () => {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
|
|
536
|
+
const {result} = renderHook(() => useProfileFiltersUrlState(), {
|
|
537
|
+
wrapper: createWrapper({profile_filters: 's:fn:=:existingFilter'}),
|
|
538
|
+
});
|
|
635
539
|
|
|
636
540
|
await waitFor(() => {
|
|
637
541
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
638
542
|
});
|
|
639
543
|
|
|
640
|
-
mockNavigateTo.mockClear();
|
|
641
|
-
|
|
642
544
|
// Apply the same filters (simulating clicking the same view tab)
|
|
643
545
|
const sameFilters: ProfileFilter[] = [
|
|
644
546
|
{
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import {useCallback, useMemo} from 'react';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {createParser, useQueryState} from 'nuqs';
|
|
17
|
+
|
|
17
18
|
import {safeDecode} from '@parca/utilities';
|
|
18
19
|
|
|
19
20
|
import {isPresetKey} from './filterPresets';
|
|
@@ -137,31 +138,32 @@ export const decodeProfileFilters = (encoded: string): ProfileFilter[] => {
|
|
|
137
138
|
}
|
|
138
139
|
};
|
|
139
140
|
|
|
141
|
+
const profileFiltersParser = createParser<ProfileFilter[]>({
|
|
142
|
+
parse: (value: string) => decodeProfileFilters(value),
|
|
143
|
+
serialize: (value: ProfileFilter[]) => encodeProfileFilters(value),
|
|
144
|
+
eq: (a, b) => encodeProfileFilters(a) === encodeProfileFilters(b),
|
|
145
|
+
})
|
|
146
|
+
.withDefault([])
|
|
147
|
+
.withOptions({history: 'replace'});
|
|
148
|
+
|
|
140
149
|
export const useProfileFiltersUrlState = (): {
|
|
141
150
|
appliedFilters: ProfileFilter[];
|
|
142
|
-
setAppliedFilters:
|
|
151
|
+
setAppliedFilters: (filters: ProfileFilter[]) => void;
|
|
143
152
|
forceApplyFilters: (filters: ProfileFilter[]) => void;
|
|
144
153
|
} => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
// Store applied filters in URL state for persistence using compact encoding
|
|
148
|
-
const [appliedFilters, setAppliedFilters] = useURLStateCustom<ProfileFilter[]>(
|
|
149
|
-
`profile_filters`,
|
|
150
|
-
{
|
|
151
|
-
parse: value => {
|
|
152
|
-
return decodeProfileFilters(value as string);
|
|
153
|
-
},
|
|
154
|
-
stringify: value => {
|
|
155
|
-
return encodeProfileFilters(value);
|
|
156
|
-
},
|
|
157
|
-
defaultValue: [],
|
|
158
|
-
}
|
|
159
|
-
);
|
|
154
|
+
const [appliedFilters, setRawFilters] = useQueryState('profile_filters', profileFiltersParser);
|
|
160
155
|
|
|
161
156
|
const memoizedAppliedFilters = useMemo(() => {
|
|
162
157
|
return appliedFilters ?? [];
|
|
163
158
|
}, [appliedFilters]);
|
|
164
159
|
|
|
160
|
+
const setAppliedFilters = useCallback(
|
|
161
|
+
(filters: ProfileFilter[]) => {
|
|
162
|
+
void setRawFilters(filters);
|
|
163
|
+
},
|
|
164
|
+
[setRawFilters]
|
|
165
|
+
);
|
|
166
|
+
|
|
165
167
|
// Force apply filters (bypasses preserve-existing strategy)
|
|
166
168
|
const forceApplyFilters = useCallback(
|
|
167
169
|
(filters: ProfileFilter[]) => {
|
|
@@ -172,11 +174,9 @@ export const useProfileFiltersUrlState = (): {
|
|
|
172
174
|
return f.value !== '' && f.type != null && f.field != null && f.matchType != null;
|
|
173
175
|
});
|
|
174
176
|
|
|
175
|
-
|
|
176
|
-
setAppliedFilters(validFilters);
|
|
177
|
-
});
|
|
177
|
+
setAppliedFilters(validFilters);
|
|
178
178
|
},
|
|
179
|
-
[
|
|
179
|
+
[setAppliedFilters]
|
|
180
180
|
);
|
|
181
181
|
|
|
182
182
|
return {
|
|
@@ -18,8 +18,8 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
|
18
18
|
import {Menu} from '@headlessui/react';
|
|
19
19
|
import {Icon} from '@iconify/react';
|
|
20
20
|
import cx from 'classnames';
|
|
21
|
+
import {useQueryState} from 'nuqs';
|
|
21
22
|
|
|
22
|
-
import {useURLState} from '@parca/components';
|
|
23
23
|
import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
|
|
24
24
|
import {ProfileType} from '@parca/parser';
|
|
25
25
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
FIELD_LOCATION_ADDRESS,
|
|
30
30
|
FIELD_MAPPING_FILE,
|
|
31
31
|
} from '../../../ProfileFlameGraph/FlameGraphArrow';
|
|
32
|
+
import {boolParam, hiddenBinariesParser, stringParam} from '../../../hooks/urlParsers';
|
|
32
33
|
import {useProfileViewContext} from '../../context/ProfileViewContext';
|
|
33
34
|
import SwitchMenuItem from './SwitchMenuItem';
|
|
34
35
|
|
|
@@ -209,14 +210,15 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
209
210
|
}) => {
|
|
210
211
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
211
212
|
const [shouldOpenLeft, setShouldOpenLeft] = useState(false);
|
|
212
|
-
const [storeSortBy] =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
213
|
+
const [storeSortBy] = useQueryState('sort_by', stringParam.withDefault(FIELD_FUNCTION_NAME));
|
|
214
|
+
const [colorStackLegend, setStoreColorStackLegend] = useQueryState(
|
|
215
|
+
'color_stack_legend',
|
|
216
|
+
stringParam
|
|
217
|
+
);
|
|
218
|
+
const [hiddenBinaries, setHiddenBinaries] = useQueryState(
|
|
219
|
+
'hidden_binaries',
|
|
220
|
+
hiddenBinariesParser
|
|
221
|
+
);
|
|
220
222
|
const {compareMode} = useProfileViewContext();
|
|
221
223
|
const [colorProfileName] = useUserPreference<string>(
|
|
222
224
|
USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
|
|
@@ -226,11 +228,10 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
226
228
|
|
|
227
229
|
// By default, we want delta profiles (CPU) to be relatively compared.
|
|
228
230
|
// For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely.
|
|
229
|
-
const compareAbsoluteDefault = profileType?.delta === false
|
|
231
|
+
const compareAbsoluteDefault = profileType?.delta === false;
|
|
230
232
|
|
|
231
|
-
const [compareAbsolute
|
|
232
|
-
|
|
233
|
-
const isCompareAbsolute = compareAbsolute === 'true';
|
|
233
|
+
const [compareAbsolute, setCompareAbsolute] = useQueryState('compare_absolute', boolParam);
|
|
234
|
+
const isCompareAbsolute = compareAbsolute ?? compareAbsoluteDefault;
|
|
234
235
|
|
|
235
236
|
useEffect(() => {
|
|
236
237
|
const checkOverflow = (): void => {
|
|
@@ -251,20 +252,20 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
251
252
|
}, [isTableVizOnly]);
|
|
252
253
|
|
|
253
254
|
const handleBinaryToggle = (index: number): void => {
|
|
254
|
-
const updatedBinaries = [...
|
|
255
|
+
const updatedBinaries = [...hiddenBinaries];
|
|
255
256
|
updatedBinaries.splice(index, 1);
|
|
256
|
-
setHiddenBinaries(updatedBinaries);
|
|
257
|
+
void setHiddenBinaries(updatedBinaries);
|
|
257
258
|
};
|
|
258
259
|
|
|
259
260
|
const setColorStackLegend = useCallback(
|
|
260
261
|
(value: string): void => {
|
|
261
|
-
setStoreColorStackLegend(value);
|
|
262
|
+
void setStoreColorStackLegend(value);
|
|
262
263
|
},
|
|
263
264
|
[setStoreColorStackLegend]
|
|
264
265
|
);
|
|
265
266
|
|
|
266
267
|
const resetLegend = (): void => {
|
|
267
|
-
setHiddenBinaries([]);
|
|
268
|
+
void setHiddenBinaries([]);
|
|
268
269
|
};
|
|
269
270
|
|
|
270
271
|
const menuItems: MenuItemType[] = [
|
|
@@ -332,7 +333,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
332
333
|
},
|
|
333
334
|
{
|
|
334
335
|
label: isCompareAbsolute ? 'Compare Relative' : 'Compare Absolute',
|
|
335
|
-
onclick: () => setCompareAbsolute(isCompareAbsolute
|
|
336
|
+
onclick: () => void setCompareAbsolute(!isCompareAbsolute),
|
|
336
337
|
hide: !compareMode,
|
|
337
338
|
icon: isCompareAbsolute ? 'fluent-mdl2:compare' : 'fluent-mdl2:compare-uneven',
|
|
338
339
|
},
|
|
@@ -362,7 +363,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
362
363
|
},
|
|
363
364
|
{
|
|
364
365
|
label: 'Reset Legend',
|
|
365
|
-
hide: hiddenBinaries
|
|
366
|
+
hide: hiddenBinaries.length === 0,
|
|
366
367
|
onclick: () => resetLegend(),
|
|
367
368
|
id: 'h-reset-legend-button',
|
|
368
369
|
icon: 'system-uicons:reset',
|
|
@@ -370,7 +371,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
370
371
|
{
|
|
371
372
|
label: 'Hidden Binaries',
|
|
372
373
|
id: 'h-hidden-binaries',
|
|
373
|
-
items:
|
|
374
|
+
items: hiddenBinaries.map((binary, index) => ({
|
|
374
375
|
label: binary,
|
|
375
376
|
customSubmenu: (
|
|
376
377
|
<div className="flex items-center gap-2 w-full">
|
|
@@ -386,7 +387,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
386
387
|
</div>
|
|
387
388
|
),
|
|
388
389
|
})),
|
|
389
|
-
hide: hiddenBinaries
|
|
390
|
+
hide: hiddenBinaries.length === 0,
|
|
390
391
|
icon: 'ph:eye-closed',
|
|
391
392
|
},
|
|
392
393
|
];
|
|
@@ -427,10 +428,8 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
|
|
427
428
|
{...item}
|
|
428
429
|
onSelect={onSelect}
|
|
429
430
|
closeDropdown={close}
|
|
430
|
-
activeValueForSortBy={storeSortBy
|
|
431
|
-
activeValueForColorBy={
|
|
432
|
-
colorBy === undefined || colorBy === '' ? 'binary' : colorBy
|
|
433
|
-
}
|
|
431
|
+
activeValueForSortBy={storeSortBy}
|
|
432
|
+
activeValueForColorBy={colorBy}
|
|
434
433
|
activeValuesForLevel={groupBy}
|
|
435
434
|
renderAsDiv={item.renderAsDiv}
|
|
436
435
|
/>
|