github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/Toolbar.tsx (about)

     1  import React, {
     2    ReactNode,
     3    RefObject,
     4    useState,
     5    useRef,
     6    useLayoutEffect,
     7    isValidElement,
     8    memo,
     9  } from 'react';
    10  import classNames from 'classnames/bind';
    11  import { faUndo } from '@fortawesome/free-solid-svg-icons/faUndo';
    12  import { faCompressAlt } from '@fortawesome/free-solid-svg-icons/faCompressAlt';
    13  import { faProjectDiagram } from '@fortawesome/free-solid-svg-icons/faProjectDiagram';
    14  import { faEllipsisV } from '@fortawesome/free-solid-svg-icons/faEllipsisV';
    15  import { Maybe } from 'true-myth';
    16  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
    17  import useResizeObserver from '@react-hook/resize-observer';
    18  // until ui is moved to its own package this should do it
    19  // eslint-disable-next-line import/no-extraneous-dependencies
    20  import Button from '@webapp/ui/Button';
    21  // eslint-disable-next-line import/no-extraneous-dependencies
    22  import { Tooltip } from '@pyroscope/webapp/javascript/ui/Tooltip';
    23  import { FitModes } from './fitMode/fitMode';
    24  import SharedQueryInput from './SharedQueryInput';
    25  import type { ViewTypes } from './FlameGraph/FlameGraphComponent/viewTypes';
    26  import type { FlamegraphRendererProps } from './FlameGraph/FlameGraphRenderer';
    27  import {
    28    TableIcon,
    29    TablePlusFlamegraphIcon,
    30    FlamegraphIcon,
    31    SandwichIcon,
    32    HeadFirstIcon,
    33    TailFirstIcon,
    34  } from './Icons';
    35  
    36  import styles from './Toolbar.module.scss';
    37  
    38  const cx = classNames.bind(styles);
    39  
    40  const DIVIDER_WIDTH = 5;
    41  const QUERY_INPUT_WIDTH = 175;
    42  const LEFT_MARGIN = 2;
    43  const RIGHT_MARGIN = 2;
    44  const TOOLBAR_SQUARE_WIDTH = 40 + LEFT_MARGIN + RIGHT_MARGIN;
    45  const MORE_BUTTON_WIDTH = 16;
    46  
    47  const calculateCollapsedItems = (
    48    clientWidth: number,
    49    collapsedItemsNumber: number,
    50    itemsW: number[]
    51  ) => {
    52    const availableToolbarItemsWidth =
    53      collapsedItemsNumber === 0
    54        ? clientWidth - QUERY_INPUT_WIDTH - 5
    55        : clientWidth - QUERY_INPUT_WIDTH - MORE_BUTTON_WIDTH - 5;
    56  
    57    let collapsedItems = 0;
    58    let visibleItemsWidth = 0;
    59    itemsW.reverse().forEach((v) => {
    60      visibleItemsWidth += v;
    61      if (availableToolbarItemsWidth <= visibleItemsWidth) {
    62        collapsedItems += 1;
    63      }
    64    });
    65  
    66    return collapsedItems;
    67  };
    68  
    69  const useMoreButton = (
    70    target: RefObject<HTMLDivElement>,
    71    toolbarItemsWidth: number[]
    72  ) => {
    73    const [isCollapsed, setCollapsedStatus] = useState(true);
    74    const [collapsedItemsNumber, setCollapsedItemsNumber] = useState(0);
    75  
    76    useLayoutEffect(() => {
    77      if (target.current) {
    78        const { width } = target.current.getBoundingClientRect();
    79        const collapsedItems = calculateCollapsedItems(
    80          width,
    81          collapsedItemsNumber,
    82          toolbarItemsWidth
    83        );
    84        setCollapsedItemsNumber(collapsedItems);
    85      }
    86    }, [target.current, toolbarItemsWidth]);
    87  
    88    const handleMoreClick = () => {
    89      setCollapsedStatus((v) => !v);
    90    };
    91  
    92    useResizeObserver(target, (entry: ResizeObserverEntry) => {
    93      const { width } = entry.target.getBoundingClientRect();
    94      const collapsedItems = calculateCollapsedItems(
    95        width,
    96        collapsedItemsNumber,
    97        toolbarItemsWidth
    98      );
    99  
   100      setCollapsedItemsNumber(collapsedItems);
   101      setCollapsedStatus(true);
   102    });
   103  
   104    return {
   105      isCollapsed,
   106      handleMoreClick,
   107      collapsedItemsNumber,
   108    };
   109  };
   110  
   111  export interface ProfileHeaderProps {
   112    view: ViewTypes;
   113    enableChangingDisplay?: boolean;
   114    flamegraphType: 'single' | 'double';
   115    handleSearchChange: (s: string) => void;
   116    highlightQuery: string;
   117    ExportData?: ReactNode;
   118  
   119    /** Whether the flamegraph is different from its original state */
   120    isFlamegraphDirty: boolean;
   121    reset: () => void;
   122  
   123    updateFitMode: (f: FitModes) => void;
   124    fitMode: FitModes;
   125    updateView: (s: ViewTypes) => void;
   126  
   127    /**
   128     * Refers to the node that has been selected in the flamegraph
   129     */
   130    selectedNode: Maybe<{ i: number; j: number }>;
   131    onFocusOnSubtree: (i: number, j: number) => void;
   132    sharedQuery?: FlamegraphRendererProps['sharedQuery'];
   133  }
   134  
   135  const Divider = () => <div className={styles.divider} />;
   136  
   137  type ToolbarItemType = {
   138    width: number;
   139    el: ReactNode;
   140  };
   141  
   142  const Toolbar = memo(
   143    ({
   144      view,
   145      handleSearchChange,
   146      highlightQuery,
   147      isFlamegraphDirty,
   148      reset,
   149      updateFitMode,
   150      fitMode,
   151      updateView,
   152      selectedNode,
   153      onFocusOnSubtree,
   154      flamegraphType,
   155      enableChangingDisplay = true,
   156      sharedQuery,
   157      ExportData,
   158    }: ProfileHeaderProps) => {
   159      const toolbarRef = useRef<HTMLDivElement>(null);
   160  
   161      const fitModeItem = {
   162        el: (
   163          <>
   164            <FitMode fitMode={fitMode} updateFitMode={updateFitMode} />
   165            <Divider />
   166          </>
   167        ),
   168        width: TOOLBAR_SQUARE_WIDTH * 2 + DIVIDER_WIDTH,
   169      };
   170      const resetItem = {
   171        el: <ResetView isFlamegraphDirty={isFlamegraphDirty} reset={reset} />,
   172        width: TOOLBAR_SQUARE_WIDTH,
   173      };
   174      const focusOnSubtree = {
   175        el: (
   176          <>
   177            <FocusOnSubtree
   178              selectedNode={selectedNode}
   179              onFocusOnSubtree={onFocusOnSubtree}
   180            />
   181            <Divider />
   182          </>
   183        ),
   184        width: TOOLBAR_SQUARE_WIDTH + DIVIDER_WIDTH,
   185      };
   186  
   187      const viewSectionItem = enableChangingDisplay
   188        ? {
   189            el: (
   190              <ViewSection
   191                flamegraphType={flamegraphType}
   192                view={view}
   193                updateView={updateView}
   194              />
   195            ),
   196            // sandwich view is hidden in diff view
   197            width: TOOLBAR_SQUARE_WIDTH * (flamegraphType === 'single' ? 5 : 3), // 1px is to display divider
   198          }
   199        : null;
   200      const exportDataItem = isValidElement(ExportData)
   201        ? {
   202            el: (
   203              <>
   204                <Divider />
   205                {ExportData}
   206              </>
   207            ),
   208            width: TOOLBAR_SQUARE_WIDTH + DIVIDER_WIDTH,
   209          }
   210        : null;
   211  
   212      const filteredToolbarItems = [
   213        fitModeItem,
   214        resetItem,
   215        focusOnSubtree,
   216        viewSectionItem,
   217        exportDataItem,
   218      ].filter((v) => v !== null) as ToolbarItemType[];
   219      const toolbarItemsWidth = filteredToolbarItems.reduce(
   220        (acc, v) => [...acc, v.width],
   221        [] as number[]
   222      );
   223  
   224      const { isCollapsed, collapsedItemsNumber, handleMoreClick } =
   225        useMoreButton(toolbarRef, toolbarItemsWidth);
   226  
   227      const toolbarFilteredItems = filteredToolbarItems.reduce(
   228        (acc, v, i) => {
   229          const isHiddenItem = i < collapsedItemsNumber;
   230  
   231          if (isHiddenItem) {
   232            acc.hidden.push(v);
   233          } else {
   234            acc.visible.push(v);
   235          }
   236  
   237          return acc;
   238        },
   239        { visible: [] as ToolbarItemType[], hidden: [] as ToolbarItemType[] }
   240      );
   241  
   242      return (
   243        <div role="toolbar" ref={toolbarRef}>
   244          <div className={styles.navbar}>
   245            <div>
   246              <SharedQueryInput
   247                width={QUERY_INPUT_WIDTH}
   248                onHighlightChange={handleSearchChange}
   249                highlightQuery={highlightQuery}
   250                sharedQuery={sharedQuery}
   251              />
   252            </div>
   253            <div>
   254              <div className={styles.itemsContainer}>
   255                {toolbarFilteredItems.visible.map((v, i) => (
   256                  // eslint-disable-next-line react/no-array-index-key
   257                  <div key={i} className={styles.item} style={{ width: v.width }}>
   258                    {v.el}
   259                  </div>
   260                ))}
   261                {collapsedItemsNumber !== 0 && (
   262                  <Tooltip placement="top" title="More">
   263                    <button
   264                      onClick={handleMoreClick}
   265                      className={cx({
   266                        [styles.moreButton]: true,
   267                        [styles.active]: !isCollapsed,
   268                      })}
   269                    >
   270                      <FontAwesomeIcon icon={faEllipsisV} />
   271                    </button>
   272                  </Tooltip>
   273                )}
   274              </div>
   275            </div>
   276            {!isCollapsed && (
   277              <div className={styles.navbarCollapsedItems}>
   278                {toolbarFilteredItems.hidden.map((v, i) => (
   279                  <div
   280                    // eslint-disable-next-line react/no-array-index-key
   281                    key={i}
   282                    className={styles.item}
   283                    style={{ width: v.width }}
   284                  >
   285                    {v.el}
   286                  </div>
   287                ))}
   288              </div>
   289            )}
   290          </div>
   291        </div>
   292      );
   293    }
   294  );
   295  
   296  function FocusOnSubtree({
   297    onFocusOnSubtree,
   298    selectedNode,
   299  }: {
   300    selectedNode: ProfileHeaderProps['selectedNode'];
   301    onFocusOnSubtree: ProfileHeaderProps['onFocusOnSubtree'];
   302  }) {
   303    const onClick = selectedNode.mapOr(
   304      () => {},
   305      (f) => {
   306        return () => onFocusOnSubtree(f.i, f.j);
   307      }
   308    );
   309  
   310    return (
   311      <Tooltip placement="top" title="Collapse nodes above">
   312        <div>
   313          <Button
   314            disabled={!selectedNode.isJust}
   315            onClick={onClick}
   316            className={styles.collapseNodeButton}
   317            aria-label="Collapse nodes above"
   318          >
   319            <FontAwesomeIcon icon={faCompressAlt} />
   320          </Button>
   321        </div>
   322      </Tooltip>
   323    );
   324  }
   325  
   326  function ResetView({
   327    isFlamegraphDirty,
   328    reset,
   329  }: {
   330    isFlamegraphDirty: ProfileHeaderProps['isFlamegraphDirty'];
   331    reset: ProfileHeaderProps['reset'];
   332  }) {
   333    return (
   334      <Tooltip placement="top" title="Reset View">
   335        <span>
   336          <Button
   337            id="reset"
   338            disabled={!isFlamegraphDirty}
   339            onClick={reset}
   340            className={styles.resetViewButton}
   341            aria-label="Reset View"
   342          >
   343            <FontAwesomeIcon icon={faUndo} />
   344          </Button>
   345        </span>
   346      </Tooltip>
   347    );
   348  }
   349  
   350  function FitMode({
   351    fitMode,
   352    updateFitMode,
   353  }: {
   354    fitMode: ProfileHeaderProps['fitMode'];
   355    updateFitMode: ProfileHeaderProps['updateFitMode'];
   356  }) {
   357    const isSelected = (a: FitModes) => fitMode === a;
   358  
   359    return (
   360      <>
   361        <Tooltip placement="top" title="Head first">
   362          <Button
   363            onClick={() => updateFitMode('HEAD')}
   364            className={cx({
   365              [styles.fitModeButton]: true,
   366              [styles.selected]: isSelected('HEAD'),
   367            })}
   368          >
   369            <HeadFirstIcon />
   370          </Button>
   371        </Tooltip>
   372        <Tooltip placement="top" title="Tail first">
   373          <Button
   374            onClick={() => updateFitMode('TAIL')}
   375            className={cx({
   376              [styles.fitModeButton]: true,
   377              [styles.selected]: isSelected('TAIL'),
   378            })}
   379          >
   380            <TailFirstIcon />
   381          </Button>
   382        </Tooltip>
   383      </>
   384    );
   385  }
   386  
   387  const getViewOptions = (
   388    flamegraphType: ProfileHeaderProps['flamegraphType']
   389  ): Array<{
   390    label: string;
   391    value: ViewTypes;
   392    Icon: (props: { fill?: string | undefined }) => JSX.Element;
   393  }> =>
   394    flamegraphType === 'single'
   395      ? [
   396          { label: 'Table', value: 'table', Icon: TableIcon },
   397          {
   398            label: 'Table and Flamegraph',
   399            value: 'both',
   400            Icon: TablePlusFlamegraphIcon,
   401          },
   402          {
   403            label: 'Flamegraph',
   404            value: 'flamegraph',
   405            Icon: FlamegraphIcon,
   406          },
   407          { label: 'Sandwich', value: 'sandwich', Icon: SandwichIcon },
   408          {
   409            label: 'GraphViz',
   410            value: 'graphviz',
   411            Icon: () => <FontAwesomeIcon icon={faProjectDiagram} />,
   412          },
   413        ]
   414      : [
   415          { label: 'Table', value: 'table', Icon: TableIcon },
   416          {
   417            label: 'Table and Flamegraph',
   418            value: 'both',
   419            Icon: TablePlusFlamegraphIcon,
   420          },
   421          {
   422            label: 'Flamegraph',
   423            value: 'flamegraph',
   424            Icon: FlamegraphIcon,
   425          },
   426        ];
   427  
   428  function ViewSection({
   429    view,
   430    updateView,
   431    flamegraphType,
   432  }: {
   433    updateView: ProfileHeaderProps['updateView'];
   434    view: ProfileHeaderProps['view'];
   435    flamegraphType: ProfileHeaderProps['flamegraphType'];
   436  }) {
   437    const options = getViewOptions(flamegraphType);
   438  
   439    return (
   440      <div className={styles.viewType}>
   441        {options.map(({ label, value, Icon }) => (
   442          <Tooltip key={value} placement="top" title={label}>
   443            <Button
   444              data-testid={value}
   445              onClick={() => updateView(value)}
   446              className={cx({
   447                [styles.toggleViewButton]: true,
   448                selected: view === value,
   449              })}
   450            >
   451              <Icon />
   452            </Button>
   453          </Tooltip>
   454        ))}
   455      </div>
   456    );
   457  }
   458  
   459  export default Toolbar;