// 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. /* eslint-disable react-hooks/set-state-in-effect */ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Menu} from '@headlessui/react'; import {Icon} from '@iconify/react'; import cx from 'classnames'; import {useQueryState} from 'nuqs'; import {USER_PREFERENCES, useUserPreference} from '@parca/hooks'; import {ProfileType} from '@parca/parser'; import { FIELD_FUNCTION_FILE_NAME, FIELD_FUNCTION_NAME, FIELD_LOCATION_ADDRESS, FIELD_MAPPING_FILE, } from '../../../ProfileFlameGraph/FlameGraphArrow'; import {boolParam, hiddenBinariesParser, stringParam} from '../../../hooks/urlParsers'; import {useProfileViewContext} from '../../context/ProfileViewContext'; import SwitchMenuItem from './SwitchMenuItem'; interface MenuItemType { label: string; items?: MenuItemType[]; onclick?: () => void; hide?: boolean; id?: string; disabled?: boolean; active?: boolean; value?: string; icon?: string; customSubmenu?: React.ReactNode; renderAsDiv?: boolean; } type MenuItemProps = MenuItemType & { onSelect: (path: string[]) => void; path?: string[]; closeDropdown: () => void; isNested?: boolean; activeValueForSortBy?: string; activeValueForColorBy?: string; activeValuesForLevel?: string[]; icon?: string; }; const MenuItem: React.FC = ({ label, items, onclick, onSelect, path = [], id, closeDropdown, isNested = false, activeValueForSortBy, activeValueForColorBy, activeValuesForLevel, value, disabled = false, icon, customSubmenu, renderAsDiv = false, }) => { 'use no memo'; const menuRef = useRef(null); const [shouldOpenLeft, setShouldOpenLeft] = useState(false); useEffect(() => { if (items !== undefined && menuRef.current !== null) { const rect = menuRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const menuWidth = 224; // w-56 = 14rem = 224px const spaceOnRight = viewportWidth - rect.right; const spaceOnLeft = rect.left; // Open to the left if there's not enough space on the right but enough on the left setShouldOpenLeft(spaceOnRight < menuWidth && spaceOnLeft >= menuWidth); } }, [items]); let isActive = false; if (isNested) { if (activeValueForSortBy !== undefined && value === activeValueForSortBy) { isActive = true; } if (activeValueForColorBy !== undefined && value === activeValueForColorBy) { isActive = true; } if (activeValuesForLevel?.includes(value ?? '') ?? false) { isActive = true; } } const handleSelect = (): void => { if (items === undefined) { if (onclick !== undefined) { onclick(); closeDropdown(); } else { onSelect([...path, label]); closeDropdown(); } } }; return (
{({close}) => ( <> {customSubmenu !== undefined ? ( customSubmenu ) : (
{icon !== undefined && } {label}
{isActive && }
)} {items !== undefined && ( )}
{items !== undefined && ( {items?.map((item, index) => ( { onSelect([...path, ...selectedPath]); close(); closeDropdown(); }} path={[...path, label]} closeDropdown={closeDropdown} isNested={true} activeValueForSortBy={activeValueForSortBy} activeValueForColorBy={activeValueForColorBy} activeValuesForLevel={activeValuesForLevel} /> ))} )} )}
); }; interface MultiLevelDropdownProps { onSelect: (path: string[]) => void; profileType?: ProfileType; groupBy: string[]; toggleGroupBy: (key: string) => void; isTableVizOnly: boolean; alignFunctionName: string; setAlignFunctionName: (align: string) => void; colorBy: string; setColorBy: (colorBy: string) => void; } const MultiLevelDropdown: React.FC = ({ onSelect, profileType, groupBy, toggleGroupBy, isTableVizOnly, alignFunctionName, setAlignFunctionName, colorBy, setColorBy, }) => { const dropdownRef = useRef(null); const [shouldOpenLeft, setShouldOpenLeft] = useState(false); const [storeSortBy] = useQueryState('sort_by', stringParam.withDefault(FIELD_FUNCTION_NAME)); const [colorStackLegend, setStoreColorStackLegend] = useQueryState( 'color_stack_legend', stringParam ); const [hiddenBinaries, setHiddenBinaries] = useQueryState( 'hidden_binaries', hiddenBinariesParser ); const {compareMode} = useProfileViewContext(); const [colorProfileName] = useUserPreference( USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key ); const isColorStackLegendEnabled = colorStackLegend === 'true'; const isLeftAligned = alignFunctionName === 'left'; // By default, we want delta profiles (CPU) to be relatively compared. // For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely. const compareAbsoluteDefault = profileType?.delta === false; const [compareAbsolute, setCompareAbsolute] = useQueryState('compare_absolute', boolParam); const isCompareAbsolute = compareAbsolute ?? compareAbsoluteDefault; useEffect(() => { const checkOverflow = (): void => { if (dropdownRef.current !== null) { const rect = dropdownRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const menuWidth = isTableVizOnly ? 256 : 320; // w-64 = 256px, w-80 = 320px const spaceOnRight = viewportWidth - rect.right; const spaceOnLeft = rect.left; setShouldOpenLeft(spaceOnRight < menuWidth && spaceOnLeft >= menuWidth); } }; checkOverflow(); window.addEventListener('resize', checkOverflow); return () => window.removeEventListener('resize', checkOverflow); }, [isTableVizOnly]); const handleBinaryToggle = (index: number): void => { const updatedBinaries = [...hiddenBinaries]; updatedBinaries.splice(index, 1); void setHiddenBinaries(updatedBinaries); }; const setColorStackLegend = useCallback( (value: string): void => { void setStoreColorStackLegend(value); }, [setStoreColorStackLegend] ); const resetLegend = (): void => { void setHiddenBinaries([]); }; const menuItems: MenuItemType[] = [ { label: 'Levels', id: 'h-levels-filter', items: [ { label: 'Function', onclick: () => toggleGroupBy(FIELD_FUNCTION_NAME), value: FIELD_FUNCTION_NAME, }, { label: 'Binary', onclick: () => toggleGroupBy(FIELD_MAPPING_FILE), value: FIELD_MAPPING_FILE, }, { label: 'Code', onclick: () => toggleGroupBy(FIELD_FUNCTION_FILE_NAME), value: FIELD_FUNCTION_FILE_NAME, }, { label: 'Address', onclick: () => toggleGroupBy(FIELD_LOCATION_ADDRESS), value: FIELD_LOCATION_ADDRESS, }, ], hide: !!isTableVizOnly, icon: 'heroicons-solid:bars-3', }, { label: 'Color by', id: 'h-color-by-filter', items: [ { label: 'Binary', onclick: () => setColorBy('binary'), value: 'binary', }, { label: 'Filename', onclick: () => setColorBy('filename'), value: 'filename', }, ], hide: false, icon: 'carbon:color-palette', }, { label: isColorStackLegendEnabled ? 'Hide legend' : 'Show legend', onclick: () => setColorStackLegend(isColorStackLegendEnabled ? 'false' : 'true'), hide: compareMode || colorProfileName === 'default', id: 'h-show-legend-button', icon: isColorStackLegendEnabled ? 'ph:eye-closed' : 'ph:eye', }, { label: isLeftAligned ? 'Right-align function names' : 'Left-align function names', onclick: () => setAlignFunctionName(isLeftAligned ? 'right' : 'left'), id: 'h-align-function-names', hide: !!isTableVizOnly, icon: isLeftAligned ? 'ic:outline-align-horizontal-right' : 'ic:outline-align-horizontal-left', }, { label: isCompareAbsolute ? 'Compare Relative' : 'Compare Absolute', onclick: () => void setCompareAbsolute(!isCompareAbsolute), hide: !compareMode, icon: isCompareAbsolute ? 'fluent-mdl2:compare' : 'fluent-mdl2:compare-uneven', }, { label: 'Dock Graph MetaInfo', hide: !!isTableVizOnly, customSubmenu: ( ), renderAsDiv: true, }, { label: 'Highlight similar stacks when hovering over a node', hide: !!isTableVizOnly, customSubmenu: ( ), renderAsDiv: true, }, { label: 'Reset Legend', hide: hiddenBinaries.length === 0, onclick: () => resetLegend(), id: 'h-reset-legend-button', icon: 'system-uicons:reset', }, { label: 'Hidden Binaries', id: 'h-hidden-binaries', items: hiddenBinaries.map((binary, index) => ({ label: binary, customSubmenu: (
handleBinaryToggle(index)} /> {binary}
), })), hide: hiddenBinaries.length === 0, icon: 'ph:eye-closed', }, ]; return (
{({open, close}) => ( <>
Preferences
{open && ( {menuItems .filter(item => item.hide !== undefined && !item.hide) .map((item, index) => ( ))} )} )}
); }; export default MultiLevelDropdown;