@parca/profile 0.19.113 → 0.19.114
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/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
- package/dist/ProfileExplorer/ProfileExplorerSingle.js +9 -3
- package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts +31 -0
- package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts.map +1 -0
- package/dist/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.js +32 -60
- package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.d.ts → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts} +4 -3
- package/dist/ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts.map +1 -0
- package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.js → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.js} +5 -4
- package/dist/{MetricsGraphStrips → ProfileFlameChart/SamplesStrips}/index.d.ts +5 -4
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -0
- package/dist/ProfileFlameChart/SamplesStrips/index.js +141 -0
- package/dist/ProfileFlameChart/index.d.ts +20 -0
- package/dist/ProfileFlameChart/index.d.ts.map +1 -0
- package/dist/ProfileFlameChart/index.js +155 -0
- package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/index.js +0 -1
- package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts +2 -1
- package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts.map +1 -1
- package/dist/ProfileMetricsGraph/hooks/useQueryRange.js +11 -21
- package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
- package/dist/ProfileMetricsGraph/index.js +13 -3
- package/dist/ProfileSelector/index.d.ts.map +1 -1
- package/dist/ProfileSelector/index.js +4 -0
- package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts +1 -0
- package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/ActionButtons/GroupByDropdown.js +2 -2
- package/dist/ProfileView/components/DashboardItems/index.d.ts +5 -4
- package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
- package/dist/ProfileView/components/DashboardItems/index.js +4 -3
- package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts +2 -1
- package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts.map +1 -1
- package/dist/ProfileView/components/GroupByLabelsDropdown/index.js +2 -2
- package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +1 -1
- package/dist/ProfileView/components/Toolbars/index.d.ts +2 -0
- package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/index.js +4 -2
- package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts +16 -0
- package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts.map +1 -0
- package/dist/ProfileView/hooks/useAutoSelectDimension.js +75 -0
- package/dist/ProfileView/hooks/useVisualizationState.d.ts +2 -0
- package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useVisualizationState.js +8 -0
- package/dist/ProfileView/index.d.ts +1 -1
- package/dist/ProfileView/index.d.ts.map +1 -1
- package/dist/ProfileView/index.js +7 -4
- package/dist/ProfileView/types/visualization.d.ts +15 -3
- package/dist/ProfileView/types/visualization.d.ts.map +1 -1
- package/dist/ProfileViewWithData.d.ts +2 -1
- package/dist/ProfileViewWithData.d.ts.map +1 -1
- package/dist/ProfileViewWithData.js +41 -29
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/styles.css +1 -1
- package/package.json +8 -7
- package/src/ProfileExplorer/ProfileExplorerSingle.tsx +14 -3
- package/src/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.tsx +77 -81
- package/src/{MetricsGraphStrips/MetricsGraphStrips.stories.tsx → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.tsx} +7 -6
- package/src/ProfileFlameChart/SamplesStrips/index.tsx +301 -0
- package/src/ProfileFlameChart/index.tsx +305 -0
- package/src/ProfileFlameGraph/index.tsx +0 -1
- package/src/ProfileMetricsGraph/hooks/useQueryRange.ts +18 -26
- package/src/ProfileMetricsGraph/index.tsx +24 -2
- package/src/ProfileSelector/index.tsx +11 -0
- package/src/ProfileView/components/ActionButtons/GroupByDropdown.tsx +3 -0
- package/src/ProfileView/components/DashboardItems/index.tsx +19 -17
- package/src/ProfileView/components/GroupByLabelsDropdown/index.tsx +4 -2
- package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +1 -1
- package/src/ProfileView/components/Toolbars/index.tsx +18 -1
- package/src/ProfileView/hooks/useAutoSelectDimension.ts +90 -0
- package/src/ProfileView/hooks/useVisualizationState.ts +17 -0
- package/src/ProfileView/index.tsx +16 -2
- package/src/ProfileView/types/visualization.ts +17 -3
- package/src/ProfileViewWithData.tsx +80 -37
- package/src/index.tsx +4 -0
- package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts +0 -10
- package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts.map +0 -1
- package/dist/MetricsGraphStrips/AreaGraph/Tooltip.js +0 -44
- package/dist/MetricsGraphStrips/AreaGraph/index.d.ts +0 -21
- package/dist/MetricsGraphStrips/AreaGraph/index.d.ts.map +0 -1
- package/dist/MetricsGraphStrips/MetricsGraphStrips.stories.d.ts.map +0 -1
- package/dist/MetricsGraphStrips/index.d.ts.map +0 -1
- package/dist/MetricsGraphStrips/index.js +0 -70
- package/src/MetricsGraphStrips/AreaGraph/Tooltip.tsx +0 -83
- package/src/MetricsGraphStrips/index.tsx +0 -142
|
@@ -16,9 +16,9 @@ import {useArgs} from '@storybook/preview-api';
|
|
|
16
16
|
// eslint-disable-next-line import/named
|
|
17
17
|
import {Meta} from '@storybook/react';
|
|
18
18
|
|
|
19
|
-
import {NumberDuo} from '
|
|
20
|
-
import {DataPoint} from './
|
|
21
|
-
import {
|
|
19
|
+
import {NumberDuo} from '../../utils';
|
|
20
|
+
import {DataPoint} from './SamplesGraph';
|
|
21
|
+
import {SamplesStrip} from './index';
|
|
22
22
|
|
|
23
23
|
function seededRandom(seed: number): () => number {
|
|
24
24
|
return () => {
|
|
@@ -39,8 +39,8 @@ for (let i = 0; i < 200; i++) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
const meta: Meta = {
|
|
42
|
-
title: 'components/
|
|
43
|
-
component:
|
|
42
|
+
title: 'components/SamplesStrip',
|
|
43
|
+
component: SamplesStrip,
|
|
44
44
|
};
|
|
45
45
|
export default meta;
|
|
46
46
|
|
|
@@ -53,6 +53,7 @@ export const ThreeCPUStrips = {
|
|
|
53
53
|
console.log('onSelectedTimeframe', index, bounds);
|
|
54
54
|
},
|
|
55
55
|
bounds: [mockData[0][0].timestamp, mockData[0][mockData[0].length - 1].timestamp],
|
|
56
|
+
stepMs: 100,
|
|
56
57
|
},
|
|
57
58
|
render: function Component(args: any): JSX.Element {
|
|
58
59
|
const [, setArgs] = useArgs();
|
|
@@ -62,6 +63,6 @@ export const ThreeCPUStrips = {
|
|
|
62
63
|
setArgs({...args, selectedTimeframe: {index, bounds}});
|
|
63
64
|
};
|
|
64
65
|
|
|
65
|
-
return <
|
|
66
|
+
return <SamplesStrip {...args} onSelectedTimeframe={onSelectedTimeframe} />;
|
|
66
67
|
},
|
|
67
68
|
};
|
|
@@ -0,0 +1,301 @@
|
|
|
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 {useMemo, useRef, useState} from 'react';
|
|
15
|
+
|
|
16
|
+
import {Icon} from '@iconify/react';
|
|
17
|
+
import cx from 'classnames';
|
|
18
|
+
import * as d3 from 'd3';
|
|
19
|
+
import isEqual from 'fast-deep-equal';
|
|
20
|
+
import {useIntersectionObserver} from 'usehooks-ts';
|
|
21
|
+
|
|
22
|
+
import {LabelSet} from '@parca/client';
|
|
23
|
+
|
|
24
|
+
import {TimelineGuide} from '../../TimelineGuide';
|
|
25
|
+
import {NumberDuo} from '../../utils';
|
|
26
|
+
import {DataPoint, SamplesGraph} from './SamplesGraph';
|
|
27
|
+
|
|
28
|
+
export type {DataPoint} from './SamplesGraph';
|
|
29
|
+
|
|
30
|
+
interface DragState {
|
|
31
|
+
stripIndex: number;
|
|
32
|
+
startX: number;
|
|
33
|
+
currentX: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Props {
|
|
37
|
+
cpus: LabelSet[];
|
|
38
|
+
data: DataPoint[][];
|
|
39
|
+
selectedTimeframe?: {
|
|
40
|
+
labels: LabelSet;
|
|
41
|
+
bounds: NumberDuo;
|
|
42
|
+
};
|
|
43
|
+
onSelectedTimeframe: (labels: LabelSet, bounds: NumberDuo | undefined) => void;
|
|
44
|
+
width?: number;
|
|
45
|
+
bounds: NumberDuo;
|
|
46
|
+
stepMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const labelSetToString = (labelSet?: LabelSet): string => {
|
|
50
|
+
if (labelSet === undefined) {
|
|
51
|
+
return '{}';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let str = '{';
|
|
55
|
+
|
|
56
|
+
let isFirst = true;
|
|
57
|
+
for (const label of labelSet.labels) {
|
|
58
|
+
if (!isFirst) {
|
|
59
|
+
str += ', ';
|
|
60
|
+
} else {
|
|
61
|
+
isFirst = false;
|
|
62
|
+
}
|
|
63
|
+
str += `${label.name}: ${label.value}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
str += '}';
|
|
67
|
+
|
|
68
|
+
return str;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const STRIP_HEIGHT = 24;
|
|
72
|
+
|
|
73
|
+
const getTimelineGuideHeight = (cpusCount: number, collapsedCount: number): number => {
|
|
74
|
+
return (STRIP_HEIGHT + 4) * (cpusCount - collapsedCount) + 20 * collapsedCount + 24 - 6;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const stickyPx = 0;
|
|
78
|
+
|
|
79
|
+
const SamplesGraphContainer = ({
|
|
80
|
+
isSelected,
|
|
81
|
+
isCollapsed,
|
|
82
|
+
cpu,
|
|
83
|
+
width,
|
|
84
|
+
onToggleCollapse,
|
|
85
|
+
data,
|
|
86
|
+
selectionBounds,
|
|
87
|
+
setSelectionBounds,
|
|
88
|
+
color,
|
|
89
|
+
stepMs,
|
|
90
|
+
onDragStart,
|
|
91
|
+
dragState,
|
|
92
|
+
stripIndex,
|
|
93
|
+
isAnyDragActive,
|
|
94
|
+
timeBounds,
|
|
95
|
+
}: {
|
|
96
|
+
isSelected: boolean;
|
|
97
|
+
isCollapsed: boolean;
|
|
98
|
+
cpu: LabelSet;
|
|
99
|
+
width: number | undefined;
|
|
100
|
+
onToggleCollapse: () => void;
|
|
101
|
+
data: DataPoint[];
|
|
102
|
+
selectionBounds: NumberDuo | undefined;
|
|
103
|
+
setSelectionBounds: (bounds: NumberDuo | undefined) => void;
|
|
104
|
+
color: (label: string) => string;
|
|
105
|
+
stepMs: number;
|
|
106
|
+
onDragStart: (stripIndex: number, startX: number) => void;
|
|
107
|
+
dragState: DragState | undefined;
|
|
108
|
+
stripIndex: number;
|
|
109
|
+
isAnyDragActive: boolean;
|
|
110
|
+
timeBounds: NumberDuo;
|
|
111
|
+
}): JSX.Element => {
|
|
112
|
+
const labelStr = labelSetToString(cpu);
|
|
113
|
+
|
|
114
|
+
const {isIntersecting, ref} = useIntersectionObserver({
|
|
115
|
+
rootMargin: `${stickyPx}px 0px 0px 0px`,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const isSticky = useMemo(() => {
|
|
119
|
+
return isSelected && isIntersecting;
|
|
120
|
+
}, [isSelected, isIntersecting]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
className={cx('min-h-5', {
|
|
125
|
+
relative: !isSelected,
|
|
126
|
+
'sticky z-30 bg-white dark:bg-black bg-opacity-75': isSelected,
|
|
127
|
+
'!bg-opacity-100': isSticky,
|
|
128
|
+
})}
|
|
129
|
+
style={{width: width ?? 1468, top: isSelected ? stickyPx : undefined}}
|
|
130
|
+
key={labelStr}
|
|
131
|
+
ref={ref}
|
|
132
|
+
>
|
|
133
|
+
<div
|
|
134
|
+
className="text-xs absolute top-0 left-0 flex gap-[2px] items-center bg-white/50 dark:bg-black/50 px-1 rounded-sm cursor-pointer"
|
|
135
|
+
style={{
|
|
136
|
+
zIndex: 15,
|
|
137
|
+
}}
|
|
138
|
+
onClick={onToggleCollapse}
|
|
139
|
+
>
|
|
140
|
+
<Icon icon={isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow'} />
|
|
141
|
+
{labelStr}
|
|
142
|
+
</div>
|
|
143
|
+
{!isCollapsed ? (
|
|
144
|
+
<SamplesGraph
|
|
145
|
+
data={data}
|
|
146
|
+
height={STRIP_HEIGHT}
|
|
147
|
+
width={width ?? 1468}
|
|
148
|
+
fill={color(labelStr)}
|
|
149
|
+
selectionBounds={selectionBounds}
|
|
150
|
+
setSelectionBounds={setSelectionBounds}
|
|
151
|
+
stepMs={stepMs}
|
|
152
|
+
onDragStart={(startX: number) => onDragStart(stripIndex, startX)}
|
|
153
|
+
dragState={dragState?.stripIndex === stripIndex ? dragState : undefined}
|
|
154
|
+
isAnyDragActive={isAnyDragActive}
|
|
155
|
+
timeBounds={timeBounds}
|
|
156
|
+
/>
|
|
157
|
+
) : null}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const SamplesStrip = ({
|
|
163
|
+
cpus,
|
|
164
|
+
data,
|
|
165
|
+
selectedTimeframe,
|
|
166
|
+
onSelectedTimeframe,
|
|
167
|
+
width,
|
|
168
|
+
bounds,
|
|
169
|
+
stepMs,
|
|
170
|
+
}: Props): JSX.Element => {
|
|
171
|
+
const [collapsedLabels, setCollapsedLabels] = useState<Set<string>>(new Set());
|
|
172
|
+
const [dragState, setDragState] = useState<DragState | undefined>(undefined);
|
|
173
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
174
|
+
|
|
175
|
+
const isDragging = dragState !== undefined;
|
|
176
|
+
|
|
177
|
+
// Sort cpus and data by label string for consistent ordering across reloads
|
|
178
|
+
const sortedItems = useMemo(() => {
|
|
179
|
+
const items = cpus.map((cpu, i) => ({
|
|
180
|
+
cpu,
|
|
181
|
+
data: data[i],
|
|
182
|
+
label: labelSetToString(cpu),
|
|
183
|
+
}));
|
|
184
|
+
return items.sort((a, b) => a.label.localeCompare(b.label));
|
|
185
|
+
}, [cpus, data]);
|
|
186
|
+
|
|
187
|
+
// Deterministic color: hash the label string so the same label always gets the same color
|
|
188
|
+
// regardless of render order.
|
|
189
|
+
const color = useMemo(() => {
|
|
190
|
+
const palette = d3.schemeObservable10;
|
|
191
|
+
const hashStr = (s: string): number => {
|
|
192
|
+
let h = 0;
|
|
193
|
+
for (let i = 0; i < s.length; i++) {
|
|
194
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
195
|
+
}
|
|
196
|
+
return Math.abs(h);
|
|
197
|
+
};
|
|
198
|
+
return (label: string): string => palette[hashStr(label) % palette.length];
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
const handleDragStart = (stripIndex: number, startX: number): void => {
|
|
202
|
+
setDragState({stripIndex, startX, currentX: startX});
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const handleMouseMove = (e: React.MouseEvent): void => {
|
|
206
|
+
if (dragState === undefined || containerRef.current === null) return;
|
|
207
|
+
|
|
208
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
209
|
+
const x = e.clientX - rect.left;
|
|
210
|
+
// Clamp to container bounds
|
|
211
|
+
const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
|
|
212
|
+
setDragState({...dragState, currentX: clampedX});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleMouseUp = (e: React.MouseEvent): void => {
|
|
216
|
+
if (dragState === undefined || containerRef.current === null) return;
|
|
217
|
+
|
|
218
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
219
|
+
const x = e.clientX - rect.left;
|
|
220
|
+
const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
|
|
221
|
+
|
|
222
|
+
const {stripIndex, startX} = dragState;
|
|
223
|
+
if (startX !== clampedX) {
|
|
224
|
+
const start = Math.min(startX, clampedX);
|
|
225
|
+
const end = Math.max(startX, clampedX);
|
|
226
|
+
// Convert pixel positions to timestamps
|
|
227
|
+
const innerWidth = width ?? rect.width;
|
|
228
|
+
const startTs = bounds[0] + (start / innerWidth) * (bounds[1] - bounds[0]);
|
|
229
|
+
const endTs = bounds[0] + (end / innerWidth) * (bounds[1] - bounds[0]);
|
|
230
|
+
// Use sortedItems to get the correct cpu for the strip index
|
|
231
|
+
onSelectedTimeframe(sortedItems[stripIndex].cpu, [startTs, endTs]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
setDragState(undefined);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const handleMouseLeave = (): void => {
|
|
238
|
+
setDragState(undefined);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (data.length === 0) {
|
|
242
|
+
return (
|
|
243
|
+
<span className="flex justify-center my-10">
|
|
244
|
+
There is no data matching your filter criteria, please try changing the filter.
|
|
245
|
+
</span>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
ref={containerRef}
|
|
252
|
+
className={cx('flex flex-col gap-1 relative my-0', {'cursor-ew-resize': isDragging})}
|
|
253
|
+
style={{width: width ?? '100%'}}
|
|
254
|
+
onMouseMove={handleMouseMove}
|
|
255
|
+
onMouseUp={handleMouseUp}
|
|
256
|
+
onMouseLeave={handleMouseLeave}
|
|
257
|
+
>
|
|
258
|
+
<TimelineGuide
|
|
259
|
+
bounds={[BigInt(0), BigInt(bounds[1] - bounds[0])]}
|
|
260
|
+
width={width ?? 1468}
|
|
261
|
+
height={getTimelineGuideHeight(sortedItems.length, collapsedLabels.size)}
|
|
262
|
+
margin={1}
|
|
263
|
+
/>
|
|
264
|
+
{sortedItems.map((item, i) => {
|
|
265
|
+
const isCollapsed = collapsedLabels.has(item.label);
|
|
266
|
+
const isSelected = isEqual(item.cpu, selectedTimeframe?.labels);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<SamplesGraphContainer
|
|
270
|
+
isSelected={isSelected}
|
|
271
|
+
isCollapsed={isCollapsed}
|
|
272
|
+
cpu={item.cpu}
|
|
273
|
+
width={width}
|
|
274
|
+
data={item.data}
|
|
275
|
+
onToggleCollapse={() => {
|
|
276
|
+
const newCollapsedLabels = new Set(collapsedLabels);
|
|
277
|
+
if (collapsedLabels.has(item.label)) {
|
|
278
|
+
newCollapsedLabels.delete(item.label);
|
|
279
|
+
} else {
|
|
280
|
+
newCollapsedLabels.add(item.label);
|
|
281
|
+
}
|
|
282
|
+
setCollapsedLabels(newCollapsedLabels);
|
|
283
|
+
}}
|
|
284
|
+
selectionBounds={isSelected ? selectedTimeframe?.bounds : undefined}
|
|
285
|
+
setSelectionBounds={newBounds => {
|
|
286
|
+
onSelectedTimeframe(item.cpu, newBounds);
|
|
287
|
+
}}
|
|
288
|
+
color={color}
|
|
289
|
+
stepMs={stepMs}
|
|
290
|
+
onDragStart={handleDragStart}
|
|
291
|
+
dragState={dragState}
|
|
292
|
+
stripIndex={i}
|
|
293
|
+
isAnyDragActive={isDragging}
|
|
294
|
+
timeBounds={bounds}
|
|
295
|
+
key={item.label}
|
|
296
|
+
/>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
};
|
|
@@ -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 "Samples group by" 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
|
};
|