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

     1  /* eslint-disable react/no-unused-state */
     2  /* eslint-disable no-bitwise */
     3  /* eslint-disable react/no-access-state-in-setstate */
     4  /* eslint-disable react/jsx-props-no-spreading */
     5  /* eslint-disable react/destructuring-assignment */
     6  /* eslint-disable no-nested-ternary */
     7  /* eslint-disable global-require */
     8  
     9  import React, { Dispatch, SetStateAction, ReactNode, Component } from 'react';
    10  import clsx from 'clsx';
    11  import { Maybe } from 'true-myth';
    12  import { createFF, Flamebearer, Profile } from '@pyroscope/models/src';
    13  import NoData from '@pyroscope/webapp/javascript/ui/NoData';
    14  
    15  import Graph from './FlameGraphComponent';
    16  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    17  // @ts-ignore: let's move this to typescript some time in the future
    18  import ProfilerTable from '../ProfilerTable';
    19  import Toolbar, { ProfileHeaderProps } from '../Toolbar';
    20  import {
    21    calleesProfile,
    22    callersProfile,
    23  } from '../convert/sandwichViewProfiles';
    24  import { DefaultPalette } from './FlameGraphComponent/colorPalette';
    25  import styles from './FlamegraphRenderer.module.scss';
    26  import PyroscopeLogo from '../logo-v3-small.svg';
    27  import { FitModes } from '../fitMode/fitMode';
    28  import { ViewTypes } from './FlameGraphComponent/viewTypes';
    29  import { GraphVizPane } from './FlameGraphComponent/GraphVizPane';
    30  import { isSameFlamebearer } from './uniqueness';
    31  import { normalize } from './normalize';
    32  
    33  // Refers to a node in the flamegraph
    34  interface Node {
    35    i: number;
    36    j: number;
    37  }
    38  
    39  export interface FlamegraphRendererProps {
    40    profile?: Profile;
    41  
    42    /** in case you ONLY want to display a specific visualization mode. It will also disable the dropdown that allows you to change mode. */
    43    onlyDisplay?: ViewTypes;
    44    showToolbar?: boolean;
    45  
    46    /** whether to display the panes (table and flamegraph) side by side ('horizontal') or one on top of the other ('vertical') */
    47    panesOrientation?: 'horizontal' | 'vertical';
    48    showPyroscopeLogo?: boolean;
    49    showCredit?: boolean;
    50    ExportData?: ProfileHeaderProps['ExportData'];
    51  
    52    /** @deprecated  prefer Profile */
    53    flamebearer?: Flamebearer;
    54    sharedQuery?: {
    55      searchQuery?: string;
    56      onQueryChange: Dispatch<SetStateAction<string | undefined>>;
    57      syncEnabled: string | boolean;
    58      toggleSync: Dispatch<SetStateAction<boolean | string>>;
    59      id: string;
    60    };
    61  
    62    children?: ReactNode;
    63  }
    64  
    65  interface FlamegraphRendererState {
    66    /** A dirty flamegraph refers to a flamegraph where its original state can be reset */
    67    isFlamegraphDirty: boolean;
    68  
    69    view: NonNullable<FlamegraphRendererProps['onlyDisplay']>;
    70    panesOrientation: NonNullable<FlamegraphRendererProps['panesOrientation']>;
    71  
    72    fitMode: 'HEAD' | 'TAIL';
    73    flamebearer: NonNullable<FlamegraphRendererProps['flamebearer']>;
    74  
    75    /** Query searched in the input box.
    76     * It's used to filter data in the table AND highlight items in the flamegraph */
    77    searchQuery: string;
    78    /** Triggered when an item is clicked on the table. It overwrites the searchQuery */
    79    selectedItem: Maybe<string>;
    80  
    81    flamegraphConfigs: {
    82      focusedNode: Maybe<Node>;
    83      zoom: Maybe<Node>;
    84    };
    85  
    86    palette: typeof DefaultPalette;
    87  }
    88  
    89  class FlameGraphRenderer extends Component<
    90    FlamegraphRendererProps,
    91    FlamegraphRendererState
    92  > {
    93    resetFlamegraphState = {
    94      focusedNode: Maybe.nothing<Node>(),
    95      zoom: Maybe.nothing<Node>(),
    96    };
    97  
    98    // TODO: At some point the initial state may be set via the user
    99    // Eg when sharing a specific node
   100    initialFlamegraphState = this.resetFlamegraphState;
   101  
   102    // eslint-disable-next-line react/static-property-placement
   103    static defaultProps = {
   104      showCredit: true,
   105    };
   106  
   107    constructor(props: FlamegraphRendererProps) {
   108      super(props);
   109  
   110      this.state = {
   111        isFlamegraphDirty: false,
   112        view: this.props.onlyDisplay ? this.props.onlyDisplay : 'both',
   113        fitMode: 'HEAD',
   114        flamebearer: normalize(props),
   115  
   116        // Default to horizontal since it's the most common case
   117        panesOrientation: props.panesOrientation
   118          ? props.panesOrientation
   119          : 'horizontal',
   120  
   121        // query used in the 'search' checkbox
   122        searchQuery: '',
   123        selectedItem: Maybe.nothing(),
   124  
   125        flamegraphConfigs: this.initialFlamegraphState,
   126  
   127        // TODO make this come from the redux store?
   128        palette: DefaultPalette,
   129      };
   130    }
   131  
   132    componentDidUpdate(
   133      prevProps: FlamegraphRendererProps,
   134      prevState: FlamegraphRendererState
   135    ) {
   136      // TODO: this is a slow operation
   137      const prevFlame = normalize(prevProps);
   138      const currFlame = normalize(this.props);
   139  
   140      if (!this.isSameFlamebearer(prevFlame, currFlame)) {
   141        const newConfigs = this.calcNewConfigs(prevFlame, currFlame);
   142  
   143        // Batch these updates to not do unnecessary work
   144        // eslint-disable-next-line react/no-did-update-set-state
   145        this.setState({
   146          flamebearer: currFlame,
   147          flamegraphConfigs: {
   148            ...this.state.flamegraphConfigs,
   149            ...newConfigs,
   150          },
   151          selectedItem: Maybe.nothing(),
   152        });
   153        return;
   154      }
   155  
   156      // flamegraph configs changed
   157      if (prevState.flamegraphConfigs !== this.state.flamegraphConfigs) {
   158        this.updateFlamegraphDirtiness();
   159      }
   160    }
   161  
   162    // Calculate what should be the new configs
   163    // It checks if the zoom/selectNode still points to the same node
   164    // If not, it resets to the resetFlamegraphState
   165    calcNewConfigs = (prevFlame: Flamebearer, currFlame: Flamebearer) => {
   166      const newConfigs = this.state.flamegraphConfigs;
   167  
   168      // This is a simple heuristic based on the name
   169      // It does not account for eg recursive calls
   170      const isSameNode = (f: Flamebearer, f2: Flamebearer, s: Maybe<Node>) => {
   171        // TODO: don't use createFF directly
   172        const getBarName = (f: Flamebearer, i: number, j: number) => {
   173          return f.names[createFF(f.format).getBarName(f.levels[i], j)];
   174        };
   175  
   176        // No node is technically the same node
   177        if (s.isNothing) {
   178          return true;
   179        }
   180  
   181        // if the bar doesn't exist, it will throw an error
   182        try {
   183          const barName1 = getBarName(f, s.value.i, s.value.j);
   184          const barName2 = getBarName(f2, s.value.i, s.value.j);
   185          return barName1 === barName2;
   186        } catch {
   187          return false;
   188        }
   189      };
   190  
   191      // Reset zoom
   192      const currZoom = this.state.flamegraphConfigs.zoom;
   193      if (!isSameNode(prevFlame, currFlame, currZoom)) {
   194        newConfigs.zoom = this.resetFlamegraphState.zoom;
   195      }
   196  
   197      // Reset focused node
   198      const currFocusedNode = this.state.flamegraphConfigs.focusedNode;
   199      if (!isSameNode(prevFlame, currFlame, currFocusedNode)) {
   200        newConfigs.focusedNode = this.resetFlamegraphState.focusedNode;
   201      }
   202  
   203      return newConfigs;
   204    };
   205  
   206    onSearchChange = (e: string) => {
   207      this.setState({ searchQuery: e });
   208    };
   209  
   210    isSameFlamebearer = (prevFlame: Flamebearer, currFlame: Flamebearer) => {
   211      return isSameFlamebearer(prevFlame, currFlame);
   212      // TODO: come up with a less resource intensive operation
   213      // keep in mind naive heuristics may provide bad behaviours like (https://github.com/pyroscope-io/pyroscope/issues/1192)
   214      //    return JSON.stringify(prevFlame) === JSON.stringify(currFlame);
   215    };
   216  
   217    onReset = () => {
   218      this.setState({
   219        ...this.state,
   220        flamegraphConfigs: {
   221          ...this.state.flamegraphConfigs,
   222          ...this.initialFlamegraphState,
   223        },
   224        selectedItem: Maybe.nothing(),
   225      });
   226    };
   227  
   228    onFlamegraphZoom = (bar: Maybe<Node>) => {
   229      // zooming on the topmost bar is equivalent to resetting to the original state
   230      if (bar.isJust && bar.value.i === 0 && bar.value.j === 0) {
   231        this.onReset();
   232        return;
   233      }
   234  
   235      // otherwise just pass it up to the state
   236      // doesn't matter if it's some or none
   237      this.setState({
   238        ...this.state,
   239        flamegraphConfigs: {
   240          ...this.state.flamegraphConfigs,
   241          zoom: bar,
   242        },
   243      });
   244    };
   245  
   246    onFocusOnNode = (i: number, j: number) => {
   247      if (i === 0 && j === 0) {
   248        this.onReset();
   249        return;
   250      }
   251  
   252      let flamegraphConfigs = { ...this.state.flamegraphConfigs };
   253  
   254      // reset zoom if we are focusing below the zoom
   255      // or the same one we were zoomed
   256      const { zoom } = this.state.flamegraphConfigs;
   257      if (zoom.isJust) {
   258        if (zoom.value.i <= i) {
   259          flamegraphConfigs = {
   260            ...flamegraphConfigs,
   261            zoom: this.initialFlamegraphState.zoom,
   262          };
   263        }
   264      }
   265  
   266      this.setState({
   267        ...this.state,
   268        flamegraphConfigs: {
   269          ...flamegraphConfigs,
   270          focusedNode: Maybe.just({ i, j }),
   271        },
   272      });
   273    };
   274  
   275    setActiveItem = (item: { name: string }) => {
   276      const { name } = item;
   277  
   278      // if clicking on the same item, undo the search
   279      if (this.state.selectedItem.isJust) {
   280        if (name === this.state.selectedItem.value) {
   281          this.setState({
   282            selectedItem: Maybe.nothing(),
   283          });
   284          return;
   285        }
   286      }
   287  
   288      // clicking for the first time
   289      this.setState({
   290        selectedItem: Maybe.just(name),
   291      });
   292    };
   293  
   294    getHighlightQuery = () => {
   295      // prefer table selected
   296      if (this.state.selectedItem.isJust) {
   297        return this.state.selectedItem.value;
   298      }
   299  
   300      return this.state.searchQuery;
   301    };
   302  
   303    updateView = (newView: ViewTypes) => {
   304      if (newView === 'sandwich') {
   305        this.setState({
   306          searchQuery: '',
   307          flamegraphConfigs: this.resetFlamegraphState,
   308        });
   309      }
   310  
   311      this.setState({
   312        view: newView,
   313      });
   314    };
   315  
   316    updateFlamegraphDirtiness = () => {
   317      // TODO(eh-am): find a better approach
   318      const isDirty = this.isDirty();
   319  
   320      this.setState({
   321        isFlamegraphDirty: isDirty,
   322      });
   323    };
   324  
   325    updateFitMode = (newFitMode: FitModes) => {
   326      this.setState({
   327        fitMode: newFitMode,
   328      });
   329    };
   330  
   331    // used as a variable instead of keeping in the state
   332    // so that the flamegraph doesn't rerender unnecessarily
   333    isDirty = () => {
   334      return (
   335        this.state.selectedItem.isJust ||
   336        JSON.stringify(this.initialFlamegraphState) !==
   337          JSON.stringify(this.state.flamegraphConfigs)
   338      );
   339    };
   340  
   341    shouldShowToolbar() {
   342      // default to true
   343      return this.props.showToolbar !== undefined ? this.props.showToolbar : true;
   344    }
   345  
   346    render = () => {
   347      // This is necessary because the order switches depending on single vs comparison view
   348      const tablePane = (
   349        <div
   350          key="table-pane"
   351          className={clsx(
   352            styles.tablePane,
   353            this.state.panesOrientation === 'vertical'
   354              ? styles.vertical
   355              : styles.horizontal
   356          )}
   357        >
   358          <ProfilerTable
   359            data-testid="table-view"
   360            flamebearer={this.state.flamebearer}
   361            fitMode={this.state.fitMode}
   362            highlightQuery={this.state.searchQuery}
   363            selectedItem={this.state.selectedItem}
   364            handleTableItemClick={this.setActiveItem}
   365            palette={this.state.palette}
   366          />
   367        </div>
   368      );
   369  
   370      const toolbarVisible = this.shouldShowToolbar();
   371  
   372      const flameGraphPane = (
   373        <Graph
   374          key="flamegraph-pane"
   375          // data-testid={flamegraphDataTestId}
   376          showCredit={this.props.showCredit as boolean}
   377          flamebearer={this.state.flamebearer}
   378          highlightQuery={this.getHighlightQuery()}
   379          setActiveItem={this.setActiveItem}
   380          selectedItem={this.state.selectedItem}
   381          updateView={this.props.onlyDisplay ? undefined : this.updateView}
   382          fitMode={this.state.fitMode}
   383          updateFitMode={this.updateFitMode}
   384          zoom={this.state.flamegraphConfigs.zoom}
   385          focusedNode={this.state.flamegraphConfigs.focusedNode}
   386          onZoom={this.onFlamegraphZoom}
   387          onFocusOnNode={this.onFocusOnNode}
   388          onReset={this.onReset}
   389          isDirty={this.isDirty}
   390          palette={this.state.palette}
   391          toolbarVisible={toolbarVisible}
   392          setPalette={(p) =>
   393            this.setState({
   394              palette: p,
   395            })
   396          }
   397        />
   398      );
   399  
   400      const sandwichPane = (() => {
   401        if (this.state.selectedItem.isNothing) {
   402          return (
   403            <div className={styles.sandwichPane} key="sandwich-pane">
   404              <div
   405                className={clsx(
   406                  styles.sandwichPaneInfo,
   407                  this.state.panesOrientation === 'vertical'
   408                    ? styles.vertical
   409                    : styles.horizontal
   410                )}
   411              >
   412                <div className={styles.arrow} />
   413                Select a function to view callers/callees sandwich view
   414              </div>
   415            </div>
   416          );
   417        }
   418  
   419        const callersFlamebearer = callersProfile(
   420          this.state.flamebearer,
   421          this.state.selectedItem.value
   422        );
   423        const calleesFlamebearer = calleesProfile(
   424          this.state.flamebearer,
   425          this.state.selectedItem.value
   426        );
   427        const sandwitchGraph = (myCustomParams: {
   428          flamebearer: Flamebearer;
   429          headerVisible?: boolean;
   430          showSingleLevel?: boolean;
   431        }) => (
   432          <Graph
   433            disableClick
   434            showCredit={this.props.showCredit as boolean}
   435            highlightQuery=""
   436            setActiveItem={this.setActiveItem}
   437            selectedItem={this.state.selectedItem}
   438            fitMode={this.state.fitMode}
   439            updateFitMode={this.updateFitMode}
   440            zoom={this.state.flamegraphConfigs.zoom}
   441            focusedNode={this.state.flamegraphConfigs.focusedNode}
   442            onZoom={this.onFlamegraphZoom}
   443            onFocusOnNode={this.onFocusOnNode}
   444            onReset={this.onReset}
   445            isDirty={this.isDirty}
   446            palette={this.state.palette}
   447            toolbarVisible={toolbarVisible}
   448            setPalette={(p) =>
   449              this.setState({
   450                palette: p,
   451              })
   452            }
   453            {...myCustomParams}
   454          />
   455        );
   456  
   457        return (
   458          <div className={styles.sandwichPane} key="sandwich-pane">
   459            <div className={styles.sandwichTop}>
   460              <span className={styles.name}>Callers</span>
   461              {/* todo(dogfrogfog): to allow left/right click on the node we should
   462              store Graph component we clicking and append action only on to
   463              this component
   464              will be implemented i nnext PR */}
   465              {sandwitchGraph({ flamebearer: callersFlamebearer })}
   466            </div>
   467            <div className={styles.sandwichBottom}>
   468              <span className={styles.name}>Callees</span>
   469              {sandwitchGraph({
   470                flamebearer: calleesFlamebearer,
   471                headerVisible: false,
   472                showSingleLevel: true,
   473              })}
   474            </div>
   475          </div>
   476        );
   477      })();
   478  
   479      // export type Flamebearer = {
   480      //   /**
   481      //    * List of names
   482      //    */
   483      //   names: string[];
   484      //   /**
   485      //    * List of level
   486      //    *
   487      //    * This is NOT the same as in the flamebearer
   488      //    * that we receive from the server.
   489      //    * As in there are some transformations required
   490      //    * (see deltaDiffWrapper)
   491      //    */
   492      //   levels: number[][];
   493      //   numTicks: number;
   494      //   maxSelf: number;
   495  
   496      //   /**
   497      //    * Sample Rate, used in text information
   498      //    */
   499      //   sampleRate: number;
   500      //   units: Units;
   501  
   502      //   spyName: SpyName;
   503      //   // format: 'double' | 'single';
   504      //   //  leftTicks?: number;
   505      //   //  rightTicks?: number;
   506      // } & addTicks;
   507  
   508      const dataUnavailable =
   509        !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
   510      const panes = decidePanesOrder(
   511        this.state.view,
   512        flameGraphPane,
   513        tablePane,
   514        sandwichPane,
   515        <GraphVizPane flamebearer={this.state.flamebearer} />
   516      );
   517  
   518      return (
   519        <div>
   520          <div>
   521            {toolbarVisible && (
   522              <Toolbar
   523                sharedQuery={this.props.sharedQuery}
   524                enableChangingDisplay={!this.props.onlyDisplay}
   525                flamegraphType={this.state.flamebearer.format}
   526                view={this.state.view}
   527                handleSearchChange={this.onSearchChange}
   528                reset={this.onReset}
   529                updateView={this.updateView}
   530                updateFitMode={this.updateFitMode}
   531                fitMode={this.state.fitMode}
   532                isFlamegraphDirty={this.isDirty()}
   533                selectedNode={this.state.flamegraphConfigs.zoom}
   534                highlightQuery={this.state.searchQuery}
   535                onFocusOnSubtree={this.onFocusOnNode}
   536                ExportData={this.props.ExportData}
   537              />
   538            )}
   539            {this.props.children}
   540            <div
   541              className={`${styles.flamegraphContainer} ${clsx(
   542                this.state.panesOrientation === 'vertical'
   543                  ? styles.vertical
   544                  : styles.horizontal,
   545                styles[this.state.panesOrientation],
   546                styles.panesWrapper
   547              )}`}
   548            >
   549              {dataUnavailable ? <NoData /> : panes.map((pane) => pane)}
   550            </div>
   551          </div>
   552  
   553          {this.props.showPyroscopeLogo && (
   554            <div className={styles.createdBy}>
   555              Created by
   556              <a
   557                href="https://twitter.com/PyroscopeIO"
   558                rel="noreferrer"
   559                target="_blank"
   560              >
   561                <PyroscopeLogo width="30" height="30" />
   562                @PyroscopeIO
   563              </a>
   564            </div>
   565          )}
   566        </div>
   567      );
   568    };
   569  }
   570  
   571  function decidePanesOrder(
   572    view: FlamegraphRendererState['view'],
   573    flamegraphPane: JSX.Element | null,
   574    tablePane: JSX.Element,
   575    sandwichPane: JSX.Element,
   576    graphvizPane: JSX.Element
   577  ) {
   578    switch (view) {
   579      case 'table': {
   580        return [tablePane];
   581      }
   582      case 'flamegraph': {
   583        return [flamegraphPane];
   584      }
   585      case 'sandwich': {
   586        return [tablePane, sandwichPane];
   587      }
   588  
   589      case 'both': {
   590        return [tablePane, flamegraphPane];
   591      }
   592  
   593      case 'graphviz': {
   594        return [graphvizPane];
   595      }
   596      default: {
   597        throw new Error(`Invalid view '${view}'`);
   598      }
   599    }
   600  }
   601  
   602  export default FlameGraphRenderer;