diff --git a/ui/packages/shared/hooks/src/useUserPreference/index.ts b/ui/packages/shared/hooks/src/useUserPreference/index.ts index 754d05fd144..50c9ec22fd8 100644 --- a/ui/packages/shared/hooks/src/useUserPreference/index.ts +++ b/ui/packages/shared/hooks/src/useUserPreference/index.ts @@ -80,6 +80,14 @@ export const USER_PREFERENCES: {[key: string]: UserPreferenceDetails} = { description: '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.', }, + FLAMECHART_AUTO_CONFIG_POPOVER_DISMISSED: { + name: 'Flamechart auto-configuration explanation dismissed', + key: 'FLAMECHART_AUTO_CONFIG_POPOVER_DISMISSED', + type: 'boolean', + default: false, + description: + 'When enabled, the flamechart auto-configuration explanation popover will not be shown.', + }, } as const; export type UserPreference = keyof typeof USER_PREFERENCES; diff --git a/ui/packages/shared/profile/src/FlameChartAutoConfigPopover/index.tsx b/ui/packages/shared/profile/src/FlameChartAutoConfigPopover/index.tsx new file mode 100644 index 00000000000..f59b8efed42 --- /dev/null +++ b/ui/packages/shared/profile/src/FlameChartAutoConfigPopover/index.tsx @@ -0,0 +1,128 @@ +// Copyright 2022 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Fragment, useState} from 'react'; + +import {Transition} from '@headlessui/react'; +import {Icon} from '@iconify/react'; +import {usePopper} from 'react-popper'; + +import {useParcaContext} from '@parca/components'; + +import {OPTIMAL_LABELS} from '../hooks/useAutoConfigureFlamechart'; + +interface FlameChartAutoConfigPopoverProps { + isOpen: boolean; + onDismiss: () => void; + anchorRef: React.RefObject; + currentSumByLabels: string[]; +} + +export const FlameChartAutoConfigPopover = ({ + isOpen, + onDismiss, + anchorRef, + currentSumByLabels, +}: FlameChartAutoConfigPopoverProps): JSX.Element => { + const [popperElement, setPopperElement] = useState(null); + const {flamechartHelpText} = useParcaContext(); + + const autoConfiguredLabels = OPTIMAL_LABELS.filter(label => !currentSumByLabels.includes(label)); + + const {styles, attributes} = usePopper(anchorRef.current, popperElement, { + placement: 'bottom-start', + strategy: 'absolute', + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 8], + }, + }, + { + name: 'flip', + options: { + fallbackPlacements: ['top-start', 'bottom-end', 'top-end'], + }, + }, + ], + }); + + if (!isOpen) return <>; + + return ( + +
+ {/* Close Button */} + + + {/* Icon */} +
+ {/* Content */} +
+

+ Flamechart Settings Auto-Configured +

+

+ We've automatically adjusted your settings for optimal flamechart viewing: +

+
    +
  • Time range reduced to 1 minute
  • + {autoConfiguredLabels.length > 0 && ( +
  • + Added sum-by labels:{' '} + {autoConfiguredLabels.map((label, index) => ( + + + {label} + + {index < autoConfiguredLabels.length - 1 ? ', ' : ''} + + ))} +
  • + )} +
