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

     1  import React, { useRef, RefObject, CSSProperties } from 'react';
     2  import type Color from 'color';
     3  import cl from 'classnames';
     4  import type { Maybe } from 'true-myth';
     5  import { doubleFF, singleFF, Flamebearer } from '@pyroscope/models/src';
     6  // until ui is moved to its own package this should do it
     7  // eslint-disable-next-line import/no-extraneous-dependencies
     8  import TableUI, {
     9    useTableSort,
    10    BodyRow,
    11    TableBodyType,
    12  } from '@webapp/ui/Table';
    13  import TableTooltip from './Tooltip/TableTooltip';
    14  import { getFormatter, ratioToPercent, diffPercent } from './format/format';
    15  import {
    16    colorBasedOnPackageName,
    17    defaultColor,
    18    getPackageNameFromStackTrace,
    19  } from './FlameGraph/FlameGraphComponent/color';
    20  import { fitIntoTableCell, FitModes } from './fitMode/fitMode';
    21  import { isMatch } from './search';
    22  import type { FlamegraphPalette } from './FlameGraph/FlameGraphComponent/colorPalette';
    23  
    24  const zero = (v?: number) => v || 0;
    25  
    26  interface SingleCell {
    27    type: 'single';
    28    self: number;
    29    total: number;
    30  }
    31  
    32  interface DoubleCell {
    33    type: 'double';
    34    self: number;
    35    total: number;
    36    selfLeft: number;
    37    selfRght: number;
    38    selfDiff: number;
    39    totalLeft: number;
    40    totalRght: number;
    41    totalDiff: number;
    42    leftTicks: number;
    43    rightTicks: number;
    44  }
    45  function generateCellSingle(
    46    ff: typeof singleFF,
    47    cell: SingleCell,
    48    level: number[],
    49    j: number
    50  ) {
    51    const c = cell;
    52  
    53    c.type = 'single';
    54    c.self = zero(c.self) + ff.getBarSelf(level, j);
    55    c.total = zero(c.total) + ff.getBarTotal(level, j);
    56    return c;
    57  }
    58  
    59  function generateCellDouble(
    60    ff: typeof doubleFF,
    61    cell: DoubleCell,
    62    level: number[],
    63    j: number,
    64    leftTicks: number,
    65    rightTicks: number
    66  ) {
    67    const c = cell;
    68  
    69    c.type = 'double';
    70    c.self = zero(c.self) + ff.getBarSelf(level, j);
    71    c.total = zero(c.total) + ff.getBarTotal(level, j);
    72    c.selfLeft = zero(c.selfLeft) + ff.getBarSelfLeft(level, j);
    73    c.selfRght = zero(c.selfRght) + ff.getBarSelfRght(level, j);
    74    c.selfDiff = zero(c.selfDiff) + ff.getBarSelfDiff(level, j);
    75    c.totalLeft = zero(c.totalLeft) + ff.getBarTotalLeft(level, j);
    76    c.totalRght = zero(c.totalRght) + ff.getBarTotalRght(level, j);
    77    c.totalDiff = zero(c.totalDiff) + ff.getBarTotalDiff(level, j);
    78    c.leftTicks = leftTicks;
    79    c.rightTicks = rightTicks;
    80    return c;
    81  }
    82  
    83  // generates a table from data in flamebearer format
    84  function generateTable(
    85    flamebearer: Flamebearer
    86  ): ((SingleCell | DoubleCell) & { name: string })[] {
    87    const table: ((SingleCell | DoubleCell) & { name: string })[] = [];
    88    if (!flamebearer) {
    89      return table;
    90    }
    91    const { names, levels, format } = flamebearer;
    92    const ff = format !== 'double' ? singleFF : doubleFF;
    93  
    94    const hash = new Map<string, (DoubleCell | SingleCell) & { name: string }>();
    95    // eslint-disable-next-line no-plusplus
    96    for (let i = 0; i < levels.length; i++) {
    97      const level = levels[i];
    98      for (let j = 0; j < level.length; j += ff.jStep) {
    99        const key = ff.getBarName(level, j);
   100        const name = names[key];
   101  
   102        if (!hash.has(name)) {
   103          hash.set(name, {
   104            name: name || '<empty>',
   105            self: 0,
   106            total: 0,
   107          } as SingleCell & { name: string });
   108        }
   109  
   110        const cell = hash.get(name);
   111        // Should not happen
   112        if (!cell) {
   113          break;
   114        }
   115  
   116        // TODO(eh-am): not the most optimal performance wise
   117        // but better for type checking
   118        if (format === 'single') {
   119          generateCellSingle(singleFF, cell as SingleCell, level, j);
   120        } else {
   121          generateCellDouble(
   122            doubleFF,
   123            cell as DoubleCell,
   124            level,
   125            j,
   126            flamebearer.leftTicks,
   127            flamebearer.rightTicks
   128          );
   129        }
   130      }
   131    }
   132  
   133    return Array.from(hash.values());
   134  }
   135  
   136  // the value must be negative or zero
   137  function neg(v: number) {
   138    return Math.min(0, v);
   139  }
   140  
   141  function backgroundImageStyle(a: number, b: number, color: Color) {
   142    const w = 148;
   143    const k = w - (a / b) * w;
   144    const clr = color.alpha(1.0);
   145    return {
   146      backgroundImage: `linear-gradient(${clr}, ${clr})`,
   147      backgroundPosition: `-${k}px 0px`,
   148      backgroundRepeat: 'no-repeat',
   149    };
   150  }
   151  
   152  // side: _ | L | R : indicates how to render the diff color
   153  // - _: render both diff color
   154  // - L: only render diff color on the left, if the left is longer than the right (better, green)
   155  // - R: only render diff color on the right, if the right is longer than the left (worse, red)
   156  export function backgroundImageDiffStyle(
   157    palette: FlamegraphPalette,
   158    a: number,
   159    b: number,
   160    total: number,
   161    color: Color,
   162    side?: 'L' | 'R'
   163  ): React.CSSProperties {
   164    const w = 148;
   165    const k = w - (Math.min(a, b) / total) * w;
   166    const kd = w - (Math.max(a, b) / total) * w;
   167    const clr = color.alpha(1.0);
   168    const cld =
   169      b < a ? palette.goodColor.alpha(0.8) : palette.badColor.alpha(0.8);
   170  
   171    if (side === 'L' && a < b) {
   172      return {
   173        backgroundImage: `linear-gradient(${clr}, ${clr})`,
   174        backgroundPosition: `${neg(-k)}px 0px`,
   175        backgroundRepeat: 'no-repeat',
   176      };
   177    }
   178    if (side === 'R' && b < a) {
   179      return {
   180        backgroundImage: `linear-gradient(${clr}, ${clr})`,
   181        backgroundPosition: `${neg(-k)}px 0px`,
   182        backgroundRepeat: 'no-repeat',
   183      };
   184    }
   185  
   186    return {
   187      backgroundImage: `linear-gradient(${clr}, ${clr}), linear-gradient(${cld}, ${cld})`,
   188      backgroundPosition: `${neg(-k)}px 0px, ${neg(-kd)}px 0px`,
   189      backgroundRepeat: 'no-repeat',
   190    };
   191  }
   192  
   193  const tableFormatSingle: {
   194    sortable: number;
   195    name: 'name' | 'self' | 'total';
   196    label: string;
   197    default?: boolean;
   198  }[] = [
   199    { sortable: 1, name: 'name', label: 'Location' },
   200    { sortable: 1, name: 'self', label: 'Self', default: true },
   201    { sortable: 1, name: 'total', label: 'Total' },
   202  ];
   203  
   204  const tableFormatDouble: {
   205    sortable: number;
   206    name: 'name' | 'baseline' | 'comparison' | 'diff';
   207    label: string;
   208    default?: boolean;
   209  }[] = [
   210    { sortable: 1, name: 'name', label: 'Location' },
   211    { sortable: 1, name: 'baseline', label: 'Baseline', default: true },
   212    { sortable: 1, name: 'comparison', label: 'Comparison' },
   213    { sortable: 1, name: 'diff', label: 'Diff' },
   214  ];
   215  
   216  function Table({
   217    tableBodyRef,
   218    flamebearer,
   219    isDoubles,
   220    fitMode,
   221    handleTableItemClick,
   222    highlightQuery,
   223    selectedItem,
   224    palette,
   225  }: ProfilerTableProps & { isDoubles: boolean }) {
   226    const tableFormat = isDoubles ? tableFormatDouble : tableFormatSingle;
   227    const tableSortProps = useTableSort(tableFormat);
   228    const table = {
   229      headRow: tableFormat,
   230      ...getTableBody({
   231        flamebearer,
   232        sortBy: tableSortProps.sortBy,
   233        sortByDirection: tableSortProps.sortByDirection,
   234        isDoubles,
   235        fitMode,
   236        handleTableItemClick,
   237        highlightQuery,
   238        palette,
   239        selectedItem,
   240      }),
   241    };
   242  
   243    return (
   244      <TableUI
   245        /* eslint-disable-next-line react/jsx-props-no-spreading */
   246        {...tableSortProps}
   247        tableBodyRef={tableBodyRef}
   248        table={table}
   249        className={cl('flamegraph-table', {
   250          'flamegraph-table-doubles': isDoubles,
   251        })}
   252      />
   253    );
   254  }
   255  
   256  interface GetTableBodyRowsProps
   257    extends Omit<ProfilerTableProps, 'tableBodyRef'> {
   258    sortBy: string;
   259    sortByDirection: string;
   260    isDoubles: boolean;
   261  }
   262  
   263  const getTableBody = ({
   264    flamebearer,
   265    sortBy,
   266    sortByDirection,
   267    isDoubles,
   268    fitMode,
   269    handleTableItemClick,
   270    highlightQuery,
   271    palette,
   272    selectedItem,
   273  }: GetTableBodyRowsProps): TableBodyType => {
   274    const { numTicks, maxSelf, sampleRate, spyName, units } = flamebearer;
   275  
   276    const tableBodyCells = generateTable(flamebearer).sort(
   277      (a, b) => b.total - a.total
   278    );
   279    const m = sortByDirection === 'asc' ? 1 : -1;
   280    let sorted: typeof tableBodyCells;
   281  
   282    if (sortBy === 'name') {
   283      sorted = tableBodyCells.sort(
   284        (a, b) => m * a[sortBy].localeCompare(b[sortBy])
   285      );
   286    } else {
   287      switch (sortBy) {
   288        case 'total':
   289        case 'self': {
   290          sorted = tableBodyCells.sort((a, b) => m * (a[sortBy] - b[sortBy]));
   291          break;
   292        }
   293        case 'baseline': {
   294          sorted = (tableBodyCells as (DoubleCell & { name: string })[]).sort(
   295            (a, b) => m * (a.totalLeft / a.leftTicks - b.totalLeft / b.leftTicks)
   296          );
   297          break;
   298        }
   299        case 'comparison': {
   300          sorted = (tableBodyCells as (DoubleCell & { name: string })[]).sort(
   301            (a, b) =>
   302              m * (a.totalRght / a.rightTicks - b.totalRght / b.rightTicks)
   303          );
   304          break;
   305        }
   306        case 'diff': {
   307          sorted = (tableBodyCells as (DoubleCell & { name: string })[]).sort(
   308            (a, b) => {
   309              const totalDiffA = diffPercent(
   310                ratioToPercent(a.totalLeft / a.leftTicks),
   311                ratioToPercent(a.totalRght / a.rightTicks)
   312              );
   313              const totalDiffB = diffPercent(
   314                ratioToPercent(b.totalLeft / b.leftTicks),
   315                ratioToPercent(b.totalRght / b.rightTicks)
   316              );
   317  
   318              return m * (totalDiffA - totalDiffB);
   319            }
   320          );
   321          break;
   322        }
   323        default:
   324          sorted = tableBodyCells;
   325          break;
   326      }
   327    }
   328  
   329    const formatter = getFormatter(numTicks, sampleRate, units);
   330    const isRowSelected = (name: string) => {
   331      if (selectedItem.isJust) {
   332        return name === selectedItem.value;
   333      }
   334  
   335      return false;
   336    };
   337  
   338    const nameCell = (x: { name: string }, style: CSSProperties) => (
   339      <button className="table-item-button">
   340        <span className="color-reference" style={style} />
   341        <div className="symbol-name" style={fitIntoTableCell(fitMode)}>
   342          {x.name}
   343        </div>
   344      </button>
   345    );
   346  
   347    const getSingleRow = (
   348      x: SingleCell & { name: string },
   349      color: Color,
   350      style: CSSProperties
   351    ): BodyRow => ({
   352      'data-row': `${x.type};${x.name};${x.self};${x.total}`,
   353      isRowSelected: isRowSelected(x.name),
   354      onClick: () => handleTableItemClick(x),
   355      cells: [
   356        { value: nameCell(x, style) },
   357        {
   358          value: formatter.format(x.self, sampleRate),
   359          style: backgroundImageStyle(x.self, maxSelf, color),
   360        },
   361        {
   362          value: formatter.format(x.total, sampleRate),
   363          style: backgroundImageStyle(x.total, numTicks, color),
   364        },
   365      ],
   366    });
   367  
   368    const getDoubleRow = (
   369      x: DoubleCell & { name: string },
   370      style: CSSProperties
   371    ): BodyRow => {
   372      const leftPercent = ratioToPercent(x.totalLeft / x.leftTicks);
   373      const rghtPercent = ratioToPercent(x.totalRght / x.rightTicks);
   374  
   375      const totalDiff = diffPercent(leftPercent, rghtPercent);
   376  
   377      let diffCellColor = '';
   378      if (totalDiff > 0) {
   379        diffCellColor = palette.badColor.rgb().string();
   380      } else if (totalDiff < 0) {
   381        diffCellColor = palette.goodColor.rgb().string();
   382      }
   383  
   384      let diffValue = '';
   385      if (!x.totalLeft || totalDiff === Infinity) {
   386        // this is a new function
   387        diffValue = '(new)';
   388      } else if (!x.totalRght) {
   389        // this function has been removed
   390        diffValue = '(removed)';
   391      } else if (totalDiff > 0) {
   392        diffValue = `(+${totalDiff.toFixed(2)}%)`;
   393      } else if (totalDiff < 0) {
   394        diffValue = `(${totalDiff.toFixed(2)}%)`;
   395      }
   396  
   397      return {
   398        'data-row': `${x.type};${x.name};${x.totalLeft};${x.leftTicks};${x.totalRght};${x.rightTicks}`,
   399        isRowSelected: isRowSelected(x.name),
   400        onClick: () => handleTableItemClick(x),
   401        cells: [
   402          { value: nameCell(x, style) },
   403          { value: `${leftPercent} %` },
   404          { value: `${rghtPercent} %` },
   405          {
   406            value: diffValue,
   407            style: {
   408              color: diffCellColor,
   409            },
   410          },
   411        ],
   412      };
   413    };
   414  
   415    const rows = sorted
   416      .filter((x) => {
   417        if (!highlightQuery) {
   418          return true;
   419        }
   420  
   421        return isMatch(highlightQuery, x.name);
   422      })
   423      .map((x) => {
   424        const pn = getPackageNameFromStackTrace(spyName, x.name);
   425        const color = isDoubles
   426          ? defaultColor
   427          : colorBasedOnPackageName(palette, pn);
   428        const style = {
   429          backgroundColor: color.rgb().toString(),
   430        };
   431  
   432        if (x.type === 'double') {
   433          return getDoubleRow(x, style);
   434        }
   435  
   436        return getSingleRow(x, color, style);
   437      });
   438  
   439    return rows.length > 0
   440      ? { bodyRows: rows, type: 'filled' as const }
   441      : {
   442          value: <div className="unsupported-format">No items found</div>,
   443          type: 'not-filled' as const,
   444        };
   445  };
   446  
   447  export interface ProfilerTableProps {
   448    flamebearer: Flamebearer;
   449    fitMode: FitModes;
   450    handleTableItemClick: (tableItem: { name: string }) => void;
   451    highlightQuery: string;
   452    palette: FlamegraphPalette;
   453    selectedItem: Maybe<string>;
   454  
   455    tableBodyRef: RefObject<HTMLTableSectionElement>;
   456  }
   457  
   458  const ProfilerTable = React.memo(function ProfilerTable({
   459    flamebearer,
   460    fitMode,
   461    handleTableItemClick,
   462    highlightQuery,
   463    palette,
   464    selectedItem,
   465  }: Omit<ProfilerTableProps, 'tableBodyRef'>) {
   466    const tableBodyRef = useRef<HTMLTableSectionElement>(null);
   467  
   468    return (
   469      <div data-testid="table-view">
   470        <Table
   471          tableBodyRef={tableBodyRef}
   472          flamebearer={flamebearer}
   473          isDoubles={flamebearer.format === 'double'}
   474          fitMode={fitMode}
   475          highlightQuery={highlightQuery}
   476          handleTableItemClick={handleTableItemClick}
   477          palette={palette}
   478          selectedItem={selectedItem}
   479        />
   480        <TableTooltip
   481          tableBodyRef={tableBodyRef}
   482          numTicks={flamebearer.numTicks}
   483          sampleRate={flamebearer.sampleRate}
   484          units={flamebearer.units}
   485          palette={palette}
   486        />
   487      </div>
   488    );
   489  });
   490  
   491  export default ProfilerTable;