@parca/profile 0.19.113 → 0.19.115

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
  3. package/dist/ProfileExplorer/ProfileExplorerSingle.js +9 -3
  4. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts +31 -0
  5. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts.map +1 -0
  6. package/dist/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.js +32 -60
  7. package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.d.ts → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts} +4 -3
  8. package/dist/ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts.map +1 -0
  9. package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.js → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.js} +5 -4
  10. package/dist/{MetricsGraphStrips → ProfileFlameChart/SamplesStrips}/index.d.ts +5 -4
  11. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -0
  12. package/dist/ProfileFlameChart/SamplesStrips/index.js +145 -0
  13. package/dist/ProfileFlameChart/index.d.ts +20 -0
  14. package/dist/ProfileFlameChart/index.d.ts.map +1 -0
  15. package/dist/ProfileFlameChart/index.js +155 -0
  16. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  17. package/dist/ProfileFlameGraph/index.js +0 -1
  18. package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts +2 -1
  19. package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts.map +1 -1
  20. package/dist/ProfileMetricsGraph/hooks/useQueryRange.js +11 -21
  21. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  22. package/dist/ProfileMetricsGraph/index.js +13 -3
  23. package/dist/ProfileSelector/index.d.ts.map +1 -1
  24. package/dist/ProfileSelector/index.js +4 -0
  25. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts +1 -0
  26. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts.map +1 -1
  27. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.js +2 -2
  28. package/dist/ProfileView/components/DashboardItems/index.d.ts +5 -4
  29. package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
  30. package/dist/ProfileView/components/DashboardItems/index.js +4 -3
  31. package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts +2 -1
  32. package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts.map +1 -1
  33. package/dist/ProfileView/components/GroupByLabelsDropdown/index.js +2 -2
  34. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +1 -1
  35. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -0
  36. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  37. package/dist/ProfileView/components/Toolbars/index.js +4 -2
  38. package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts +16 -0
  39. package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts.map +1 -0
  40. package/dist/ProfileView/hooks/useAutoSelectDimension.js +75 -0
  41. package/dist/ProfileView/hooks/useVisualizationState.d.ts +2 -0
  42. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  43. package/dist/ProfileView/hooks/useVisualizationState.js +8 -0
  44. package/dist/ProfileView/index.d.ts +1 -1
  45. package/dist/ProfileView/index.d.ts.map +1 -1
  46. package/dist/ProfileView/index.js +7 -4
  47. package/dist/ProfileView/types/visualization.d.ts +15 -3
  48. package/dist/ProfileView/types/visualization.d.ts.map +1 -1
  49. package/dist/ProfileViewWithData.d.ts +2 -1
  50. package/dist/ProfileViewWithData.d.ts.map +1 -1
  51. package/dist/ProfileViewWithData.js +41 -29
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -0
  55. package/dist/styles.css +1 -1
  56. package/package.json +8 -7
  57. package/src/ProfileExplorer/ProfileExplorerSingle.tsx +14 -3
  58. package/src/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.tsx +77 -81
  59. package/src/{MetricsGraphStrips/MetricsGraphStrips.stories.tsx → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.tsx} +7 -6
  60. package/src/ProfileFlameChart/SamplesStrips/index.tsx +317 -0
  61. package/src/ProfileFlameChart/index.tsx +305 -0
  62. package/src/ProfileFlameGraph/index.tsx +0 -1
  63. package/src/ProfileMetricsGraph/hooks/useQueryRange.ts +18 -26
  64. package/src/ProfileMetricsGraph/index.tsx +24 -2
  65. package/src/ProfileSelector/index.tsx +11 -0
  66. package/src/ProfileView/components/ActionButtons/GroupByDropdown.tsx +3 -0
  67. package/src/ProfileView/components/DashboardItems/index.tsx +19 -17
  68. package/src/ProfileView/components/GroupByLabelsDropdown/index.tsx +4 -2
  69. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +1 -1
  70. package/src/ProfileView/components/Toolbars/index.tsx +18 -1
  71. package/src/ProfileView/hooks/useAutoSelectDimension.ts +90 -0
  72. package/src/ProfileView/hooks/useVisualizationState.ts +17 -0
  73. package/src/ProfileView/index.tsx +16 -2
  74. package/src/ProfileView/types/visualization.ts +17 -3
  75. package/src/ProfileViewWithData.tsx +80 -37
  76. package/src/index.tsx +4 -0
  77. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts +0 -10
  78. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts.map +0 -1
  79. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.js +0 -44
  80. package/dist/MetricsGraphStrips/AreaGraph/index.d.ts +0 -21
  81. package/dist/MetricsGraphStrips/AreaGraph/index.d.ts.map +0 -1
  82. package/dist/MetricsGraphStrips/MetricsGraphStrips.stories.d.ts.map +0 -1
  83. package/dist/MetricsGraphStrips/index.d.ts.map +0 -1
  84. package/dist/MetricsGraphStrips/index.js +0 -70
  85. package/src/MetricsGraphStrips/AreaGraph/Tooltip.tsx +0 -83
  86. package/src/MetricsGraphStrips/index.tsx +0 -142
