Skip to content

Commit 701acd8

Browse files
authored
ui: Improve label name and values UI (#5989)
* ui: Add user preferences for color and alignment in flamegraph Introduces new user preferences for 'Color by' and 'Align function names' in the flamegraph visualization. Updates state management and toolbar components to support these preferences/ * Add refetch and update caching for label values Introduces a refetch method to label value hooks and context, allowing manual refresh of label values. Reduces staleTime from 5 minutes to 30 seconds and enables keepPreviousData for smoother UI updates. Refactors ViewMatchers label value fetching for better performance and maintainability. Adds cacheTime option to useGrpcQuery for more flexible caching. * Add manual refresh for label values in matchers UI Introduces a 'Refresh results' button to manually refetch label values in both SuggestionsList and Select components, improving user experience when label values may be stale or missing. Reduces label names and values cache time to 2 minutes and ensures UI state resets appropriately after search. Also improves handling of label name/value options to ensure selected values are always visible, and disables 'Show more/less' while editing. * fix linter * remove stale time for label requests
1 parent 23dd823 commit 701acd8

File tree

15 files changed

+513
-149
lines changed

15 files changed

+513
-149
lines changed

ui/packages/shared/hooks/src/useUserPreference/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ export const USER_PREFERENCES: {[key: string]: UserPreferenceDetails} = {
6464
description:
6565
'When enabled, this option adds a new visualization type, allowing you to view your data in an Flame chart format.',
6666
},
67+
COLOR_BY: {
68+
name: 'Color by',
69+
key: 'COLOR_BY',
70+
type: 'string',
71+
default: 'binary',
72+
description:
73+
'Choose how to color the flame graph nodes. Color by binary shows different colors for different binaries, while color by filename shows different colors for different source files.',
74+
},
75+
ALIGN_FUNCTION_NAME: {
76+
name: 'Align function names',
77+
key: 'ALIGN_FUNCTION_NAME',
78+
type: 'string',
79+
default: 'left',
80+
description:
81+
'Choose how to align function names in the flame graph. Left alignment shows function names starting from the left edge, while right alignment shows them from the right edge.',
82+
},
6783
} as const;
6884

6985
export type UserPreference = keyof typeof USER_PREFERENCES;

ui/packages/shared/profile/src/MatchersInput/SuggestionsList.tsx

Lines changed: 119 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import {Fragment, useCallback, useEffect, useState} from 'react';
1515

1616
import {Transition} from '@headlessui/react';
17+
import {Icon} from '@iconify/react';
18+
import cx from 'classnames';
1719
import {usePopper} from 'react-popper';
1820

1921
import {useParcaContext} from '@parca/components';
@@ -53,6 +55,7 @@ interface Props {
5355
isLabelNamesLoading: boolean;
5456
isLabelValuesLoading: boolean;
5557
shouldTrimPrefix: boolean;
58+
refetchLabelValues: () => void;
5659
}
5760

