@parca/profile 0.19.133 → 0.19.135
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 +8 -0
- package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
- package/dist/ProfileExplorer/ProfileExplorerSingle.js +3 -9
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/SamplesStrips/index.js +61 -39
- package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts +7 -0
- package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts.map +1 -0
- package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.js +79 -0
- package/dist/ProfileFlameChart/index.d.ts +1 -2
- package/dist/ProfileFlameChart/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.js +14 -21
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts +3 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +89 -24
- package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +2 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +2 -2
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts +4 -0
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +51 -10
- package/dist/ProfileFlameGraph/index.d.ts +0 -1
- package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/index.js +3 -8
- package/dist/ProfileView/components/DashboardItems/index.d.ts +1 -2
- package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
- package/dist/ProfileView/components/DashboardItems/index.js +2 -2
- package/dist/ProfileView/index.d.ts +1 -1
- package/dist/ProfileView/index.d.ts.map +1 -1
- package/dist/ProfileView/index.js +1 -2
- package/dist/ProfileView/types/visualization.d.ts +0 -1
- package/dist/ProfileView/types/visualization.d.ts.map +1 -1
- package/dist/ProfileViewWithData.d.ts +1 -2
- package/dist/ProfileViewWithData.d.ts.map +1 -1
- package/dist/ProfileViewWithData.js +2 -2
- package/dist/TimelineGuide/index.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/ProfileExplorer/ProfileExplorerSingle.tsx +3 -14
- package/src/ProfileFlameChart/SamplesStrips/index.tsx +90 -49
- package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.test.ts +73 -0
- package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.ts +86 -0
- package/src/ProfileFlameChart/index.tsx +16 -45
- package/src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx +119 -25
- package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +3 -1
- package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +5 -3
- package/src/ProfileFlameGraph/FlameGraphArrow/useZoom.ts +78 -17
- package/src/ProfileFlameGraph/index.tsx +4 -24
- package/src/ProfileView/components/DashboardItems/index.tsx +0 -3
- package/src/ProfileView/index.tsx +0 -2
- package/src/ProfileView/types/visualization.ts +0 -1
- package/src/ProfileViewWithData.tsx +0 -3
- package/src/TimelineGuide/index.tsx +1 -1
|
@@ -20,11 +20,12 @@ import isEqual from 'fast-deep-equal';
|
|
|
20
20
|
import {useIntersectionObserver} from 'usehooks-ts';
|
|
21
21
|
|
|
22
22
|
import {LabelSet} from '@parca/client';
|
|
23
|
-
import {Button} from '@parca/components';
|
|
23
|
+
import {Button, useParcaContext} from '@parca/components';
|
|
24
24
|
|
|
25
25
|
import {TimelineGuide} from '../../TimelineGuide';
|
|
26
26
|
import {NumberDuo} from '../../utils';
|
|
27
27
|
import {DataPoint, SamplesGraph} from './SamplesGraph';
|
|
28
|
+
import {createLabelSetComparator, labelSetToString} from './labelSetUtils';
|
|
28
29
|
|
|
29
30
|
export type {DataPoint} from './SamplesGraph';
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ interface DragState {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
interface Props {
|
|
39
|
+
loading?: boolean;
|
|
38
40
|
cpus: LabelSet[];
|
|
39
41
|
data: DataPoint[][];
|
|
40
42
|
selectedTimeframe?: {
|
|
@@ -47,33 +49,45 @@ interface Props {
|
|
|
47
49
|
stepMs: number;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
const STRIP_HEIGHT = 24;
|
|
53
|
+
const LABEL_ROW_HEIGHT = 16; // text-xs label row above each strip
|
|
54
|
+
const GAP = 4; // gap-1 between flex children
|
|
55
|
+
const MAX_VISIBLE_STRIPS = 20;
|
|
56
|
+
const LOADING_STRIP_COUNT = 8;
|
|
57
|
+
|
|
58
|
+
const generateMockStripData = (
|
|
59
|
+
bounds: NumberDuo
|
|
60
|
+
): {cpus: LabelSet[]; data: DataPoint[][]; stepMs: number} => {
|
|
61
|
+
const stepMs = Math.max(Math.floor((bounds[1] - bounds[0]) / 240), 100);
|
|
62
|
+
const cpus: LabelSet[] = Array.from({length: LOADING_STRIP_COUNT}, (_, i) => ({
|
|
63
|
+
labels: [{name: 'cpu', value: String(i)}],
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
let seed = 42;
|
|
67
|
+
const data = cpus.map(() => {
|
|
68
|
+
const points: DataPoint[] = [];
|
|
69
|
+
for (let ts = bounds[0]; ts < bounds[1]; ts += stepMs) {
|
|
70
|
+
seed = (seed * 16807 + 11) % 2147483647;
|
|
71
|
+
const value = (seed % 80) + 10;
|
|
72
|
+
seed = (seed * 16807 + 11) % 2147483647;
|
|
73
|
+
const sampleCount = (seed % 50) + 1;
|
|
74
|
+
points.push({timestamp: ts, value, sampleCount});
|
|
63
75
|
}
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
str += '}';
|
|
76
|
+
return points;
|
|
77
|
+
});
|
|
68
78
|
|
|
69
|
-
return
|
|
79
|
+
return {cpus, data, stepMs};
|
|
70
80
|
};
|
|
71
81
|
|
|
72
|
-
const STRIP_HEIGHT = 24;
|
|
73
|
-
const MAX_VISIBLE_STRIPS = 20;
|
|
74
|
-
|
|
75
82
|
const getTimelineGuideHeight = (cpusCount: number, collapsedCount: number): number => {
|
|
76
|
-
|
|
83
|
+
const expandedCount = cpusCount - collapsedCount;
|
|
84
|
+
// Each expanded strip: label row + graph height
|
|
85
|
+
// Each collapsed strip: min-h-5 (20px)
|
|
86
|
+
// Gaps between strips (gap-1 = 4px)
|
|
87
|
+
const expandedTotal = expandedCount * (LABEL_ROW_HEIGHT + STRIP_HEIGHT);
|
|
88
|
+
const collapsedTotal = collapsedCount * 20; // min-h-5
|
|
89
|
+
const gaps = cpusCount * GAP + 20; // timeline header
|
|
90
|
+
return expandedTotal + collapsedTotal + gaps;
|
|
77
91
|
};
|
|
78
92
|
|
|
79
93
|
const stickyPx = 0;
|
|
@@ -81,7 +95,7 @@ const stickyPx = 0;
|
|
|
81
95
|
const SamplesGraphContainer = ({
|
|
82
96
|
isSelected,
|
|
83
97
|
isCollapsed,
|
|
84
|
-
|
|
98
|
+
label: labelStr,
|
|
85
99
|
width,
|
|
86
100
|
onToggleCollapse,
|
|
87
101
|
data,
|
|
@@ -94,10 +108,11 @@ const SamplesGraphContainer = ({
|
|
|
94
108
|
stripIndex,
|
|
95
109
|
isAnyDragActive,
|
|
96
110
|
timeBounds,
|
|
111
|
+
loading,
|
|
97
112
|
}: {
|
|
98
113
|
isSelected: boolean;
|
|
99
114
|
isCollapsed: boolean;
|
|
100
|
-
|
|
115
|
+
label: string;
|
|
101
116
|
width: number | undefined;
|
|
102
117
|
onToggleCollapse: () => void;
|
|
103
118
|
data: DataPoint[];
|
|
@@ -110,9 +125,8 @@ const SamplesGraphContainer = ({
|
|
|
110
125
|
stripIndex: number;
|
|
111
126
|
isAnyDragActive: boolean;
|
|
112
127
|
timeBounds: NumberDuo;
|
|
128
|
+
loading?: boolean;
|
|
113
129
|
}): JSX.Element => {
|
|
114
|
-
const labelStr = labelSetToString(cpu);
|
|
115
|
-
|
|
116
130
|
const {isIntersecting, ref} = useIntersectionObserver({
|
|
117
131
|
rootMargin: `${stickyPx}px 0px 0px 0px`,
|
|
118
132
|
});
|
|
@@ -133,14 +147,17 @@ const SamplesGraphContainer = ({
|
|
|
133
147
|
ref={ref}
|
|
134
148
|
>
|
|
135
149
|
<div
|
|
136
|
-
className="text-xs
|
|
137
|
-
|
|
138
|
-
zIndex: 15,
|
|
139
|
-
}}
|
|
140
|
-
onClick={onToggleCollapse}
|
|
150
|
+
className="text-xs flex gap-[2px] items-center px-1 cursor-pointer text-gray-600 dark:text-gray-400"
|
|
151
|
+
onClick={loading === true ? undefined : onToggleCollapse}
|
|
141
152
|
>
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
{loading === true ? (
|
|
154
|
+
<div className="h-3 w-24 rounded bg-gray-200 dark:bg-gray-700 mb-1" />
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<Icon icon={isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow'} className="shrink-0" />
|
|
158
|
+
{labelStr}
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
144
161
|
</div>
|
|
145
162
|
{!isCollapsed ? (
|
|
146
163
|
<SamplesGraph
|
|
@@ -162,6 +179,7 @@ const SamplesGraphContainer = ({
|
|
|
162
179
|
};
|
|
163
180
|
|
|
164
181
|
export const SamplesStrip = ({
|
|
182
|
+
loading,
|
|
165
183
|
cpus,
|
|
166
184
|
data,
|
|
167
185
|
selectedTimeframe,
|
|
@@ -170,6 +188,18 @@ export const SamplesStrip = ({
|
|
|
170
188
|
bounds,
|
|
171
189
|
stepMs,
|
|
172
190
|
}: Props): JSX.Element => {
|
|
191
|
+
const {isDarkMode} = useParcaContext();
|
|
192
|
+
const effectiveLoading = loading === true;
|
|
193
|
+
|
|
194
|
+
// When loading, use mock data to render a pixel-perfect skeleton
|
|
195
|
+
const mockData = useMemo(
|
|
196
|
+
() => (effectiveLoading && bounds[0] !== bounds[1] ? generateMockStripData(bounds) : null),
|
|
197
|
+
[effectiveLoading, bounds]
|
|
198
|
+
);
|
|
199
|
+
const effectiveCpus = mockData?.cpus ?? cpus;
|
|
200
|
+
const effectiveData = mockData?.data ?? data;
|
|
201
|
+
const effectiveStepMs = mockData?.stepMs ?? stepMs;
|
|
202
|
+
|
|
173
203
|
const [collapsedLabels, setCollapsedLabels] = useState<Set<string>>(new Set());
|
|
174
204
|
const [showAll, setShowAll] = useState(false);
|
|
175
205
|
const [dragState, setDragState] = useState<DragState | undefined>(undefined);
|
|
@@ -177,15 +207,19 @@ export const SamplesStrip = ({
|
|
|
177
207
|
|
|
178
208
|
const isDragging = dragState !== undefined;
|
|
179
209
|
|
|
180
|
-
|
|
210
|
+
const {compare, keyOrder} = useMemo(
|
|
211
|
+
() => createLabelSetComparator(effectiveCpus),
|
|
212
|
+
[effectiveCpus]
|
|
213
|
+
);
|
|
214
|
+
|
|
181
215
|
const sortedItems = useMemo(() => {
|
|
182
|
-
const items =
|
|
216
|
+
const items = effectiveCpus.map((cpu, i) => ({
|
|
183
217
|
cpu,
|
|
184
|
-
data:
|
|
185
|
-
label: labelSetToString(cpu),
|
|
218
|
+
data: effectiveData[i],
|
|
219
|
+
label: labelSetToString(cpu, keyOrder),
|
|
186
220
|
}));
|
|
187
|
-
return items.sort((a, b) => a.
|
|
188
|
-
}, [
|
|
221
|
+
return items.sort((a, b) => compare(a.cpu, b.cpu));
|
|
222
|
+
}, [effectiveCpus, effectiveData, compare, keyOrder]);
|
|
189
223
|
|
|
190
224
|
const hasMore = useMemo(() => sortedItems.length > MAX_VISIBLE_STRIPS, [sortedItems]);
|
|
191
225
|
const visibleItems = useMemo(
|
|
@@ -194,8 +228,11 @@ export const SamplesStrip = ({
|
|
|
194
228
|
);
|
|
195
229
|
|
|
196
230
|
// Deterministic color: hash the label string so the same label always gets the same color
|
|
197
|
-
// regardless of render order.
|
|
231
|
+
// regardless of render order. When loading, use muted gray.
|
|
198
232
|
const color = useMemo(() => {
|
|
233
|
+
if (effectiveLoading) {
|
|
234
|
+
return (_label: string): string => (isDarkMode ? '#374151' : '#d1d5db');
|
|
235
|
+
}
|
|
199
236
|
const palette = d3.schemeObservable10;
|
|
200
237
|
const hashStr = (s: string): number => {
|
|
201
238
|
let h = 0;
|
|
@@ -205,7 +242,7 @@ export const SamplesStrip = ({
|
|
|
205
242
|
return Math.abs(h);
|
|
206
243
|
};
|
|
207
244
|
return (label: string): string => palette[hashStr(label) % palette.length];
|
|
208
|
-
}, []);
|
|
245
|
+
}, [effectiveLoading, isDarkMode]);
|
|
209
246
|
|
|
210
247
|
const handleDragStart = (stripIndex: number, startX: number): void => {
|
|
211
248
|
setDragState({stripIndex, startX, currentX: startX});
|
|
@@ -246,7 +283,7 @@ export const SamplesStrip = ({
|
|
|
246
283
|
setDragState(undefined);
|
|
247
284
|
};
|
|
248
285
|
|
|
249
|
-
if (
|
|
286
|
+
if (!effectiveLoading && effectiveData.length === 0) {
|
|
250
287
|
return (
|
|
251
288
|
<span className="flex justify-center my-10">
|
|
252
289
|
There is no data matching your filter criteria, please try changing the filter.
|
|
@@ -257,11 +294,14 @@ export const SamplesStrip = ({
|
|
|
257
294
|
return (
|
|
258
295
|
<div
|
|
259
296
|
ref={containerRef}
|
|
260
|
-
className={cx('flex flex-col gap-1 relative my-0', {
|
|
297
|
+
className={cx('flex flex-col gap-1 relative my-0', {
|
|
298
|
+
'cursor-ew-resize': isDragging,
|
|
299
|
+
'animate-pulse pointer-events-none': effectiveLoading,
|
|
300
|
+
})}
|
|
261
301
|
style={{width: width ?? '100%'}}
|
|
262
|
-
onMouseMove={handleMouseMove}
|
|
263
|
-
onMouseUp={handleMouseUp}
|
|
264
|
-
onMouseLeave={handleMouseLeave}
|
|
302
|
+
onMouseMove={effectiveLoading ? undefined : handleMouseMove}
|
|
303
|
+
onMouseUp={effectiveLoading ? undefined : handleMouseUp}
|
|
304
|
+
onMouseLeave={effectiveLoading ? undefined : handleMouseLeave}
|
|
265
305
|
>
|
|
266
306
|
<TimelineGuide
|
|
267
307
|
bounds={[BigInt(0), BigInt(bounds[1] - bounds[0])]}
|
|
@@ -280,7 +320,7 @@ export const SamplesStrip = ({
|
|
|
280
320
|
<SamplesGraphContainer
|
|
281
321
|
isSelected={isSelected}
|
|
282
322
|
isCollapsed={isCollapsed}
|
|
283
|
-
|
|
323
|
+
label={item.label}
|
|
284
324
|
width={width}
|
|
285
325
|
data={item.data}
|
|
286
326
|
onToggleCollapse={() => {
|
|
@@ -297,12 +337,13 @@ export const SamplesStrip = ({
|
|
|
297
337
|
onSelectedTimeframe(item.cpu, newBounds);
|
|
298
338
|
}}
|
|
299
339
|
color={color}
|
|
300
|
-
stepMs={
|
|
340
|
+
stepMs={effectiveStepMs}
|
|
301
341
|
onDragStart={handleDragStart}
|
|
302
342
|
dragState={dragState}
|
|
303
343
|
stripIndex={i}
|
|
304
344
|
isAnyDragActive={isDragging}
|
|
305
345
|
timeBounds={bounds}
|
|
346
|
+
loading={effectiveLoading}
|
|
306
347
|
key={item.label}
|
|
307
348
|
/>
|
|
308
349
|
);
|
|
@@ -0,0 +1,73 @@
|
|
|
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 {describe, expect, it} from 'vitest';
|
|
15
|
+
|
|
16
|
+
import {LabelSet} from '@parca/client';
|
|
17
|
+
|
|
18
|
+
import {createLabelSetComparator, labelSetToString} from './labelSetUtils';
|
|
19
|
+
|
|
20
|
+
const ls = (labels: Record<string, string>): LabelSet => ({
|
|
21
|
+
labels: Object.entries(labels).map(([name, value]) => ({name, value})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('createLabelSetComparator', () => {
|
|
25
|
+
it('sorts numeric cpu values numerically', () => {
|
|
26
|
+
const sets = [ls({cpu: '10'}), ls({cpu: '2'}), ls({cpu: '1'})];
|
|
27
|
+
const {compare} = createLabelSetComparator(sets);
|
|
28
|
+
const sorted = [...sets].sort(compare);
|
|
29
|
+
expect(sorted.map(s => s.labels[0].value)).toEqual(['1', '2', '10']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('sorts text labels lexicographically', () => {
|
|
33
|
+
const sets = [ls({node: 'charlie'}), ls({node: 'alpha'}), ls({node: 'bravo'})];
|
|
34
|
+
const {compare} = createLabelSetComparator(sets);
|
|
35
|
+
const sorted = [...sets].sort(compare);
|
|
36
|
+
expect(sorted.map(s => s.labels[0].value)).toEqual(['alpha', 'bravo', 'charlie']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sorts text labels before numeric labels', () => {
|
|
40
|
+
const sets = [ls({node: 'b', cpu: '1'}), ls({node: 'a', cpu: '2'}), ls({node: 'a', cpu: '1'})];
|
|
41
|
+
const {compare} = createLabelSetComparator(sets);
|
|
42
|
+
const sorted = [...sets].sort(compare);
|
|
43
|
+
expect(sorted.map(s => [s.labels[0].value, s.labels[1].value])).toEqual([
|
|
44
|
+
['a', '1'],
|
|
45
|
+
['a', '2'],
|
|
46
|
+
['b', '1'],
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('treats key as text if any value is non-numeric', () => {
|
|
51
|
+
const sets = [ls({cpu: '10'}), ls({cpu: '2'}), ls({cpu: 'all'})];
|
|
52
|
+
const {compare} = createLabelSetComparator(sets);
|
|
53
|
+
const sorted = [...sets].sort(compare);
|
|
54
|
+
// Lexicographic: '10' < '2' < 'all'
|
|
55
|
+
expect(sorted.map(s => s.labels[0].value)).toEqual(['10', '2', 'all']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('labelSetToString', () => {
|
|
60
|
+
it('formats labels in key order: text first, numeric last', () => {
|
|
61
|
+
const sets = [ls({cpu: '0', node: 'alpha'}), ls({cpu: '1', node: 'beta'})];
|
|
62
|
+
const {keyOrder} = createLabelSetComparator(sets);
|
|
63
|
+
expect(labelSetToString(sets[0], keyOrder)).toBe('{node: alpha, cpu: 0}');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('formats without keyOrder using original label order', () => {
|
|
67
|
+
expect(labelSetToString(ls({cpu: '0', node: 'alpha'}))).toBe('{cpu: 0, node: alpha}');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns {} for undefined', () => {
|
|
71
|
+
expect(labelSetToString(undefined)).toBe('{}');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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 {LabelSet} from '@parca/client';
|
|
15
|
+
|
|
16
|
+
// Determine which label keys have all-numeric values across every label set.
|
|
17
|
+
const getNumericKeys = (labelSets: LabelSet[]): Set<string> => {
|
|
18
|
+
const numericKeys = new Set<string>();
|
|
19
|
+
if (labelSets.length === 0) return numericKeys;
|
|
20
|
+
|
|
21
|
+
const keyCandidates = new Set(labelSets[0].labels.map(l => l.name));
|
|
22
|
+
for (const key of keyCandidates) {
|
|
23
|
+
const allNumeric = labelSets.every(ls => {
|
|
24
|
+
const label = ls.labels.find(l => l.name === key);
|
|
25
|
+
return label != null && label.value !== '' && !isNaN(Number(label.value));
|
|
26
|
+
});
|
|
27
|
+
if (allNumeric) numericKeys.add(key);
|
|
28
|
+
}
|
|
29
|
+
return numericKeys;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Get key order: text keys first (sorted), then numeric keys (sorted).
|
|
33
|
+
const getSortedKeys = (labelSets: LabelSet[], numericKeys: Set<string>): string[] => {
|
|
34
|
+
const allKeys = new Set<string>();
|
|
35
|
+
for (const ls of labelSets) {
|
|
36
|
+
for (const l of ls.labels) allKeys.add(l.name);
|
|
37
|
+
}
|
|
38
|
+
return [...allKeys]
|
|
39
|
+
.filter(k => !numericKeys.has(k))
|
|
40
|
+
.sort()
|
|
41
|
+
.concat([...numericKeys].sort());
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Format a LabelSet as a string with keys ordered: text first, then numeric.
|
|
45
|
+
export const labelSetToString = (labelSet: LabelSet | undefined, keyOrder?: string[]): string => {
|
|
46
|
+
if (labelSet === undefined) return '{}';
|
|
47
|
+
|
|
48
|
+
const labels =
|
|
49
|
+
keyOrder != null
|
|
50
|
+
? keyOrder
|
|
51
|
+
.map(key => labelSet.labels.find(l => l.name === key))
|
|
52
|
+
.filter((l): l is {name: string; value: string} => l != null)
|
|
53
|
+
: labelSet.labels;
|
|
54
|
+
|
|
55
|
+
if (labels.length === 0) return '{}';
|
|
56
|
+
|
|
57
|
+
return '{' + labels.map(l => `${l.name}: ${l.value}`).join(', ') + '}';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Build a comparator for LabelSets: text keys first (for grouping), then numeric keys.
|
|
61
|
+
// Also returns the key order so labelSetToString can use the same ordering.
|
|
62
|
+
export const createLabelSetComparator = (
|
|
63
|
+
labelSets: LabelSet[]
|
|
64
|
+
): {compare: (a: LabelSet, b: LabelSet) => number; keyOrder: string[]} => {
|
|
65
|
+
const numericKeys = getNumericKeys(labelSets);
|
|
66
|
+
const keyOrder = getSortedKeys(labelSets, numericKeys);
|
|
67
|
+
|
|
68
|
+
const compare = (a: LabelSet, b: LabelSet): number => {
|
|
69
|
+
const aMap = new Map(a.labels.map(l => [l.name, l.value]));
|
|
70
|
+
const bMap = new Map(b.labels.map(l => [l.name, l.value]));
|
|
71
|
+
for (const key of keyOrder) {
|
|
72
|
+
const aVal = aMap.get(key) ?? '';
|
|
73
|
+
const bVal = bMap.get(key) ?? '';
|
|
74
|
+
if (numericKeys.has(key)) {
|
|
75
|
+
const diff = Number(aVal) - Number(bVal);
|
|
76
|
+
if (diff !== 0) return diff;
|
|
77
|
+
} else {
|
|
78
|
+
const cmp = aVal.localeCompare(bVal);
|
|
79
|
+
if (cmp !== 0) return cmp;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {compare, keyOrder};
|
|
86
|
+
};
|
|
@@ -14,19 +14,13 @@
|
|
|
14
14
|
import {useEffect, useMemo, useRef} from 'react';
|
|
15
15
|
|
|
16
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';
|
|
17
|
+
import {useURLState, useURLStateCustom, type OptionsCustom} from '@parca/components';
|
|
24
18
|
import {Matcher, MatcherTypes, ProfileType, Query} from '@parca/parser';
|
|
25
|
-
import {TimeUnits,
|
|
19
|
+
import {TimeUnits, formatDate, formatDuration} from '@parca/utilities';
|
|
26
20
|
|
|
27
21
|
import ProfileFlameGraph, {validateFlameChartQuery} from '../ProfileFlameGraph';
|
|
28
22
|
import {boundsFromProfileSource} from '../ProfileFlameGraph/FlameGraphArrow/utils';
|
|
29
|
-
import {MergedProfileSource, ProfileSource} from '../ProfileSource';
|
|
23
|
+
import {MergedProfileSource, ProfileSource, timeFormat} from '../ProfileSource';
|
|
30
24
|
import type {SamplesData} from '../ProfileView/types/visualization';
|
|
31
25
|
import {useQuery} from '../useQuery';
|
|
32
26
|
import {NumberDuo} from '../utils';
|
|
@@ -82,7 +76,6 @@ interface ProfileFlameChartProps {
|
|
|
82
76
|
isHalfScreen: boolean;
|
|
83
77
|
metadataMappingFiles?: string[];
|
|
84
78
|
metadataLoading?: boolean;
|
|
85
|
-
onSwitchToOneMinute?: () => void;
|
|
86
79
|
}
|
|
87
80
|
|
|
88
81
|
// Helper to create a filtered profile source with narrowed time bounds
|
|
@@ -125,9 +118,7 @@ export const ProfileFlameChart = ({
|
|
|
125
118
|
isHalfScreen,
|
|
126
119
|
metadataMappingFiles,
|
|
127
120
|
metadataLoading,
|
|
128
|
-
onSwitchToOneMinute,
|
|
129
121
|
}: ProfileFlameChartProps): JSX.Element => {
|
|
130
|
-
const {loader} = useParcaContext();
|
|
131
122
|
const zoomControlsRef = useRef<HTMLDivElement>(null);
|
|
132
123
|
|
|
133
124
|
const [selectedTimeframe, setSelectedTimeframe] = useURLStateCustom<
|
|
@@ -211,27 +202,9 @@ export const ProfileFlameChart = ({
|
|
|
211
202
|
return {cpus, data, stepMs};
|
|
212
203
|
}, [samplesData?.series, samplesData?.stepMs]);
|
|
213
204
|
|
|
214
|
-
const {isValid, isNonDelta
|
|
215
|
-
profileSource as MergedProfileSource
|
|
216
|
-
);
|
|
205
|
+
const {isValid, isNonDelta} = validateFlameChartQuery(profileSource as MergedProfileSource);
|
|
217
206
|
|
|
218
207
|
if (!isValid) {
|
|
219
|
-
if (isDurationTooLong) {
|
|
220
|
-
return (
|
|
221
|
-
<div className="flex flex-col justify-center items-center p-10 text-center gap-4 text-sm">
|
|
222
|
-
<span>
|
|
223
|
-
Flame chart is unavailable for queries longer than one minute. Try reducing the time
|
|
224
|
-
range to one minute or selecting a point in the metrics graph.
|
|
225
|
-
</span>
|
|
226
|
-
{onSwitchToOneMinute != null && (
|
|
227
|
-
<Button variant="primary" onClick={onSwitchToOneMinute}>
|
|
228
|
-
Switch to last 1 minute
|
|
229
|
-
</Button>
|
|
230
|
-
)}
|
|
231
|
-
</div>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
208
|
const message = isNonDelta
|
|
236
209
|
? 'To use the Flame chart, please switch to a Delta profile.'
|
|
237
210
|
: 'Flame chart is unavailable for this query.';
|
|
@@ -242,12 +215,10 @@ export const ProfileFlameChart = ({
|
|
|
242
215
|
|
|
243
216
|
const hasDimension = (flamechartDimension ?? []).length > 0;
|
|
244
217
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return <>{loader}</>;
|
|
248
|
-
}
|
|
218
|
+
const isStripsLoading =
|
|
219
|
+
metadataLoading === true || !hasDimension || samplesData?.loading === true;
|
|
249
220
|
|
|
250
|
-
if (!hasDimension) {
|
|
221
|
+
if (!hasDimension && metadataLoading !== true) {
|
|
251
222
|
return (
|
|
252
223
|
<div className="flex justify-center items-center py-10 text-gray-500 dark:text-gray-400 text-sm">
|
|
253
224
|
Select a label in the "Samples group by" dropdown above to view the samples
|
|
@@ -256,16 +227,12 @@ export const ProfileFlameChart = ({
|
|
|
256
227
|
);
|
|
257
228
|
}
|
|
258
229
|
|
|
259
|
-
if (samplesData?.loading === true) {
|
|
260
|
-
return <>{loader}</>;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
230
|
return (
|
|
264
231
|
<div>
|
|
265
|
-
{
|
|
266
|
-
{stripsData.cpus.length > 0 && stripsData.data.length > 0 && (
|
|
232
|
+
{(isStripsLoading || (stripsData.cpus.length > 0 && stripsData.data.length > 0)) && (
|
|
267
233
|
<div className="mb-2">
|
|
268
234
|
<SamplesStrip
|
|
235
|
+
loading={isStripsLoading}
|
|
269
236
|
cpus={stripsData.cpus}
|
|
270
237
|
data={stripsData.data}
|
|
271
238
|
selectedTimeframe={selectedTimeframe}
|
|
@@ -284,13 +251,17 @@ export const ProfileFlameChart = ({
|
|
|
284
251
|
.map(l => `${l.name} = ${l.value}`)
|
|
285
252
|
.join(', ');
|
|
286
253
|
const durationMs = selectedTimeframe.bounds[1] - selectedTimeframe.bounds[0];
|
|
287
|
-
const duration =
|
|
254
|
+
const duration =
|
|
255
|
+
durationMs < 5000
|
|
256
|
+
? `${(durationMs / 1000).toFixed(1)}s`
|
|
257
|
+
: formatDuration({[TimeUnits.Milliseconds]: durationMs});
|
|
258
|
+
const fmt = durationMs < 5000 ? "yyyy-MM-dd HH:mm:ss.SSS '(UTC)'" : timeFormat();
|
|
288
259
|
return (
|
|
289
260
|
<div className="flex items-center justify-between px-2 py-1">
|
|
290
261
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
291
262
|
Samples matching {labels} over {duration} from{' '}
|
|
292
|
-
{
|
|
293
|
-
{
|
|
263
|
+
{formatDate(new Date(selectedTimeframe.bounds[0]), fmt)} to{' '}
|
|
264
|
+
{formatDate(new Date(selectedTimeframe.bounds[1]), fmt)}
|
|
294
265
|
</div>
|
|
295
266
|
<div ref={zoomControlsRef} />
|
|
296
267
|
</div>
|