Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ui/packages/shared/hooks/src/useUserPreference/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,20 @@
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;

const useUserPreference = <T>(preferenceName: UserPreference): [T, (flag: T) => void] => {
const [flags, setFlags] = useLocalStorage<{[flag: string]: any}>(USER_PREFERENCES_KEY, {});

Check warning on line 96 in ui/packages/shared/hooks/src/useUserPreference/index.ts

View workflow job for this annotation

GitHub Actions / UI Test and Lint

Unexpected any. Specify a different type

const value: T = flags[preferenceName] ?? USER_PREFERENCES[preferenceName].default;
const setFlag = (flag: T): void => {
Expand Down
128 changes: 128 additions & 0 deletions ui/packages/shared/profile/src/FlameChartAutoConfigPopover/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
currentSumByLabels: string[];
}

export const FlameChartAutoConfigPopover = ({
isOpen,
onDismiss,
anchorRef,
currentSumByLabels,
}: FlameChartAutoConfigPopoverProps): JSX.Element => {
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(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 (
<Transition
show={isOpen}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="z-50 w-96 rounded-lg bg-blue-50 dark:bg-gray-900 border border-blue-200 dark:border-gray-700 shadow-lg p-4"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{/* Close Button */}
<button
onClick={onDismiss}
className="absolute top-4 right-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-200"
aria-label="Dismiss"
>
<Icon icon="mdi:close" width={20} height={20} />
</button>

{/* Icon */}
<div className="flex items-start gap-3">
{/* Content */}
<div className="flex-1 pr-6">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Flamechart Settings Auto-Configured
</h3>
<p className="text-sm text-gray-800 dark:text-gray-200 mb-3">
We&apos;ve automatically adjusted your settings for optimal flamechart viewing:
</p>
<ul className="text-sm text-gray-800 dark:text-gray-200 mb-3 list-disc list-inside space-y-1">
<li>Time range reduced to 1 minute</li>
{autoConfiguredLabels.length > 0 && (
<li>
Added sum-by labels:{' '}
{autoConfiguredLabels.map((label, index) => (
<span key={label}>
<code className="text-xs text-gray-200 dark:text-gray-800 bg-indigo-600 dark:bg-indigo-500 p-1 rounded">
{label}
</code>
{index < autoConfiguredLabels.length - 1 ? ', ' : ''}
</span>
))}
</li>
)}
</ul>
{flamechartHelpText != null && (
<p className="text-sm text-gray-800 dark:text-gray-200 [&_a]:underline">
{flamechartHelpText}
</p>
)}
</div>
</div>
</div>
</Transition>
);
};
28 changes: 23 additions & 5 deletions ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {useMeasure} from 'react-use';

import {FlamegraphArrow} from '@parca/client';
import {
Button,
FlameGraphSkeleton,
SandwichFlameGraphSkeleton,
useParcaContext,
Expand All @@ -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';

Expand Down Expand Up @@ -72,6 +74,12 @@ const ErrorContent = ({errorMessage}: {errorMessage: string | ReactNode}): JSX.E
);
};

const AutoConfigButton = ({onClick}: {onClick: () => void}): JSX.Element => (
<Button onClick={onClick} variant="secondary" className="my-2">
Auto-configure for optimal Flame Chart viewing
</Button>
);

export const validateFlameChartQuery = (
profileSource: MergedProfileSource
): {isValid: boolean; isNonDelta: boolean; isDurationTooLong: boolean} => {
Expand Down Expand Up @@ -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<CurrentPathFrame[]>([]);

Expand Down Expand Up @@ -232,24 +242,26 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({
return (
<ErrorContent
errorMessage={
<>
<div className="flex flex-col items-center">
<span>
Flame chart is unavailable for queries longer than one minute. Please select a
point in the metrics graph to continue.
</span>
{!compareMode && <AutoConfigButton onClick={handleAutoConfigureFlameChart} />}
{flamechartHelpText ?? null}
</>
</div>
}
/>
);
} else {
return (
<ErrorContent
errorMessage={
<>
<div className="flex flex-col items-center">
<span>The Flame chart is not available for this query.</span>
{!compareMode && <AutoConfigButton onClick={handleAutoConfigureFlameChart} />}
{flamechartHelpText ?? null}
</>
</div>
}
/>
);
Expand Down Expand Up @@ -326,6 +338,8 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({
mappingsList,
filenamesList,
colorBy,
handleAutoConfigureFlameChart,
compareMode,
]);

useEffect(() => {
Expand Down Expand Up @@ -375,7 +389,11 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({
<ErrorContent
errorMessage={
<>
<span>{capitalizeOnlyFirstLetter(error.message)}</span>
<span>
{error.message != null
? capitalizeOnlyFirstLetter(error.message)
: 'An error occurred'}
</span>
{isFlameChart ? flamechartHelpText ?? null : null}
</>
}
Expand Down
16 changes: 13 additions & 3 deletions ui/packages/shared/profile/src/ProfileSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -251,7 +261,7 @@ const ProfileSelector = ({
profileTypesData,
setProfileName,
setQueryExpression,
querySelection,
querySelection: draftSelection,
navigateTo,
loading: sumByLoading,
});
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>('compare_a');
const [compareB] = useURLState<string>('compare_b');
const comparing = compareA === 'true' || compareB === 'true';
const expressionA = queryParams.get('expression_a');
const expressionB = queryParams.get('expression_b');
const [expressionA] = useURLState<string>('expression_a');
const [expressionB] = useURLState<string>('expression_b');

// Track if we've already set up compare mode to prevent infinite loops
const hasSetupCompareMode = useRef(false);
Expand Down
56 changes: 53 additions & 3 deletions ui/packages/shared/profile/src/QueryControls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -108,6 +116,21 @@ export function QueryControls({
const defaultQueryBrowserRef = useRef<HTMLDivElement>(null);
const actualQueryBrowserRef = queryBrowserRef ?? defaultQueryBrowserRef;
const [searchExecutedTimestamp, setSearchExecutedTimestamp] = useState<number>(0);
const sumByContainerRef = useRef<HTMLDivElement>(null);
const [dashboardItems] = useURLState('dashboard_items', {
alwaysReturnArray: true,
});

const [showPopover, setShowPopover] = useState<boolean>(false);

const [autoConfigTs] = useURLState('autoconfig_ts_a');
const [lastSeenTs, setLastSeenTs] = useState<string>();
const [configuredLabels, setConfiguredLabels] = useState<string[]>([]);
const [isPopoverDismissed, setIsPopoverDismissed] = useUserPreference<boolean>(
USER_PREFERENCES.FLAMECHART_AUTO_CONFIG_POPOVER_DISMISSED.key
);

const isFlameChart = dashboardItems?.includes('flamechart') ?? false;

const {refetch: refetchLabelNames} = useLabelNames(
queryClient,
Expand All @@ -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 (
<div
className="flex w-full flex-wrap items-start gap-2"
Expand Down Expand Up @@ -199,7 +241,7 @@ export function QueryControls({
</div>

{showSumBySelector && (
<div {...testId(TEST_IDS.SUM_BY_CONTAINER)}>
<div ref={sumByContainerRef} {...testId(TEST_IDS.SUM_BY_CONTAINER)}>
<div className="mb-0.5 mt-1.5 flex items-center justify-between">
<label className="text-xs" {...testId(TEST_IDS.SUM_BY_LABEL)}>
Sum by
Expand Down Expand Up @@ -262,6 +304,14 @@ export function QueryControls({
/>
</div>
)}
{isFlameChart && showPopover && (
<FlameChartAutoConfigPopover
isOpen={showPopover}
onDismiss={handleDismissPopover}
anchorRef={sumByContainerRef}
currentSumByLabels={configuredLabels}
/>
)}

<DateTimeRangePicker
onRangeSelection={setTimeRangeSelection}
Expand Down
Loading
Loading