+ {flamechartHelpText != null && ( +

+ {flamechartHelpText} +

+ )} +
+
+
+
+ ); +}; diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx index 0e87cadd5af..83bc01bc421 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx @@ -19,6 +19,7 @@ import {useMeasure} from 'react-use'; import {FlamegraphArrow} from '@parca/client'; import { + Button, FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext, @@ -34,6 +35,7 @@ import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext'; import {useProfileMetadata} from '../ProfileView/hooks/useProfileMetadata'; import {useVisualizationState} from '../ProfileView/hooks/useVisualizationState'; import {TimelineGuide} from '../TimelineGuide'; +import {useAutoConfigureFlamechart} from '../hooks/useAutoConfigureFlamechart'; import {FlameGraphArrow} from './FlameGraphArrow'; import {CurrentPathFrame, boundsFromProfileSource} from './FlameGraphArrow/utils'; @@ -72,6 +74,12 @@ const ErrorContent = ({errorMessage}: {errorMessage: string | ReactNode}): JSX.E ); }; +const AutoConfigButton = ({onClick}: {onClick: () => void}): JSX.Element => ( + +); + export const validateFlameChartQuery = ( profileSource: MergedProfileSource ): {isValid: boolean; isNonDelta: boolean; isDurationTooLong: boolean} => { @@ -109,6 +117,8 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({ const [flameChartRef, {height: flameChartHeight}] = useMeasure(); const {colorBy, setColorBy} = useVisualizationState(); + const handleAutoConfigureFlameChart = useAutoConfigureFlamechart(); + // Create local state for paths when in sandwich view to avoid URL updates const [localCurPathArrow, setLocalCurPathArrow] = useState([]); @@ -232,13 +242,14 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({ return ( +
Flame chart is unavailable for queries longer than one minute. Please select a point in the metrics graph to continue. + {!compareMode && } {flamechartHelpText ?? null} - +
} /> ); @@ -246,10 +257,11 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({ return ( +
The Flame chart is not available for this query. + {!compareMode && } {flamechartHelpText ?? null} - +
} /> ); @@ -326,6 +338,8 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({ mappingsList, filenamesList, colorBy, + handleAutoConfigureFlameChart, + compareMode, ]); useEffect(() => { @@ -375,7 +389,11 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({ - {capitalizeOnlyFirstLetter(error.message)} + + {error.message != null + ? capitalizeOnlyFirstLetter(error.message) + : 'An error occurred'} + {isFlameChart ? flamechartHelpText ?? null : null} } diff --git a/ui/packages/shared/profile/src/ProfileSelector/index.tsx b/ui/packages/shared/profile/src/ProfileSelector/index.tsx index 92d63b574a3..b4b9f7b01cb 100644 --- a/ui/packages/shared/profile/src/ProfileSelector/index.tsx +++ b/ui/packages/shared/profile/src/ProfileSelector/index.tsx @@ -146,6 +146,16 @@ const ProfileSelector = ({ queryBrowserMode === 'advanced' ); + // Sync local timeRangeSelection state when draftSelection changes from external URL updates + useEffect(() => { + const newTimeRange = DateTimeRange.fromRangeKey( + draftSelection.timeSelection, + draftSelection.from, + draftSelection.to + ); + setTimeRangeSelection(newTimeRange); + }, [draftSelection.timeSelection, draftSelection.from, draftSelection.to]); + // Handler to update draft when time range changes const handleTimeRangeChange = useCallback( (range: DateTimeRange) => { @@ -251,7 +261,7 @@ const ProfileSelector = ({ profileTypesData, setProfileName, setQueryExpression, - querySelection, + querySelection: draftSelection, navigateTo, loading: sumByLoading, }); @@ -325,10 +335,10 @@ const ProfileSelector = ({ showMetricsGraph={showMetricsGraph} setDisplayHideMetricsGraphButton={setDisplayHideMetricsGraphButton} heightStyle={heightStyle} - querySelection={querySelection} + querySelection={draftSelection} profileSelection={profileSelection} comparing={comparing} - sumBy={querySelection.sumBy} + sumBy={draftSelection.sumBy ?? []} defaultSumByLoading={sumByLoading} queryClient={queryClient} queryExpressionString={queryExpressionString} diff --git a/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts b/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts index c085840c900..023bd7df824 100644 --- a/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts +++ b/ui/packages/shared/profile/src/ProfileSelector/useAutoQuerySelector.ts @@ -14,6 +14,7 @@ import {useEffect, useRef} from 'react'; import {ProfileTypesResponse} from '@parca/client'; +import {useURLState} from '@parca/components'; import {selectAutoQuery, setAutoQuery, useAppDispatch, useAppSelector} from '@parca/store'; import {type NavigateFunction} from '@parca/utilities'; @@ -42,12 +43,11 @@ export const useAutoQuerySelector = ({ }: Props): void => { const autoQuery = useAppSelector(selectAutoQuery); const dispatch = useAppDispatch(); - const queryParams = new URLSearchParams(location.search); - const compareA = queryParams.get('compare_a'); - const compareB = queryParams.get('compare_b'); + const [compareA] = useURLState('compare_a'); + const [compareB] = useURLState('compare_b'); const comparing = compareA === 'true' || compareB === 'true'; - const expressionA = queryParams.get('expression_a'); - const expressionB = queryParams.get('expression_b'); + const [expressionA] = useURLState('expression_a'); + const [expressionB] = useURLState('expression_b'); // Track if we've already set up compare mode to prevent infinite loops const hasSetupCompareMode = useRef(false); diff --git a/ui/packages/shared/profile/src/QueryControls/index.tsx b/ui/packages/shared/profile/src/QueryControls/index.tsx index cc66510039c..3ed415972a2 100644 --- a/ui/packages/shared/profile/src/QueryControls/index.tsx +++ b/ui/packages/shared/profile/src/QueryControls/index.tsx @@ -11,17 +11,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {Switch} from '@headlessui/react'; import {RpcError} from '@protobuf-ts/runtime-rpc'; import {type SelectInstance} from 'react-select'; import {ProfileTypesResponse, QueryServiceClient} from '@parca/client'; -import {Button, DateTimeRange, DateTimeRangePicker, useParcaContext} from '@parca/components'; +import { + Button, + DateTimeRange, + DateTimeRangePicker, + useParcaContext, + useURLState, +} from '@parca/components'; +import {USER_PREFERENCES, useUserPreference} from '@parca/hooks'; import {ProfileType, Query} from '@parca/parser'; import {TEST_IDS, testId} from '@parca/test-utils'; +import {FlameChartAutoConfigPopover} from '../FlameChartAutoConfigPopover'; import MatchersInput from '../MatchersInput'; import {QuerySelection} from '../ProfileSelector'; import ProfileTypeSelector from '../ProfileTypeSelector'; @@ -108,6 +116,21 @@ export function QueryControls({ const defaultQueryBrowserRef = useRef(null); const actualQueryBrowserRef = queryBrowserRef ?? defaultQueryBrowserRef; const [searchExecutedTimestamp, setSearchExecutedTimestamp] = useState(0); + const sumByContainerRef = useRef(null); + const [dashboardItems] = useURLState('dashboard_items', { + alwaysReturnArray: true, + }); + + const [showPopover, setShowPopover] = useState(false); + + const [autoConfigTs] = useURLState('autoconfig_ts_a'); + const [lastSeenTs, setLastSeenTs] = useState(); + const [configuredLabels, setConfiguredLabels] = useState([]); + const [isPopoverDismissed, setIsPopoverDismissed] = useUserPreference( + USER_PREFERENCES.FLAMECHART_AUTO_CONFIG_POPOVER_DISMISSED.key + ); + + const isFlameChart = dashboardItems?.includes('flamechart') ?? false; const {refetch: refetchLabelNames} = useLabelNames( queryClient, @@ -116,6 +139,25 @@ export function QueryControls({ timeRangeSelection.getToMs() ); + // Detect auto-config timestamp changes and show popover + useEffect(() => { + if ( + autoConfigTs !== undefined && + autoConfigTs !== '' && + autoConfigTs !== lastSeenTs && + !isPopoverDismissed + ) { + setShowPopover(true); + setConfiguredLabels(sumBySelection ?? []); + setLastSeenTs(autoConfigTs as string); + } + }, [autoConfigTs, lastSeenTs, sumBySelection, isPopoverDismissed]); + + const handleDismissPopover = (): void => { + setShowPopover(false); + setIsPopoverDismissed(true); + }; + return (
{showSumBySelector && ( -
+
)} + {isFlameChart && showPopover && ( + + )} void) => { + const batchUpdates = useURLStateBatch(); + const [existingSumByA] = useURLState('sum_by_a'); + const [_, setSumByA] = useURLState('sum_by_a'); + + const [, setSelectionA] = useURLState('selection_a'); + const [expressionA] = useURLState('expression_a'); + const [, setTimeSelectionA] = useURLState('time_selection_a'); + const [, setFromA] = useURLState('from_a'); + const [, setToA] = useURLState('to_a'); + const [, setMergeFromA] = useURLState('merge_from_a'); + const [, setMergeToA] = useURLState('merge_to_a'); + const [, setAutoConfigTsA] = useURLState('autoconfig_ts_a'); + + const handleAutoConfigureFlameChart = useCallback(() => { + const toMs = Date.now(); + const fromMs = toMs - 60000; + + const existing = + existingSumByA !== undefined && existingSumByA !== '' + ? existingSumByA.split(',').filter(Boolean) + : []; + const toAdd = OPTIMAL_LABELS.filter(label => !existing.includes(label)); + const mergedLabels = [...existing, ...toAdd]; + + batchUpdates(() => { + setFromA(fromMs.toString()); + setToA(toMs.toString()); + setTimeSelectionA('relative:minute|1'); + setMergeFromA((BigInt(fromMs) * 1_000_000n).toString()); + setMergeToA((BigInt(toMs) * 1_000_000n).toString()); + setSumByA(mergedLabels.join(',')); + + // Signal to QueryControls that auto-config was triggered + setAutoConfigTsA(Date.now().toString()); + + // Update selection to trigger ProfileSelector refresh + if (expressionA !== undefined && expressionA !== '') { + setSelectionA(expressionA); + } + }); + }, [ + batchUpdates, + existingSumByA, + setFromA, + setToA, + setTimeSelectionA, + setMergeFromA, + setMergeToA, + setSumByA, + setAutoConfigTsA, + expressionA, + setSelectionA, + ]); + + return handleAutoConfigureFlameChart; +}; diff --git a/ui/packages/shared/profile/src/useSumBy.ts b/ui/packages/shared/profile/src/useSumBy.ts index ee1f5d92e64..e4da306dc3f 100644 --- a/ui/packages/shared/profile/src/useSumBy.ts +++ b/ui/packages/shared/profile/src/useSumBy.ts @@ -267,7 +267,7 @@ export const useDraftSumBy = ( return { draftSumBy: draftSumBy ?? defaultSumBy ?? DEFAULT_EMPTY_SUM_BY, - setDraftSumBy: setDraftSumBy, + setDraftSumBy, isDraftSumByLoading: isLoading, }; };