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;