@parca/profile 0.19.133 → 0.19.134

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 (54) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
  3. package/dist/ProfileExplorer/ProfileExplorerSingle.js +3 -9
  4. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
  5. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
  6. package/dist/ProfileFlameChart/SamplesStrips/index.js +61 -39
  7. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts +7 -0
  8. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.d.ts.map +1 -0
  9. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.js +79 -0
  10. package/dist/ProfileFlameChart/index.d.ts +1 -2
  11. package/dist/ProfileFlameChart/index.d.ts.map +1 -1
  12. package/dist/ProfileFlameChart/index.js +14 -21
  13. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts +3 -0
  14. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.d.ts.map +1 -1
  15. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +89 -24
  16. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -1
  17. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +2 -1
  18. package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
  19. package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +2 -2
  20. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts +4 -0
  21. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.d.ts.map +1 -1
  22. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +51 -10
  23. package/dist/ProfileFlameGraph/index.d.ts +0 -1
  24. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  25. package/dist/ProfileFlameGraph/index.js +3 -8
  26. package/dist/ProfileView/components/DashboardItems/index.d.ts +1 -2
  27. package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
  28. package/dist/ProfileView/components/DashboardItems/index.js +2 -2
  29. package/dist/ProfileView/index.d.ts +1 -1
  30. package/dist/ProfileView/index.d.ts.map +1 -1
  31. package/dist/ProfileView/index.js +1 -2
  32. package/dist/ProfileView/types/visualization.d.ts +0 -1
  33. package/dist/ProfileView/types/visualization.d.ts.map +1 -1
  34. package/dist/ProfileViewWithData.d.ts +1 -2
  35. package/dist/ProfileViewWithData.d.ts.map +1 -1
  36. package/dist/ProfileViewWithData.js +2 -2
  37. package/dist/TimelineGuide/index.js +1 -1
  38. package/dist/styles.css +1 -1
  39. package/package.json +3 -3
  40. package/src/ProfileExplorer/ProfileExplorerSingle.tsx +3 -14
  41. package/src/ProfileFlameChart/SamplesStrips/index.tsx +90 -49
  42. package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.test.ts +73 -0
  43. package/src/ProfileFlameChart/SamplesStrips/labelSetUtils.ts +86 -0
  44. package/src/ProfileFlameChart/index.tsx +16 -45
  45. package/src/ProfileFlameGraph/FlameGraphArrow/MiniMap.tsx +119 -25
  46. package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +3 -1
  47. package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +5 -3
  48. package/src/ProfileFlameGraph/FlameGraphArrow/useZoom.ts +78 -17
  49. package/src/ProfileFlameGraph/index.tsx +4 -24
  50. package/src/ProfileView/components/DashboardItems/index.tsx +0 -3
  51. package/src/ProfileView/index.tsx +0 -2
  52. package/src/ProfileView/types/visualization.ts +0 -1
  53. package/src/ProfileViewWithData.tsx +0 -3
  54. 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
- export const labelSetToString = (labelSet?: LabelSet): string => {
51
- if (labelSet === undefined) {
52
- return '{}';
53
- }
54
-
55
- let str = '{';
56
-
57
- let isFirst = true;
58
- for (const label of labelSet.labels) {
59
- if (!isFirst) {
60
- str += ', ';
61
- } else {
62
- isFirst = false;
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
- str += `${label.name}: ${label.value}`;
65
- }
66
-
67
- str += '}';
76
+ return points;
77
+ });
68
78
 
69
- return str;
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
- return (STRIP_HEIGHT + 4) * (cpusCount - collapsedCount) + 20 * collapsedCount + 24 - 6;
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
- cpu,
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
- cpu: LabelSet;
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 absolute top-0 left-0 flex gap-[2px] items-center bg-white/50 dark:bg-black/50 px-1 rounded-sm cursor-pointer"
137
- style={{
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
- <Icon icon={isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow'} />
143
- {labelStr}
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
- // Sort cpus and data by label string for consistent ordering across reloads
210
+ const {compare, keyOrder} = useMemo(
211
+ () => createLabelSetComparator(effectiveCpus),
212
+ [effectiveCpus]
213
+ );
214
+
181
215
  const sortedItems = useMemo(() => {
182
- const items = cpus.map((cpu, i) => ({
216
+ const items = effectiveCpus.map((cpu, i) => ({
183
217
  cpu,
184
- data: data[i],
185
- label: labelSetToString(cpu),
218
+ data: effectiveData[i],
219
+ label: labelSetToString(cpu, keyOrder),
186
220
  }));
187
- return items.sort((a, b) => a.label.localeCompare(b.label));
188
- }, [cpus, data]);
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 (data.length === 0) {
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', {'cursor-ew-resize': isDragging})}
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
- cpu={item.cpu}
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={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, formatDateTimeDownToMS, formatDuration} from '@parca/utilities';
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, isDurationTooLong} = validateFlameChartQuery(
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
- // Show loader while metadata labels are loading (needed for dimension auto-selection)
246
- if (metadataLoading === true) {
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 &quot;Samples group by&quot; 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
- {/* Samples Strips - rendered above flamechart */}
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 = formatDuration({[TimeUnits.Milliseconds]: durationMs});
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
- {formatDateTimeDownToMS(selectedTimeframe.bounds[0])} to{' '}
293
- {formatDateTimeDownToMS(selectedTimeframe.bounds[1])}
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>