@@ -0,0 +1,305 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useEffect, useMemo, useRef} from 'react';
15
+
16
+ import {LabelSet, QueryRequest_ReportType, QueryServiceClient} from '@parca/client';
17
+ import {
18
+ Button,
19
+ useParcaContext,
20
+ useURLState,
21
+ useURLStateCustom,
22
+ type OptionsCustom,
23
+ } from '@parca/components';
24
+ import {Matcher, MatcherTypes, ProfileType, Query} from '@parca/parser';
25
+
26
+ import ProfileFlameGraph, {validateFlameChartQuery} from '../ProfileFlameGraph';
27
+ import {boundsFromProfileSource} from '../ProfileFlameGraph/FlameGraphArrow/utils';
28
+ import {MergedProfileSource, ProfileSource} from '../ProfileSource';
29
+ import type {SamplesData} from '../ProfileView/types/visualization';
30
+ import {useQuery} from '../useQuery';
31
+ import {NumberDuo} from '../utils';
32
+ import {SamplesStrip} from './SamplesStrips';
33
+
34
+ interface SelectedTimeframe {
35
+ labels: LabelSet;
36
+ bounds: NumberDuo;
37
+ }
38
+
39
+ const TimeframeStateSerializer: OptionsCustom<SelectedTimeframe | undefined> = {
40
+ parse: (value: string | string[] | undefined) => {
41
+ if (value == null || value === '' || value === 'undefined' || Array.isArray(value)) {
42
+ return undefined;
43
+ }
44
+ try {
45
+ const [labelPart, boundsPart] = value.split('|');
46
+ if (labelPart != null && boundsPart != null) {
47
+ const labels = labelPart.split(',').map(labelStr => {
48
+ const [name, ...rest] = labelStr.split(':');
49
+ return {name, value: rest.join(':')};
50
+ });
51
+ const [startMs, endMs] = boundsPart.split(',').map(Number);
52
+ if (labels.length > 0 && !isNaN(startMs) && !isNaN(endMs)) {
53
+ return {
54
+ labels: {labels},
55
+ bounds: [startMs, endMs] as NumberDuo,
56
+ };
57
+ }
58
+ }
59
+ } catch {
60
+ // Ignore parsing errors
61
+ }
62
+ return undefined;
63
+ },
64
+ stringify: (value: SelectedTimeframe | undefined) => {
65
+ if (value == null) {
66
+ return '';
67
+ }
68
+ const labelsStr = value.labels.labels.map(l => `${l.name}:${l.value}`).join(',');
69
+ return `${labelsStr}|${value.bounds[0]},${value.bounds[1]}`;
70
+ },
71
+ };
72
+
73
+ interface ProfileFlameChartProps {
74
+ samplesData?: SamplesData;
75
+ queryClient: QueryServiceClient;
76
+ profileSource: ProfileSource;
77
+ width: number;
78
+ total: bigint;
79
+ filtered: bigint;
80
+ profileType?: ProfileType;
81
+ isHalfScreen: boolean;
82
+ metadataMappingFiles?: string[];
83
+ metadataLoading?: boolean;
84
+ onSwitchToOneMinute?: () => void;
85
+ }
86
+
87
+ // Helper to create a filtered profile source with narrowed time bounds
88
+ // and dimension label matchers from the selected strip.
89
+ const createFilteredProfileSource = (
90
+ profileSource: ProfileSource,
91
+ selectedTimeframe: {labels: LabelSet; bounds: NumberDuo}
92
+ ): ProfileSource | null => {
93
+ if (!(profileSource instanceof MergedProfileSource)) {
94
+ return null;
95
+ }
96
+
97
+ // The bounds are in milliseconds, convert to nanoseconds for the profile source
98
+ // Round to integers since BigInt requires integer values
99
+ const mergeFrom = BigInt(Math.round(selectedTimeframe.bounds[0])) * 1_000_000n;
100
+ const mergeTo = BigInt(Math.round(selectedTimeframe.bounds[1])) * 1_000_000n;
101
+
102
+ // Add dimension labels as additional matchers to the query
103
+ const dimensionMatchers = selectedTimeframe.labels.labels.map(
104
+ l => new Matcher(l.name, MatcherTypes.MatchEqual, l.value)
105
+ );
106
+
107
+ const query = new Query(
108
+ profileSource.query.profType,
109
+ [...profileSource.query.matchers, ...dimensionMatchers],
110
+ ''
111
+ );
112
+
113
+ return new MergedProfileSource(mergeFrom, mergeTo, query);
114
+ };
115
+
116
+ export const ProfileFlameChart = ({
117
+ samplesData,
118
+ queryClient,
119
+ profileSource,
120
+ width,
121
+ total,
122
+ filtered,
123
+ profileType,
124
+ isHalfScreen,
125
+ metadataMappingFiles,
126
+ metadataLoading,
127
+ onSwitchToOneMinute,
128
+ }: ProfileFlameChartProps): JSX.Element => {
129
+ const {loader} = useParcaContext();
130
+
131
+ const [selectedTimeframe, setSelectedTimeframe] = useURLStateCustom<
132
+ SelectedTimeframe | undefined
133
+ >('flamechart_timeframe', TimeframeStateSerializer);
134
+
135
+ // Read flamechart dimension from URL state to detect changes
136
+ const [flamechartDimension] = useURLState<string[]>('flamechart_dimension', {
137
+ alwaysReturnArray: true,
138
+ });
139
+
140
+ // Reset selection when the parent time range (profileSource) changes
141
+ const timeBoundsKey = boundsFromProfileSource(profileSource).join(',');
142
+ const prevTimeBoundsKey = useRef(timeBoundsKey);
143
+ useEffect(() => {
144
+ if (prevTimeBoundsKey.current !== timeBoundsKey) {
145
+ prevTimeBoundsKey.current = timeBoundsKey;
146
+ setSelectedTimeframe(undefined);
147
+ }
148
+ }, [timeBoundsKey, setSelectedTimeframe]);
149
+
150
+ // Reset selection when the dimension changes
151
+ const dimensionKey = (flamechartDimension ?? []).join(',');
152
+ const prevDimensionKey = useRef(dimensionKey);
153
+ useEffect(() => {
154
+ if (prevDimensionKey.current !== dimensionKey) {
155
+ prevDimensionKey.current = dimensionKey;
156
+ setSelectedTimeframe(undefined);
157
+ }
158
+ }, [dimensionKey, setSelectedTimeframe]);
159
+
160
+ // Handle timeframe selection from strips
161
+ const handleSelectedTimeframe = (labels: LabelSet, bounds: NumberDuo | undefined): void => {
162
+ if (bounds === undefined) {
163
+ setSelectedTimeframe(undefined);
164
+ } else {
165
+ setSelectedTimeframe({labels, bounds});
166
+ }
167
+ };
168
+
169
+ // Create filtered profile source when selection exists
170
+ const filteredProfileSource = useMemo(() => {
171
+ if (selectedTimeframe == null) return null;
172
+ return createFilteredProfileSource(profileSource, selectedTimeframe);
173
+ }, [profileSource, selectedTimeframe]);
174
+
175
+ // Query flamechart data only when a strip selection exists
176
+ const {
177
+ isLoading: flamechartLoading,
178
+ response: flamechartResponse,
179
+ error: flamechartError,
180
+ } = useQuery(
181
+ queryClient,
182
+ filteredProfileSource ?? profileSource,
183
+ QueryRequest_ReportType.FLAMECHART,
184
+ {
185
+ skip: selectedTimeframe == null || filteredProfileSource == null,
186
+ }
187
+ );
188
+
189
+ const flamechartArrow =
190
+ flamechartResponse?.report.oneofKind === 'flamegraphArrow'
191
+ ? flamechartResponse.report.flamegraphArrow
192
+ : undefined;
193
+ const flamechartTotal = flamechartResponse != null ? BigInt(flamechartResponse.total) : total;
194
+ const flamechartFiltered =
195
+ flamechartResponse != null ? BigInt(flamechartResponse.filtered) : filtered;
196
+
197
+ // Get time bounds from profile source for the strips
198
+ const timeBounds = boundsFromProfileSource(profileSource);
199
+
200
+ // Transform samples data for SamplesStrip component
201
+ const stripsData = useMemo(() => {
202
+ if (samplesData?.series == null) return {cpus: [], data: [], stepMs: 0};
203
+
204
+ const cpus = samplesData.series.map(s => s.labelset);
205
+ const data = samplesData.series.map(s => s.data);
206
+
207
+ const stepMs = samplesData.stepMs ?? 0;
208
+
209
+ return {cpus, data, stepMs};
210
+ }, [samplesData?.series, samplesData?.stepMs]);
211
+
212
+ const {isValid, isNonDelta, isDurationTooLong} = validateFlameChartQuery(
213
+ profileSource as MergedProfileSource
214
+ );
215
+
216
+ if (!isValid) {
217
+ if (isDurationTooLong) {
218
+ return (
219
+ <div className="flex flex-col justify-center items-center p-10 text-center gap-4 text-sm">
220
+ <span>
221
+ Flame chart is unavailable for queries longer than one minute. Try reducing the time
222
+ range to one minute or selecting a point in the metrics graph.
223
+ </span>
224
+ {onSwitchToOneMinute != null && (
225
+ <Button variant="primary" onClick={onSwitchToOneMinute}>
226
+ Switch to last 1 minute
227
+ </Button>
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ const message = isNonDelta
234
+ ? 'To use the Flame chart, please switch to a Delta profile.'
235
+ : 'Flame chart is unavailable for this query.';
236
+ return (
237
+ <div className="flex flex-col justify-center p-10 text-center gap-6 text-sm">{message}</div>
238
+ );
239
+ }
240
+
241
+ const hasDimension = (flamechartDimension ?? []).length > 0;
242
+
243
+ // Show loader while metadata labels are loading (needed for dimension auto-selection)
244
+ if (metadataLoading === true) {
245
+ return <>{loader}</>;
246
+ }
247
+
248
+ if (!hasDimension) {
249
+ return (
250
+ <div className="flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm">
251
+ Select a label in the &quot;Samples group by&quot; dropdown above to view the samples
252
+ strips.
253
+ </div>
254
+ );
255
+ }
256
+
257
+ if (samplesData?.loading === true) {
258
+ return <>{loader}</>;
259
+ }
260
+
261
+ return (
262
+ <div>
263
+ {/* Samples Strips - rendered above flamechart */}
264
+ {stripsData.cpus.length > 0 && stripsData.data.length > 0 && (
265
+ <div className="mb-2">
266
+ <SamplesStrip
267
+ cpus={stripsData.cpus}
268
+ data={stripsData.data}
269
+ selectedTimeframe={selectedTimeframe}
270
+ onSelectedTimeframe={handleSelectedTimeframe}
271
+ width={width}
272
+ bounds={[Number(timeBounds[0] / 1_000_000n), Number(timeBounds[1] / 1_000_000n)]}
273
+ stepMs={stripsData.stepMs}
274
+ />
275
+ </div>
276
+ )}
277
+
278
+ {/* Flamegraph visualization - only shown when a time range is selected in the strips */}
279
+ {selectedTimeframe != null && filteredProfileSource != null ? (
280
+ <ProfileFlameGraph
281
+ arrow={flamechartArrow}
282
+ loading={flamechartLoading}
283
+ error={flamechartError}
284
+ profileSource={filteredProfileSource}
285
+ width={width}
286
+ total={flamechartTotal}
287
+ filtered={flamechartFiltered}
288
+ profileType={profileType}
289
+ isHalfScreen={isHalfScreen}
290
+ metadataMappingFiles={metadataMappingFiles}
291
+ metadataLoading={metadataLoading}
292
+ isFlameChart={true}
293
+ curPathArrow={[]}
294
+ setNewCurPathArrow={() => {}}
295
+ />
296
+ ) : (
297
+ <div className="flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm">
298
+ Select a time range in the samples strips above to view the flamechart.
299
+ </div>
300
+ )}
301
+ </div>
302
+ );
303
+ };
304
+
305
+ export default ProfileFlameChart;
@@ -77,7 +77,6 @@ export const validateFlameChartQuery = (
77
77
  ): {isValid: boolean; isNonDelta: boolean; isDurationTooLong: boolean} => {
78
78
  const isNonDelta = !profileSource.ProfileType().delta;
79
79
  const duration = profileSource.mergeTo - profileSource.mergeFrom;
80
- console.log('duration of flame chart query: ', duration, 'ns');
81
80
  const isDurationTooLong = duration > 60_000_000_000n; // 60 seconds in nanoseconds
82
81
  return {isValid: !isNonDelta && !isDurationTooLong, isNonDelta, isDurationTooLong};
83
82
  };
@@ -11,20 +11,24 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
 
14
- import {useEffect, useMemo} from 'react';
15
-
16
14
  import {RpcError} from '@protobuf-ts/runtime-rpc';
17
15
 
18
16
  import {Duration, QueryRangeResponse, QueryServiceClient, Timestamp} from '@parca/client';
19
- import {useGrpcMetadata, useURLState} from '@parca/components';
20
- import {getStepDuration} from '@parca/utilities';
17
+ import {useGrpcMetadata} from '@parca/components';
18
+ import {getStepDuration, getStepDurationInMilliseconds} from '@parca/utilities';
21
19
 
22
20
  import useGrpcQuery from '../../useGrpcQuery';
23
21
 
22
+ interface QueryRangeResult {
23
+ response: QueryRangeResponse;
24
+ stepDurationMs: number;
25
+ }
26
+
24
27
  interface IQueryRangeState {
25
28
  response: QueryRangeResponse | null;
26
29
  isLoading: boolean;
27
30
  error: RpcError | null;
31
+ stepDurationMs: number;
28
32
  }
29
33
 
30
34
  export const getStepCountFromScreenWidth = (pixelsPerPoint: number): number => {
@@ -43,33 +47,16 @@ export const useQueryRange = (
43
47
  start: number,
44
48
  end: number,
45
49
  sumBy: string[],
50
+ stepCount: number,
46
51
  skip = false
47
52
  ): IQueryRangeState => {
48
53
  const metadata = useGrpcMetadata();
49
- const [stepCountStr, setStepCount] = useURLState('step_count');
50
-
51
- const defaultStepCount = useMemo(() => {
52
- return getStepCountFromScreenWidth(10);
53
- }, []);
54
-
55
- const stepCount = useMemo(() => {
56
- if (stepCountStr != null) {
57
- return parseInt(stepCountStr as string, 10);
58
- }
59
-
60
- return defaultStepCount;
61
- }, [stepCountStr, defaultStepCount]);
62
-
63
- useEffect(() => {
64
- if (stepCountStr == null) {
65
- setStepCount(defaultStepCount.toString());
66
- }
67
- }, [stepCountStr, defaultStepCount, setStepCount]);
68
54
 
69
- const {data, isLoading, error} = useGrpcQuery<QueryRangeResponse | undefined>({
55
+ const {data, isLoading, error} = useGrpcQuery<QueryRangeResult | undefined>({
70
56
  key: ['query-range', queryExpression, start, end, (sumBy ?? []).join(','), stepCount, metadata],
71
57
  queryFn: async signal => {
72
58
  const stepDuration = getStepDuration(start, end, stepCount);
59
+ const stepDurationMs = getStepDurationInMilliseconds(stepDuration);
73
60
  const {response} = await client.queryRange(
74
61
  {
75
62
  query: queryExpression,
@@ -81,7 +68,7 @@ export const useQueryRange = (
81
68
  },
82
69
  {meta: metadata, abort: signal}
83
70
  );
84
- return response;
71
+ return {response, stepDurationMs};
85
72
  },
86
73
  options: {
87
74
  retry: false,
@@ -90,5 +77,10 @@ export const useQueryRange = (
90
77
  },
91
78
  });
92
79
 
93
- return {isLoading, error: error as RpcError | null, response: data ?? null};
80
+ return {
81
+ isLoading,
82
+ error: error as RpcError | null,
83
+ response: data?.response ?? null,
84
+ stepDurationMs: data?.stepDurationMs ?? 0,
85
+ };
94
86
  };
@@ -25,8 +25,11 @@ import {
25
25
  import {
26
26
  DateTimeRange,
27
27
  MetricsGraphSkeleton,
28
+ NumberParser,
29
+ NumberSerializer,
28
30
  TextWithTooltip,
29
31
  useParcaContext,
32
+ useURLStateCustom,
30
33
  } from '@parca/components';
31
34
  import {Query} from '@parca/parser';
32
35
  import {TEST_IDS, testId} from '@parca/test-utils';
@@ -35,7 +38,7 @@ import {capitalizeOnlyFirstLetter, formatDate, timePattern, valueFormatter} from
35
38
  import {MergedProfileSelection, ProfileSelection} from '..';
36
39
  import MetricsGraph, {ContextMenuItemOrSubmenu, Series, SeriesPoint} from '../MetricsGraph';
37
40
  import {useMetricsGraphDimensions} from '../MetricsGraph/useMetricsGraphDimensions';
38
- import {useQueryRange} from './hooks/useQueryRange';
41
+ import {getStepCountFromScreenWidth, useQueryRange} from './hooks/useQueryRange';
39
42
 
40
43
  const createProfileContextMenuItems = (
41
44
  addLabelMatcher: (
@@ -197,11 +200,30 @@ const ProfileMetricsGraph = ({
197
200
  comparing = false,
198
201
  sumBy,
199
202
  }: ProfileMetricsGraphProps): JSX.Element => {
203
+ const [rawStepCount] = useURLStateCustom<number>('step_count', {
204
+ defaultValue: String(getStepCountFromScreenWidth(10)),
205
+ parse: NumberParser,
206
+ stringify: NumberSerializer,
207
+ });
208
+ // Clamp step count so the step duration is at least 1 second as we don't have this enforced server-side anymore.
209
+ const stepCount = useMemo(() => {
210
+ const maxForOneSecond = Math.floor((to - from) / 1000);
211
+ return Math.min(rawStepCount, maxForOneSecond);
212
+ }, [rawStepCount, from, to]);
213
+
200
214
  const {
201
215
  isLoading: metricsGraphLoading,
202
216
  response,
203
217
  error,
204
- } = useQueryRange(queryClient, queryExpression, from, to, sumBy, queryExpression === '');
218
+ } = useQueryRange(
219
+ queryClient,
220
+ queryExpression,
221
+ from,
222
+ to,
223
+ sumBy,
224
+ stepCount,
225
+ queryExpression === ''
226
+ );
205
227
  const {onError, perf, authenticationErrorMessage, isDarkMode, timezone} = useParcaContext();
206
228
  const {width, height, margin, heightStyle} = useMetricsGraphDimensions(comparing);
207
229
  const [showAllSeriesForResponse, setShowAllSeriesForResponse] = useState<typeof response | null>(
@@ -153,6 +153,17 @@ const ProfileSelector = ({
153
153
  DateTimeRange.fromRangeKey(draftSelection.timeSelection, draftSelection.from, draftSelection.to)
154
154
  );
155
155
 
156
+ // Sync local timeRangeSelection when URL state changes externally (e.g., "Switch to 1 minute" button)
157
+ useEffect(() => {
158
+ setTimeRangeSelection(
159
+ DateTimeRange.fromRangeKey(
160
+ querySelection.timeSelection,
161
+ querySelection.from,
162
+ querySelection.to
163
+ )
164
+ );
165
+ }, [querySelection.timeSelection, querySelection.from, querySelection.to]);
166
+
156
167
  const [queryExpressionString, setQueryExpressionString] = useState(draftSelection.expression);
157
168
 
158
169
  const [advancedModeForQueryBrowser, setAdvancedModeForQueryBrowser] = useState(
@@ -21,6 +21,7 @@ interface GroupByControlsProps {
21
21
  setGroupByLabels: (labels: string[]) => void;
22
22
  metadataRefetch?: () => Promise<void>;
23
23
  metadataLoading: boolean;
24
+ label?: string;
24
25
  }
25
26
 
26
27
  const GroupByControls: React.FC<GroupByControlsProps> = ({
@@ -29,6 +30,7 @@ const GroupByControls: React.FC<GroupByControlsProps> = ({
29
30
  setGroupByLabels,
30
31
  metadataRefetch,
31
32
  metadataLoading,
33
+ label,
32
34
  }) => {
33
35
  return (
34
36
  <div className="relative flex" id="h-group-by-controls">
@@ -38,6 +40,7 @@ const GroupByControls: React.FC<GroupByControlsProps> = ({
38
40
  setGroupByLabels={setGroupByLabels}
39
41
  metadataRefetch={metadataRefetch}
40
42
  metadataLoading={metadataLoading}
43
+ label={label}
41
44
  />
42
45
  </div>
43
46
  );
@@ -16,6 +16,7 @@ import {Profiler, ProfilerOnRenderCallback} from 'react';
16
16
  import {QueryServiceClient} from '@parca/client';
17
17
  import {ConditionalWrapper} from '@parca/components';
18
18
 
19
+ import ProfileFlameChart from '../../../ProfileFlameChart';
19
20
  import ProfileFlameGraph from '../../../ProfileFlameGraph';
20
21
  import {CurrentPathFrame} from '../../../ProfileFlameGraph/FlameGraphArrow/utils';
21
22
  import {ProfileSource} from '../../../ProfileSource';
@@ -24,6 +25,7 @@ import {SourceView} from '../../../SourceView';
24
25
  import {Table} from '../../../Table';
25
26
  import type {
26
27
  FlamegraphData,
28
+ SamplesData,
27
29
  SandwichData,
28
30
  SourceData,
29
31
  TopTableData,
@@ -35,7 +37,7 @@ interface GetDashboardItemProps {
35
37
  isHalfScreen: boolean;
36
38
  dimensions: DOMRect | undefined;
37
39
  flamegraphData: FlamegraphData;
38
- flamechartData: FlamegraphData;
40
+ samplesData?: SamplesData;
39
41
  topTableData?: TopTableData;
40
42
  sandwichData: SandwichData;
41
43
  sourceData?: SourceData;
@@ -47,7 +49,8 @@ interface GetDashboardItemProps {
47
49
  perf?: {
48
50
  onRender?: ProfilerOnRenderCallback;
49
51
  };
50
- queryClient?: QueryServiceClient;
52
+ queryClient: QueryServiceClient;
53
+ onSwitchToOneMinute?: () => void;
51
54
  }
52
55
 
53
56
  export const getDashboardItem = ({
@@ -55,7 +58,7 @@ export const getDashboardItem = ({
55
58
  isHalfScreen,
56
59
  dimensions,
57
60
  flamegraphData,
58
- flamechartData,
61
+ samplesData,
59
62
  topTableData,
60
63
  sourceData,
61
64
  sandwichData,
@@ -65,6 +68,8 @@ export const getDashboardItem = ({
65
68
  curPathArrow,
66
69
  setNewCurPathArrow,
67
70
  perf,
71
+ queryClient,
72
+ onSwitchToOneMinute,
68
73
  }: GetDashboardItemProps): JSX.Element => {
69
74
  switch (type) {
70
75
  case 'flamegraph':
@@ -102,16 +107,10 @@ export const getDashboardItem = ({
102
107
  );
103
108
  case 'flamechart':
104
109
  return (
105
- <ProfileFlameGraph
106
- curPathArrow={[]}
107
- setNewCurPathArrow={() => {}}
108
- arrow={flamechartData?.arrow}
109
- total={total}
110
- filtered={filtered}
111
- profileType={profileSource?.ProfileType()}
112
- loading={flamechartData.loading}
113
- error={flamechartData.error}
114
- isHalfScreen={isHalfScreen}
110
+ <ProfileFlameChart
111
+ samplesData={samplesData}
112
+ queryClient={queryClient}
113
+ profileSource={profileSource}
115
114
  width={
116
115
  dimensions?.width !== undefined
117
116
  ? isHalfScreen
@@ -119,10 +118,13 @@ export const getDashboardItem = ({
119
118
  : dimensions.width - 16
120
119
  : 0
121
120
  }
122
- metadataMappingFiles={flamechartData.metadataMappingFiles}
123
- metadataLoading={flamechartData.metadataLoading}
124
- profileSource={profileSource}
125
- isFlameChart={true}
121
+ total={total}
122
+ filtered={filtered}
123
+ profileType={profileSource?.ProfileType()}
124
+ isHalfScreen={isHalfScreen}
125
+ metadataMappingFiles={flamegraphData.metadataMappingFiles}
126
+ metadataLoading={flamegraphData.metadataLoading}
127
+ onSwitchToOneMinute={onSwitchToOneMinute}
126
128
  />
127
129
  );
128
130
  case 'table':
@@ -27,6 +27,7 @@ interface Props {
27
27
  setGroupByLabels: (labels: string[]) => void;
28
28
  metadataRefetch?: () => Promise<void>;
29
29
  metadataLoading: boolean;
30
+ label?: string;
30
31
  }
31
32
 
32
33
  const GroupByLabelsDropdown = ({
@@ -35,12 +36,13 @@ const GroupByLabelsDropdown = ({
35
36
  setGroupByLabels,
36
37
  metadataRefetch,
37
38
  metadataLoading,
39
+ label = 'Group by',
38
40
  }: Props): JSX.Element => {
39
41
  return (
40
42
  <div className="flex flex-col relative" {...testId(TEST_IDS.GROUP_BY_CONTAINER)}>
41
43
  <div className="flex items-center justify-between">
42
44
  <label className="text-sm" {...testId(TEST_IDS.GROUP_BY_LABEL)}>
43
- Group by
45
+ {label}
44
46
  </label>
45
47
  </div>
46
48
 
@@ -56,7 +58,7 @@ const GroupByLabelsDropdown = ({
56
58
  refreshTitle="Refresh label names"
57
59
  refreshTestId="group-by-refresh-button"
58
60
  menuTestId={TEST_IDS.GROUP_BY_SELECT_FLYOUT}
59
- value={groupBy
61
+ value={(groupBy ?? [])
60
62
  .filter(l => l.startsWith(FIELD_LABELS))
61
63
  .map(l => ({value: l, label: l.slice(FIELD_LABELS.length + 1)}))}
62
64
  onChange={newValue => {
@@ -412,7 +412,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
412
412
  <Menu.Items
413
413
  className={cx(
414
414
  isTableVizOnly ? 'w-64' : 'w-80',
415
- 'absolute z-30 mt-2 py-2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border dark:bg-gray-900 dark:border-gray-600',
415
+ 'absolute z-50 mt-2 py-2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border dark:bg-gray-900 dark:border-gray-600',
416
416
  shouldOpenLeft ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
417
417
  )}
418
418
  >
@@ -47,6 +47,8 @@ export interface VisualisationToolbarProps {
47
47
  preferencesModal?: boolean;
48
48
  profileViewExternalSubActions?: React.ReactNode;
49
49
  setGroupByLabels: (labels: string[]) => void;
50
+ flamechartDimension: string[];
51
+ setFlamechartDimension: (labels: string[]) => void;
50
52
  showVisualizationSelector?: boolean;
51
53
  sandwichFunctionName?: string;
52
54
  alignFunctionName: string;
@@ -135,6 +137,8 @@ export const VisualisationToolbar: FC<VisualisationToolbarProps> = ({
135
137
  groupBy,
136
138
  toggleGroupBy,
137
139
  setGroupByLabels,
140
+ flamechartDimension,
141
+ setFlamechartDimension,
138
142
  profileType,
139
143
  profileSource,
140
144
  queryClient,
@@ -158,6 +162,8 @@ export const VisualisationToolbar: FC<VisualisationToolbarProps> = ({
158
162
  const isTableVizOnly = dashboardItems?.length === 1 && isTableViz;
159
163
  const isGraphViz = dashboardItems?.includes('flamegraph');
160
164
  const isGraphVizOnly = dashboardItems?.length === 1 && isGraphViz;
165
+ const isFlamechartViz = dashboardItems?.includes('flamechart');
166
+ const isFlamechartVizOnly = dashboardItems?.length === 1 && isFlamechartViz;
161
167
 
162
168
  const req = profileSource?.QueryRequest();
163
169
  if (req !== null && req !== undefined) {
@@ -184,8 +190,19 @@ export const VisualisationToolbar: FC<VisualisationToolbarProps> = ({
184
190
  </>
185
191
  )}
186
192
 
193
+ {isFlamechartViz && (
194
+ <GroupByDropdown
195
+ groupBy={flamechartDimension}
196
+ labels={groupByLabels}
197
+ setGroupByLabels={setFlamechartDimension}
198
+ metadataRefetch={metadataRefetch}
199
+ metadataLoading={metadataLoading}
200
+ label="Samples group by"
201
+ />
202
+ )}
203
+
187
204
  <div className="flex mt-5">
188
- <ProfileFilters />
205
+ {!isFlamechartVizOnly && <ProfileFilters />}
189
206
 
190
207
  {profileViewExternalSubActions != null ? profileViewExternalSubActions : null}
191
208
  </div>