github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/Tooltip/Tooltip.tsx (about) 1 import React, { 2 CSSProperties, 3 RefObject, 4 ReactNode, 5 useEffect, 6 useState, 7 useRef, 8 useCallback, 9 Dispatch, 10 SetStateAction, 11 } from 'react'; 12 import clsx from 'clsx'; 13 import type { Units } from '@pyroscope/models/src'; 14 15 import RightClickIcon from './RightClickIcon'; 16 import LeftClickIcon from './LeftClickIcon'; 17 18 import styles from './Tooltip.module.scss'; 19 20 export type TooltipData = { 21 units: Units; 22 percent?: string | number; 23 samples?: string; 24 formattedValue?: string; 25 self?: string; 26 total?: string; 27 tooltipType: 'table' | 'flamegraph'; 28 }; 29 30 export interface TooltipProps { 31 // canvas or table body ref 32 dataSourceRef: RefObject<HTMLCanvasElement | HTMLTableSectionElement>; 33 34 shouldShowFooter?: boolean; 35 shouldShowTitle?: boolean; 36 clickInfoSide?: 'left' | 'right'; 37 38 setTooltipContent: ( 39 setContent: Dispatch< 40 SetStateAction<{ 41 title: { 42 text: string; 43 diff: { 44 text: string; 45 color: string; 46 }; 47 }; 48 tooltipData: TooltipData[]; 49 }> 50 >, 51 onMouseOut: () => void, 52 e: MouseEvent 53 ) => void; 54 } 55 56 export function Tooltip({ 57 shouldShowFooter = true, 58 shouldShowTitle = true, 59 dataSourceRef, 60 clickInfoSide, 61 setTooltipContent, 62 }: TooltipProps) { 63 const tooltipRef = useRef<HTMLDivElement>(null); 64 const [content, setContent] = React.useState({ 65 title: { 66 text: '', 67 diff: { 68 text: '', 69 color: '', 70 }, 71 }, 72 tooltipData: [] as TooltipData[], 73 }); 74 const [style, setStyle] = useState<CSSProperties>(); 75 76 const onMouseOut = () => { 77 setStyle({ 78 visibility: 'hidden', 79 }); 80 setContent({ 81 title: { 82 text: '', 83 diff: { 84 text: '', 85 color: '', 86 }, 87 }, 88 tooltipData: [], 89 }); 90 }; 91 92 const memoizedOnMouseMove = useCallback( 93 (e: MouseEvent) => { 94 if (!tooltipRef || !tooltipRef.current) { 95 throw new Error('Missing tooltipElement'); 96 } 97 98 const left = Math.min( 99 e.clientX + 12, 100 window.innerWidth - tooltipRef.current.clientWidth - 20 101 ); 102 const top = e.clientY + 20; 103 104 const style: React.CSSProperties = { 105 top, 106 left, 107 visibility: 'visible', 108 }; 109 110 setTooltipContent(setContent, onMouseOut, e); 111 setStyle(style); 112 }, 113 114 // these are the dependencies from props 115 // that are going to be used in onMouseMove 116 [tooltipRef, setTooltipContent] 117 ); 118 119 useEffect(() => { 120 // use closure to "cache" the current dataSourceRef(canvas/table) reference 121 // so that when cleaning up, it points to a valid canvas 122 // (otherwise it would be null) 123 const dataSourceEl = dataSourceRef.current; 124 if (!dataSourceEl) { 125 return () => {}; 126 } 127 128 // watch for mouse events on the bar 129 dataSourceEl.addEventListener( 130 'mousemove', 131 memoizedOnMouseMove as EventListener 132 ); 133 dataSourceEl.addEventListener('mouseout', onMouseOut); 134 135 return () => { 136 dataSourceEl.removeEventListener( 137 'mousemove', 138 memoizedOnMouseMove as EventListener 139 ); 140 dataSourceEl.removeEventListener('mouseout', onMouseOut); 141 }; 142 }, [dataSourceRef.current, memoizedOnMouseMove]); 143 144 return ( 145 <div 146 data-testid="tooltip" 147 className={clsx(styles.tooltip, { 148 [styles.flamegraphDiffTooltip]: content.tooltipData.length > 1, 149 })} 150 style={style} 151 ref={tooltipRef} 152 > 153 {content.tooltipData.length > 0 && ( 154 <> 155 {shouldShowTitle && ( 156 <div className={styles.tooltipName} data-testid="tooltip-title"> 157 {content.title.text} 158 </div> 159 )} 160 <div 161 className={styles.functionName} 162 data-testid="tooltip-function-name" 163 > 164 {content.title.text} 165 </div> 166 {content.title.diff.text.length > 0 ? ( 167 <TooltipTable 168 data={content.tooltipData} 169 diff={content.title.diff} 170 /> 171 ) : ( 172 <TooltipTable data={content.tooltipData} /> 173 )} 174 {shouldShowFooter && <TooltipFooter clickInfoSide={clickInfoSide} />} 175 </> 176 )} 177 </div> 178 ); 179 } 180 181 const tooltipTitles: Record< 182 Units, 183 { percent: string; formattedValue: string; total: string } 184 > = { 185 objects: { 186 percent: '% of objects in RAM', 187 formattedValue: 'Objects in RAM', 188 total: '% of total RAM', 189 }, 190 goroutines: { 191 percent: '% of goroutines', 192 formattedValue: 'Goroutines', 193 total: '% of total goroutines', 194 }, 195 bytes: { 196 percent: '% of RAM', 197 formattedValue: 'RAM', 198 total: '% of total bytes', 199 }, 200 samples: { 201 percent: 'Share of CPU', 202 formattedValue: 'CPU Time', 203 total: '% of total CPU', 204 }, 205 lock_nanoseconds: { 206 percent: '% of Time spent', 207 formattedValue: 'Time', 208 total: '% of total seconds', 209 }, 210 lock_samples: { 211 percent: '% of contended locks', 212 formattedValue: 'Contended locks', 213 total: '% of total locks', 214 }, 215 trace_samples: { 216 percent: '% of time', 217 formattedValue: 'Samples', 218 total: '% of total samples', 219 }, 220 exceptions: { 221 percent: '% of thrown exceptions', 222 formattedValue: 'Thrown exceptions', 223 total: '% of total thrown exceptions', 224 }, 225 unknown: { 226 percent: 'Percentage', 227 formattedValue: 'Units', 228 total: '% of total units', 229 }, 230 }; 231 232 function TooltipTable({ 233 data, 234 diff, 235 }: { 236 data: TooltipData[]; 237 diff?: { text: string; color: string }; 238 }) { 239 const [baselineData, comparisonData] = data; 240 241 if (!baselineData) { 242 return null; 243 } 244 245 let renderTable: () => ReactNode; 246 247 switch (baselineData.tooltipType) { 248 case 'flamegraph': 249 renderTable = () => ( 250 <> 251 {comparisonData && ( 252 <thead> 253 <tr> 254 <th /> 255 <th>Baseline</th> 256 <th>Comparison</th> 257 <th>Diff</th> 258 </tr> 259 </thead> 260 )} 261 <tbody> 262 <tr> 263 <td>{tooltipTitles[baselineData.units].percent}:</td> 264 <td>{baselineData.percent}</td> 265 {comparisonData && ( 266 <> 267 <td>{comparisonData.percent}</td> 268 <td> 269 {diff && ( 270 <span 271 data-testid="tooltip-diff" 272 style={{ color: diff.color }} 273 > 274 {diff.text} 275 </span> 276 )} 277 </td> 278 </> 279 )} 280 </tr> 281 <tr> 282 <td>{tooltipTitles[baselineData.units].formattedValue}:</td> 283 <td>{baselineData.formattedValue}</td> 284 {comparisonData && ( 285 <> 286 <td>{comparisonData.formattedValue}</td> 287 <td /> 288 </> 289 )} 290 </tr> 291 <tr> 292 <td>Samples:</td> 293 <td>{baselineData.samples}</td> 294 {comparisonData && ( 295 <> 296 <td>{comparisonData.samples}</td> 297 <td /> 298 </> 299 )} 300 </tr> 301 </tbody> 302 </> 303 ); 304 break; 305 case 'table': 306 renderTable = () => ( 307 <> 308 <thead> 309 <tr> 310 <td /> 311 <td>Self ({tooltipTitles[baselineData.units].total})</td> 312 <td>Total ({tooltipTitles[baselineData.units].total})</td> 313 </tr> 314 </thead> 315 <tbody> 316 <tr> 317 <td>{tooltipTitles[baselineData.units].formattedValue}:</td> 318 <td>{baselineData.self}</td> 319 <td>{baselineData.total}</td> 320 </tr> 321 </tbody> 322 </> 323 ); 324 break; 325 default: 326 renderTable = () => null; 327 } 328 329 return ( 330 <table 331 data-testid="tooltip-table" 332 className={clsx(styles.tooltipTable, { 333 [styles[`${baselineData.tooltipType}${comparisonData ? 'Diff' : ''}`]]: 334 baselineData.tooltipType, 335 })} 336 > 337 {renderTable()} 338 </table> 339 ); 340 } 341 342 function TooltipFooter({ 343 clickInfoSide, 344 }: { 345 clickInfoSide?: 'left' | 'right'; 346 }) { 347 let clickInfo: ReactNode; 348 349 switch (clickInfoSide) { 350 case 'right': 351 clickInfo = ( 352 <> 353 <RightClickIcon /> 354 <span>Right click for more node viewing options</span> 355 </> 356 ); 357 break; 358 case 'left': 359 clickInfo = ( 360 <> 361 <LeftClickIcon /> 362 <span>Click to highlight node in flamegraph</span> 363 </> 364 ); 365 break; 366 default: 367 clickInfo = <></>; 368 } 369 370 return ( 371 <div data-testid="tooltip-footer" className={styles.clickInfo}> 372 {clickInfo} 373 </div> 374 ); 375 }