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;