5861
const LoadingSpinner = (): JSX.Element => {
@@ -75,13 +78,26 @@ const SuggestionsList = ({
7578
isLabelNamesLoading,
7679
isLabelValuesLoading,
7780
shouldTrimPrefix = false,
81+
refetchLabelValues,
7882
}: Props): JSX.Element => {
7983
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
8084
const {styles, attributes} = usePopper(inputRef, popperElement, {
8185
placement: 'bottom-start',
8286
});
8387
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState<number>(-1);
8488
const [showSuggest, setShowSuggest] = useState(true);
89+
const [isRefetching, setIsRefetching] = useState(false);
90+
91+
const handleRefetch = useCallback(async () => {
92+
if (isRefetching) return;
93+
94+
setIsRefetching(true);
95+
try {
96+
await refetchLabelValues();
97+
} finally {
98+
setIsRefetching(false);
99+
}
100+
}, [refetchLabelValues, isRefetching]);
85101

86102
const suggestionsLength =
87103
suggestions.literals.length + suggestions.labelNames.length + suggestions.labelValues.length;
@@ -231,43 +247,106 @@ const SuggestionsList = ({
231247
style={{width: inputRef?.offsetWidth}}
232248
className="absolute z-10 mt-1 max-h-[400px] overflow-auto rounded-md bg-gray-50 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-900 sm:text-sm"
233249
>
234-
{isLabelNamesLoading ? (
235-
<LoadingSpinner />
236-
) : (
237-
<>
238-
{suggestions.labelNames.map((l, i) => (
239-
<SuggestionItem
240-
isHighlighted={highlightedSuggestionIndex === i}
241-
onHighlight={() => setHighlightedSuggestionIndex(i)}
242-
onApplySuggestion={() => applySuggestionWithIndex(i)}
243-
onResetHighlight={() => resetHighlight()}
244-
value={transformLabelsForSuggestions(l.value, shouldTrimPrefix)}
245-
key={transformLabelsForSuggestions(l.value, shouldTrimPrefix)}
246-
/>
247-
))}
248-
</>
249-
)}
250-
251-
{suggestions.literals.map((l, i) => (
252-
<SuggestionItem
253-
isHighlighted={highlightedSuggestionIndex === i + suggestions.labelNames.length}
254-
onHighlight={() =>
255-
setHighlightedSuggestionIndex(i + suggestions.labelNames.length)
256-
}
257-
onApplySuggestion={() =>
258-
applySuggestionWithIndex(i + suggestions.labelNames.length)
259-
}
260-
onResetHighlight={() => resetHighlight()}
261-
value={l.value}
262-
key={l.value}
263-
/>
264-
))}
265-
266-
{isLabelValuesLoading ? (
267-
<LoadingSpinner />
268-
) : (
269-
<>
270-
{suggestions.labelValues.map((l, i) => (
250+
<div className="relative pb-12">
251+
{isLabelNamesLoading ? (
252+
<LoadingSpinner />
253+
) : (
254+
<>
255+
{suggestions.labelNames.map((l, i) => (
256+
<SuggestionItem
257+
isHighlighted={highlightedSuggestionIndex === i}
258+
onHighlight={() => setHighlightedSuggestionIndex(i)}
259+
onApplySuggestion={() => applySuggestionWithIndex(i)}
260+
onResetHighlight={() => resetHighlight()}
261+
value={transformLabelsForSuggestions(l.value, shouldTrimPrefix)}
262+
key={transformLabelsForSuggestions(l.value, shouldTrimPrefix)}
263+
/>
264+
))}
265+
</>
266+
)}
267+
268+
{suggestions.literals.map((l, i) => (
269+
<SuggestionItem
270+
isHighlighted={highlightedSuggestionIndex === i + suggestions.labelNames.length}
271+
onHighlight={() =>
272+
setHighlightedSuggestionIndex(i + suggestions.labelNames.length)
273+
}
274+
onApplySuggestion={() =>
275+
applySuggestionWithIndex(i + suggestions.labelNames.length)
276+
}
277+
onResetHighlight={() => resetHighlight()}
278+
value={l.value}
279+
key={l.value}
280+
/>
281+
))}
282+
283+
{isLabelValuesLoading ? (
284+
<LoadingSpinner />
285+
) : suggestions.labelNames.length === 0 && suggestions.literals.length === 0 ? (
286+
<>
287+
{suggestions.labelValues.length === 0 ? (
288+
<div
289+
className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center"
290+
data-testid="suggestions-no-results"
291+
>
292+
No label values found
293+
</div>
294+
) : (
295+
suggestions.labelValues.map((l, i) => (
296+
<SuggestionItem
297+
isHighlighted={
298+
highlightedSuggestionIndex ===
299+
i + suggestions.labelNames.length + suggestions.literals.length
300+
}
301+
onHighlight={() =>
302+
setHighlightedSuggestionIndex(
303+
i + suggestions.labelNames.length + suggestions.literals.length
304+
)
305+
}
306+
onApplySuggestion={() =>
307+
applySuggestionWithIndex(
308+
i + suggestions.labelNames.length + suggestions.literals.length
309+
)
310+
}
311+
onResetHighlight={() => resetHighlight()}
312+
value={l.value}
313+
key={l.value}
314+
/>
315+
))
316+
)}
317+
<div className="absolute w-full flex items-center justify-center bottom-0 px-3 py-2 bg-gray-50 dark:bg-gray-800">
318+
<button
319+
onClick={e => {
320+
e.preventDefault();
321+
e.stopPropagation();
322+
void handleRefetch();
323+
}}
324+
disabled={isRefetching}
325+
className={cx(
326+
'p-1 flex items-center gap-1 rounded-full transition-all duration-200 w-auto justify-center',
327+
isRefetching
328+
? 'cursor-wait opacity-50'
329+
: 'hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer'
330+
)}
331+
title="Refresh label values"
332+
type="button"
333+
data-testid="suggestions-refresh-button"
334+
>
335+
<Icon
336+
icon="system-uicons:reset"
337+
className={cx(
338+
'w-3 h-3 text-gray-500 dark:text-gray-400',
339+
isRefetching && 'animate-spin'
340+
)}
341+
/>
342+
<span className="text-xs text-gray-500 dark:text-gray-400">
343+
Refresh results
344+
</span>
345+
</button>
346+
</div>
347+
</>
348+
) : (
349+
suggestions.labelValues.map((l, i) => (
271350
<SuggestionItem
272351
isHighlighted={
273352
highlightedSuggestionIndex ===
@@ -287,9 +366,9 @@ const SuggestionsList = ({
287366
value={l.value}
288367
key={l.value}
289368
/>
290-
))}
291-
</>
292-
)}
369+
))
370+
)}
371+
</div>
293372
</div>
294373
</Transition>
295374
</div>

ui/packages/shared/profile/src/MatchersInput/index.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ export const useLabelNames = (
7373
},
7474
options: {
7575
enabled: profileType !== undefined && profileType !== '',
76-
staleTime: 1000 * 60 * 5, // 5 minutes
7776
keepPreviousData: false,
7877
},
7978
});
@@ -87,6 +86,7 @@ interface UseLabelValues {
8786
error?: Error;
8887
};
8988
loading: boolean;
89+
refetch: () => void;
9090
}
9191

9292
export const useLabelValues = (
@@ -98,7 +98,7 @@ export const useLabelValues = (
9898
): UseLabelValues => {
9999
const metadata = useGrpcMetadata();
100100

101-
const {data, isLoading, error} = useGrpcQuery<string[]>({
101+
const {data, isLoading, error, refetch} = useGrpcQuery<string[]>({
102102
key: ['labelValues', labelName, profileType, start, end],
103103
queryFn: async signal => {
104104
const request: ValuesRequest = {labelName, match: [], profileType};
@@ -115,12 +115,17 @@ export const useLabelValues = (
115115
profileType !== '' &&
116116
labelName !== undefined &&
117117
labelName !== '',
118-
staleTime: 1000 * 60 * 5, // 5 minutes
119118
keepPreviousData: false,
120119
},
121120
});
122121

123-
return {result: {response: data ?? [], error: error as Error}, loading: isLoading};
122+
return {
123+
result: {response: data ?? [], error: error as Error},
124+
loading: isLoading,
125+
refetch: () => {
126+
void refetch();
127+
},
128+
};
124129
};
125130

126131
export const useFetchUtilizationLabelValues = (
@@ -130,8 +135,10 @@ export const useFetchUtilizationLabelValues = (
130135
const {data} = useQuery({
131136
queryKey: ['utilizationLabelValues', labelName],
132137
queryFn: async () => {
133-
return await utilizationLabels?.utilizationFetchLabelValues?.(labelName);
138+
const result = await utilizationLabels?.utilizationFetchLabelValues?.(labelName);
139+
return result ?? [];
134140
},
141+
enabled: utilizationLabels?.utilizationFetchLabelValues != null && labelName !== '',
135142
});
136143

137144
return data ?? [];
@@ -155,6 +162,7 @@ const MatchersInput = ({
155162
currentLabelName,
156163
setCurrentLabelName,
157164
shouldHandlePrefixes,
165+
refetchLabelValues,
158166
} = useLabels();
159167

160168
const value = currentQuery.matchersString();
@@ -327,6 +335,7 @@ const MatchersInput = ({
327335
focusedInput={focusedInput}
328336
isLabelValuesLoading={isLabelValuesLoading && lastCompleted.type === 'literal'}
329337
shouldTrimPrefix={shouldHandlePrefixes}
338+
refetchLabelValues={refetchLabelValues}
330339
/>
331340
</div>
332341
);

ui/packages/shared/profile/src/ProfileSelector/QueryControls.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14+
import {useState} from 'react';
15+
1416
import {Switch} from '@headlessui/react';
1517
import {RpcError} from '@protobuf-ts/runtime-rpc';
1618
import Select, {type SelectInstance} from 'react-select';
@@ -93,6 +95,7 @@ export function QueryControls({
9395
profileTypesError,
9496
}: QueryControlsProps): JSX.Element {
9597
const {timezone} = useParcaContext();
98+
const [searchExecutedTimestamp, setSearchExecutedTimestamp] = useState<number>(0);
9699

97100
return (
98101
<div
@@ -180,7 +183,6 @@ export function QueryControls({
180183
/>
181184
) : (
182185
<SimpleMatchers
183-
key={query.toString()}
184186
setMatchersString={setMatchersString}
185187
runQuery={setQueryExpression}
186188
currentQuery={query}
@@ -189,6 +191,7 @@ export function QueryControls({
189191
queryClient={queryClient}
190192
start={timeRangeSelection.getFromMs()}
191193
end={timeRangeSelection.getToMs()}
194+
searchExecutedTimestamp={searchExecutedTimestamp}
192195
/>
193196
)}
194197
</div>
@@ -261,6 +264,7 @@ export function QueryControls({
261264
disabled={searchDisabled}
262265
onClick={(e: React.MouseEvent<HTMLElement>) => {
263266
e.preventDefault();
267+
setSearchExecutedTimestamp(Date.now());
264268
setQueryExpression(true);
265269
}}
266270
id="h-matcher-search-button"

ui/packages/shared/profile/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ interface MultiLevelDropdownProps {
187187
groupBy: string[];
188188
toggleGroupBy: (key: string) => void;
189189
isTableVizOnly: boolean;
190+
alignFunctionName: string;
191+
setAlignFunctionName: (align: string) => void;
192+
colorBy: string;
193+
setColorBy: (colorBy: string) => void;
190194
}
191195

192196
const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
@@ -195,14 +199,17 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
195199
groupBy,
196200
toggleGroupBy,
197201
isTableVizOnly,
202+
alignFunctionName,
203+
setAlignFunctionName,
204+
colorBy,
205+
setColorBy,
198206
}) => {
199207
const dropdownRef = useRef<HTMLDivElement>(null);
200208
const [shouldOpenLeft, setShouldOpenLeft] = useState(false);
201209
const [storeSortBy] = useURLState('sort_by', {
202210
defaultValue: FIELD_FUNCTION_NAME,
203211
});
204212
const [colorStackLegend, setStoreColorStackLegend] = useURLState('color_stack_legend');
205-
const [colorBy, setColorBy] = useURLState('color_by');
206213
const [hiddenBinaries, setHiddenBinaries] = useURLState('hidden_binaries', {
207214
defaultValue: [],
208215
alwaysReturnArray: true,
@@ -212,9 +219,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
212219
USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
213220
);
214221
const isColorStackLegendEnabled = colorStackLegend === 'true';
215-
216-
const [alignFunctionName, setAlignFunctionName] = useURLState('align_function_name');
217-
const isLeftAligned = alignFunctionName === 'left' || alignFunctionName === undefined;
222+
const isLeftAligned = alignFunctionName === 'left';
218223

219224
// By default, we want delta profiles (CPU) to be relatively compared.
220225
// For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely.
@@ -421,7 +426,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
421426
closeDropdown={close}
422427
activeValueForSortBy={storeSortBy as string}
423428
activeValueForColorBy={
424-
colorBy === undefined || colorBy === '' ? 'binary' : (colorBy as string)
429+
colorBy === undefined || colorBy === '' ? 'binary' : colorBy
425430
}
426431
activeValuesForLevel={groupBy}
427432
renderAsDiv={item.renderAsDiv}

0 commit comments

Comments
 (0)