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;