github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/pages/TagExplorerView.tsx (about) 1 import React, { useEffect, useMemo } from 'react'; 2 import { NavLink, useLocation } from 'react-router-dom'; 3 import type { Maybe } from 'true-myth'; 4 import type { ClickEvent } from '@webapp/ui/Menu'; 5 import Color from 'color'; 6 import TotalSamplesChart from '@webapp/pages/tagExplorer/components/TotalSamplesChart'; 7 import type { Profile } from '@pyroscope/models/src'; 8 import Box, { CollapseBox } from '@webapp/ui/Box'; 9 import Toolbar from '@webapp/components/Toolbar'; 10 import ExportData from '@webapp/components/ExportData'; 11 import TimelineChartWrapper, { 12 TimelineGroupData, 13 } from '@webapp/components/TimelineChart/TimelineChartWrapper'; 14 import { FlamegraphRenderer } from '@pyroscope/flamegraph/src'; 15 import Dropdown, { MenuItem } from '@webapp/ui/Dropdown'; 16 import TagsSelector from '@webapp/pages/tagExplorer/components/TagsSelector'; 17 import TableUI, { useTableSort, BodyRow } from '@webapp/ui/Table'; 18 import useColorMode from '@webapp/hooks/colorMode.hook'; 19 import useTimeZone from '@webapp/hooks/timeZone.hook'; 20 import { appendLabelToQuery } from '@webapp/util/query'; 21 import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; 22 import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; 23 import { 24 actions, 25 setDateRange, 26 fetchTags, 27 selectQueries, 28 selectContinuousState, 29 selectAppTags, 30 TagsState, 31 fetchTagExplorerView, 32 fetchTagExplorerViewProfile, 33 ALL_TAGS, 34 setQuery, 35 selectAnnotationsOrDefault, 36 } from '@webapp/redux/reducers/continuous'; 37 import { queryToAppName } from '@webapp/models/query'; 38 import PageTitle from '@webapp/components/PageTitle'; 39 import ExploreTooltip from '@webapp/components/TimelineChart/ExploreTooltip'; 40 import { getFormatter } from '@pyroscope/flamegraph/src/format/format'; 41 import { LoadingOverlay } from '@webapp/ui/LoadingOverlay'; 42 import { calculateMean, calculateStdDeviation, calculateTotal } from './math'; 43 import { PAGES } from './constants'; 44 import { 45 addSpaces, 46 getIntegerSpaceLengthForString, 47 getTableIntegerSpaceLengthByColumn, 48 formatValue, 49 } from './formatTableData'; 50 // eslint-disable-next-line css-modules/no-unused-class 51 import styles from './TagExplorerView.module.scss'; 52 import { formatTitle } from './formatTitle'; 53 54 const TIMELINE_SERIES_COLORS = [ 55 Color.rgb(242, 204, 12), 56 Color.rgb(115, 191, 105), 57 Color.rgb(138, 184, 255), 58 Color.rgb(255, 120, 10), 59 Color.rgb(242, 73, 92), 60 Color.rgb(87, 148, 242), 61 Color.rgb(184, 119, 217), 62 Color.rgb(112, 93, 160), 63 Color.rgb(55, 135, 45), 64 Color.rgb(250, 222, 42), 65 Color.rgb(68, 126, 188), 66 Color.rgb(193, 92, 23), 67 Color.rgb(137, 15, 2), 68 Color.rgb(10, 67, 124), 69 Color.rgb(109, 31, 98), 70 Color.rgb(88, 68, 119), 71 Color.rgb(183, 219, 171), 72 Color.rgb(244, 213, 152), 73 Color.rgb(112, 219, 237), 74 Color.rgb(249, 186, 143), 75 Color.rgb(242, 145, 145), 76 Color.rgb(130, 181, 216), 77 Color.rgb(229, 168, 226), 78 Color.rgb(174, 162, 224), 79 Color.rgb(98, 158, 81), 80 Color.rgb(229, 172, 14), 81 Color.rgb(100, 176, 200), 82 Color.rgb(224, 117, 45), 83 Color.rgb(191, 27, 0), 84 Color.rgb(10, 80, 161), 85 Color.rgb(150, 45, 130), 86 Color.rgb(97, 77, 147), 87 Color.rgb(154, 196, 138), 88 Color.rgb(242, 201, 109), 89 Color.rgb(101, 197, 219), 90 Color.rgb(249, 147, 78), 91 Color.rgb(234, 100, 96), 92 Color.rgb(81, 149, 206), 93 Color.rgb(214, 131, 206), 94 Color.rgb(128, 110, 183), 95 Color.rgb(63, 104, 51), 96 Color.rgb(150, 115, 2), 97 Color.rgb(47, 87, 94), 98 Color.rgb(153, 68, 10), 99 Color.rgb(88, 20, 12), 100 Color.rgb(5, 43, 81), 101 Color.rgb(81, 23, 73), 102 Color.rgb(63, 43, 91), 103 Color.rgb(224, 249, 215), 104 Color.rgb(252, 234, 202), 105 Color.rgb(207, 250, 255), 106 Color.rgb(249, 226, 210), 107 Color.rgb(252, 226, 222), 108 Color.rgb(186, 223, 244), 109 Color.rgb(249, 217, 249), 110 Color.rgb(222, 218, 247), 111 ]; 112 113 const TOP_N_ROWS = 10; 114 const OTHER_TAG_NAME = 'Other'; 115 116 // structured data to display/style table cells 117 interface TableValuesData { 118 color?: Color; 119 mean: number; 120 stdDeviation: number; 121 total: number; 122 tagName: string; 123 totalLabel: string; 124 stdDeviationLabel: string; 125 meanLabel: string; 126 } 127 128 const calculateTableData = ({ 129 data, 130 formatter, 131 profile, 132 }: { 133 data: TimelineGroupData[]; 134 formatter?: ReturnType<typeof getFormatter>; 135 profile?: Profile; 136 }): TableValuesData[] => 137 data.reduce((acc, { tagName, data, color }) => { 138 const mean = calculateMean(data.samples); 139 const total = calculateTotal(data.samples); 140 const stdDeviation = calculateStdDeviation(data.samples, mean); 141 142 acc.push({ 143 tagName, 144 color, 145 mean, 146 total, 147 stdDeviation, 148 meanLabel: formatValue({ value: mean, formatter, profile }), 149 stdDeviationLabel: formatValue({ 150 value: stdDeviation, 151 formatter, 152 profile, 153 }), 154 totalLabel: formatValue({ value: total, formatter, profile }), 155 }); 156 157 return acc; 158 }, [] as TableValuesData[]); 159 160 const TIMELINE_WRAPPER_ID = 'explore_timeline_wrapper'; 161 162 const getTimelineColor = (index: number, palette: Color[]): Color => 163 Color(palette[index % (palette.length - 1)]); 164 165 function TagExplorerView() { 166 const { offset } = useTimeZone(); 167 const { colorMode } = useColorMode(); 168 const dispatch = useAppDispatch(); 169 170 const { from, until, tagExplorerView, refreshToken } = useAppSelector( 171 selectContinuousState 172 ); 173 const { query } = useAppSelector(selectQueries); 174 const tags = useAppSelector(selectAppTags(query)); 175 const appName = queryToAppName(query); 176 177 const annotations = useAppSelector( 178 selectAnnotationsOrDefault('tagExplorerView') 179 ); 180 181 useEffect(() => { 182 if (query) { 183 dispatch(fetchTags(query)); 184 } 185 }, [query]); 186 187 const { 188 groupByTag, 189 groupByTagValue, 190 groupsLoadingType, 191 activeTagProfileLoadingType, 192 } = tagExplorerView; 193 194 useEffect(() => { 195 if (from && until && query && groupByTagValue) { 196 const fetchData = dispatch(fetchTagExplorerViewProfile(null)); 197 return () => fetchData.abort('cancel'); 198 } 199 return undefined; 200 }, [from, until, query, groupByTagValue]); 201 202 useEffect(() => { 203 if (from && until && query) { 204 const fetchData = dispatch(fetchTagExplorerView(null)); 205 return () => fetchData.abort('cancel'); 206 } 207 return undefined; 208 }, [from, until, query, groupByTag, refreshToken]); 209 210 const getGroupsData = (): { 211 groupsData: TimelineGroupData[]; 212 activeTagProfile?: Profile; 213 } => { 214 switch (tagExplorerView.groupsLoadingType) { 215 case 'loaded': 216 case 'reloading': 217 const groups = Object.entries(tagExplorerView.groups).reduce( 218 (acc, [tagName, data], index) => { 219 acc.push({ 220 tagName, 221 data, 222 color: getTimelineColor(index, TIMELINE_SERIES_COLORS), 223 }); 224 225 return acc; 226 }, 227 [] as TimelineGroupData[] 228 ); 229 230 if ( 231 groups.length > 0 && 232 (activeTagProfileLoadingType === 'loaded' || 233 activeTagProfileLoadingType === 'reloading') && 234 tagExplorerView?.activeTagProfile 235 ) { 236 return { 237 groupsData: groups, 238 activeTagProfile: tagExplorerView.activeTagProfile, 239 }; 240 } 241 242 return { 243 groupsData: [], 244 activeTagProfile: undefined, 245 }; 246 247 default: 248 return { 249 groupsData: [], 250 activeTagProfile: undefined, 251 }; 252 } 253 }; 254 255 const { groupsData, activeTagProfile } = getGroupsData(); 256 257 const handleGroupByTagValueChange = (v: string) => { 258 if (v === OTHER_TAG_NAME) { 259 return; 260 } 261 262 dispatch(actions.setTagExplorerViewGroupByTagValue(v)); 263 }; 264 265 const handleGroupedByTagChange = (value: string) => { 266 dispatch(actions.setTagExplorerViewGroupByTag(value)); 267 }; 268 269 const exportFlamegraphDotComFn = useExportToFlamegraphDotCom( 270 activeTagProfile, 271 groupByTag, 272 groupByTagValue 273 ); 274 // when there's no groupByTag value backend returns groups with single "*" group, 275 // which is "application without any tag" group. when backend returns multiple groups, 276 // "*" group samples array is filled with zeros (not longer valid application data). 277 // removing "*" group from table data helps to show only relevant data 278 const filteredGroupsData = 279 groupsData.length === 1 280 ? [{ ...groupsData[0], tagName: appName.unwrapOr('') }] 281 : groupsData.filter((a) => a.tagName !== '*'); 282 283 // filteredGroupsData has single "application without tags" group for initial view 284 // its not "real" group so we filter it 285 const whereDropdownItems = filteredGroupsData.reduce((acc, group) => { 286 if (group.tagName === appName.unwrapOr('')) { 287 return acc; 288 } 289 290 acc.push(group.tagName); 291 return acc; 292 }, [] as string[]); 293 294 const sortedGroupsByTotal = [...filteredGroupsData].sort( 295 (a, b) => calculateTotal(b.data.samples) - calculateTotal(a.data.samples) 296 ); 297 298 const topNGroups = sortedGroupsByTotal.slice(0, TOP_N_ROWS); 299 const groupsRemainder = sortedGroupsByTotal.slice( 300 TOP_N_ROWS, 301 sortedGroupsByTotal.length 302 ); 303 304 const groups = 305 filteredGroupsData.length > TOP_N_ROWS 306 ? [ 307 ...topNGroups, 308 { 309 tagName: OTHER_TAG_NAME, 310 color: Color('#888'), 311 data: { 312 samples: groupsRemainder.reduce((acc: number[], current) => { 313 return acc.concat(current.data.samples); 314 }, []), 315 }, 316 } as TimelineGroupData, 317 ] 318 : filteredGroupsData; 319 320 const formatter = 321 activeTagProfile && 322 getFormatter( 323 activeTagProfile.flamebearer.numTicks, 324 activeTagProfile.metadata.sampleRate, 325 activeTagProfile.metadata.units 326 ); 327 328 const dataLoading = 329 groupsLoadingType === 'loading' || 330 groupsLoadingType === 'reloading' || 331 activeTagProfileLoadingType === 'loading'; 332 333 return ( 334 <> 335 <PageTitle title={formatTitle('Tag Explorer View', query)} /> 336 <div className={styles.tagExplorerView} data-testid="tag-explorer-view"> 337 <Toolbar 338 onSelectedApp={(query) => { 339 dispatch(setQuery(query)); 340 }} 341 /> 342 <Box> 343 <ExploreHeader 344 appName={appName} 345 tags={tags} 346 whereDropdownItems={whereDropdownItems} 347 selectedTag={tagExplorerView.groupByTag} 348 selectedTagValue={tagExplorerView.groupByTagValue} 349 handleGroupByTagChange={handleGroupedByTagChange} 350 handleGroupByTagValueChange={handleGroupByTagValueChange} 351 /> 352 <div id={TIMELINE_WRAPPER_ID} className={styles.timelineWrapper}> 353 <LoadingOverlay active={dataLoading}> 354 <TimelineChartWrapper 355 selectionType="double" 356 mode="multiple" 357 timezone={offset === 0 ? 'utc' : 'browser'} 358 data-testid="timeline-explore-page" 359 id="timeline-chart-explore-page" 360 annotations={annotations} 361 timelineGroups={groups} 362 // to not "dim" timelines when "All" option is selected 363 activeGroup={ 364 groupByTagValue !== ALL_TAGS ? groupByTagValue : '' 365 } 366 showTagsLegend={groups.length > 1} 367 handleGroupByTagValueChange={handleGroupByTagValueChange} 368 onSelect={(from, until) => 369 dispatch(setDateRange({ from, until })) 370 } 371 height="125px" 372 format="lines" 373 onHoverDisplayTooltip={(data) => ( 374 <ExploreTooltip 375 values={data.values} 376 timeLabel={data.timeLabel} 377 profile={activeTagProfile} 378 /> 379 )} 380 /> 381 </LoadingOverlay> 382 </div> 383 </Box> 384 <CollapseBox 385 title={`${appName 386 .map((a) => `${a} Tag Breakdown`) 387 .unwrapOr('Tag Breakdown')}`} 388 > 389 <div className={styles.statisticsBox}> 390 <div className={styles.pieChartWrapper}> 391 <TotalSamplesChart 392 formatter={formatter} 393 filteredGroupsData={groups} 394 profile={activeTagProfile} 395 isLoading={dataLoading} 396 /> 397 </div> 398 <Table 399 appName={appName.unwrapOr('')} 400 whereDropdownItems={whereDropdownItems} 401 groupByTag={groupByTag} 402 groupByTagValue={groupByTagValue} 403 groupsData={groups} 404 handleGroupByTagValueChange={handleGroupByTagValueChange} 405 isLoading={dataLoading} 406 activeTagProfile={activeTagProfile} 407 formatter={formatter} 408 /> 409 </div> 410 </CollapseBox> 411 <Box> 412 <div className={styles.flamegraphWrapper}> 413 <LoadingOverlay active={dataLoading}> 414 <FlamegraphRenderer 415 showCredit={false} 416 profile={activeTagProfile} 417 colorMode={colorMode} 418 ExportData={ 419 activeTagProfile && ( 420 <ExportData 421 flamebearer={activeTagProfile} 422 exportPNG 423 exportJSON 424 exportPprof 425 exportHTML 426 exportFlamegraphDotCom 427 exportFlamegraphDotComFn={exportFlamegraphDotComFn} 428 /> 429 ) 430 } 431 /> 432 </LoadingOverlay> 433 </div> 434 </Box> 435 </div> 436 </> 437 ); 438 } 439 440 function Table({ 441 appName, 442 whereDropdownItems, 443 groupByTag, 444 groupByTagValue, 445 groupsData, 446 isLoading, 447 handleGroupByTagValueChange, 448 activeTagProfile, 449 formatter, 450 }: { 451 appName: string; 452 whereDropdownItems: string[]; 453 groupByTag: string; 454 groupByTagValue: string | undefined; 455 groupsData: TimelineGroupData[]; 456 isLoading: boolean; 457 handleGroupByTagValueChange: (groupedByTagValue: string) => void; 458 activeTagProfile?: Profile; 459 formatter?: ReturnType<typeof getFormatter>; 460 }) { 461 const { search } = useLocation(); 462 const isTagSelected = (tag: string) => tag === groupByTagValue; 463 464 const handleTableRowClick = (value: string) => { 465 // prevent clicking on single "application without tags" group row or Other row 466 if (value === appName || value === OTHER_TAG_NAME) { 467 return; 468 } 469 470 if (value !== groupByTagValue) { 471 handleGroupByTagValueChange(value); 472 } else { 473 handleGroupByTagValueChange(ALL_TAGS); 474 } 475 }; 476 477 const getSingleViewSearch = () => { 478 if (!groupByTagValue || ALL_TAGS) return search; 479 480 const searchParams = new URLSearchParams(search); 481 searchParams.delete('query'); 482 searchParams.set( 483 'query', 484 appendLabelToQuery(`${appName}{}`, groupByTag, groupByTagValue) 485 ); 486 return `?${searchParams.toString()}`; 487 }; 488 489 const headRow = [ 490 // when groupByTag is not selected table represents single "application without tags" group 491 { 492 name: 'name', 493 label: groupByTag === '' ? 'Application' : 'Tag name', 494 sortable: 1, 495 }, 496 { name: 'avgSamples', label: 'Average', sortable: 1 }, 497 { name: 'stdDeviation', label: 'Standard Deviation', sortable: 1 }, 498 { name: 'totalSamples', label: 'Total', sortable: 1 }, 499 ]; 500 501 const groupsTotal = useMemo( 502 () => 503 groupsData.reduce((acc, current) => { 504 return acc + calculateTotal(current.data.samples); 505 }, 0), 506 [groupsData] 507 ); 508 509 const tableValuesData = calculateTableData({ 510 data: groupsData, 511 formatter, 512 profile: activeTagProfile, 513 }); 514 515 const tableIntegerSpaceLengthByColumn = 516 getTableIntegerSpaceLengthByColumn(tableValuesData); 517 518 const formattedTableData = tableValuesData.map((v) => { 519 const meanLength = getIntegerSpaceLengthForString(v.meanLabel); 520 const stdDeviationLength = getIntegerSpaceLengthForString( 521 v.stdDeviationLabel 522 ); 523 const totalLength = getIntegerSpaceLengthForString(v.totalLabel); 524 525 return { 526 ...v, 527 totalLabel: addSpaces( 528 tableIntegerSpaceLengthByColumn.total, 529 totalLength, 530 v.totalLabel 531 ), 532 stdDeviationLabel: addSpaces( 533 tableIntegerSpaceLengthByColumn.stdDeviation, 534 stdDeviationLength, 535 v.stdDeviationLabel 536 ), 537 meanLabel: addSpaces( 538 tableIntegerSpaceLengthByColumn.mean, 539 meanLength, 540 v.meanLabel 541 ), 542 }; 543 }); 544 545 const { sortByDirection, sortBy, updateSortParams } = useTableSort(headRow); 546 547 const sortedTableValuesData = (() => { 548 const m = sortByDirection === 'asc' ? 1 : -1; 549 let sorted: TableValuesData[] = []; 550 551 switch (sortBy) { 552 case 'name': 553 sorted = formattedTableData.sort( 554 (a, b) => m * a.tagName.localeCompare(b.tagName) 555 ); 556 break; 557 case 'totalSamples': 558 sorted = formattedTableData.sort((a, b) => m * (a.total - b.total)); 559 break; 560 case 'avgSamples': 561 sorted = formattedTableData.sort((a, b) => m * (a.mean - b.mean)); 562 break; 563 case 'stdDeviation': 564 sorted = formattedTableData.sort( 565 (a, b) => m * (a.stdDeviation - b.stdDeviation) 566 ); 567 break; 568 default: 569 sorted = formattedTableData; 570 } 571 572 return sorted; 573 })(); 574 575 const bodyRows = sortedTableValuesData.reduce( 576 ( 577 acc, 578 { tagName, color, total, totalLabel, stdDeviationLabel, meanLabel } 579 ): BodyRow[] => { 580 const percentage = (total / groupsTotal) * 100; 581 const row = { 582 isRowSelected: isTagSelected(tagName), 583 onClick: () => handleTableRowClick(tagName), 584 cells: [ 585 { 586 value: ( 587 <div className={styles.tagName}> 588 <span 589 className={styles.tagColor} 590 style={{ backgroundColor: color?.toString() }} 591 /> 592 <span className={styles.label}> 593 {tagName} 594 <span className={styles.bold}> 595 {`(${percentage.toFixed(2)}%)`} 596 </span> 597 </span> 598 </div> 599 ), 600 }, 601 { value: meanLabel }, 602 { value: stdDeviationLabel }, 603 { value: totalLabel }, 604 ], 605 }; 606 acc.push(row); 607 608 return acc; 609 }, 610 [] as BodyRow[] 611 ); 612 const table = { 613 headRow, 614 ...(isLoading 615 ? { type: 'not-filled' as const, value: <LoadingOverlay active /> } 616 : { type: 'filled' as const, bodyRows }), 617 }; 618 619 return ( 620 <div className={styles.tableWrapper}> 621 <div className={styles.tableDescription} data-testid="explore-table"> 622 <div className={styles.buttons}> 623 <NavLink 624 to={{ 625 pathname: PAGES.CONTINOUS_SINGLE_VIEW, 626 search: getSingleViewSearch(), 627 }} 628 exact 629 > 630 Single 631 </NavLink> 632 <TagsSelector 633 linkName="Comparison" 634 whereDropdownItems={whereDropdownItems} 635 groupByTag={groupByTag} 636 appName={appName} 637 /> 638 <TagsSelector 639 linkName="Diff" 640 whereDropdownItems={whereDropdownItems} 641 groupByTag={groupByTag} 642 appName={appName} 643 /> 644 </div> 645 </div> 646 <TableUI 647 updateSortParams={updateSortParams} 648 sortBy={sortBy} 649 sortByDirection={sortByDirection} 650 table={table} 651 className={styles.tagExplorerTable} 652 /> 653 </div> 654 ); 655 } 656 657 function ExploreHeader({ 658 appName, 659 whereDropdownItems, 660 tags, 661 selectedTag, 662 selectedTagValue, 663 handleGroupByTagChange, 664 handleGroupByTagValueChange, 665 }: { 666 appName: Maybe<string>; 667 whereDropdownItems: string[]; 668 tags: TagsState; 669 selectedTag: string; 670 selectedTagValue: string; 671 handleGroupByTagChange: (value: string) => void; 672 handleGroupByTagValueChange: (value: string) => void; 673 }) { 674 const tagKeys = Object.keys(tags.tags); 675 const groupByDropdownItems = 676 tagKeys.length > 0 ? tagKeys : ['No tags available']; 677 678 const handleGroupByClick = (e: ClickEvent) => { 679 handleGroupByTagChange(e.value); 680 }; 681 682 const handleGroupByValueClick = (e: ClickEvent) => { 683 handleGroupByTagValueChange(e.value); 684 }; 685 686 useEffect(() => { 687 if (tagKeys.length && !selectedTag) { 688 handleGroupByTagChange(tagKeys[0]); 689 } 690 }, [tagKeys, selectedTag]); 691 692 return ( 693 <div className={styles.header} data-testid="explore-header"> 694 <span className={styles.title}>{appName.unwrapOr('')}</span> 695 <div className={styles.queryGrouppedBy}> 696 <span className={styles.selectName}>grouped by</span> 697 <Dropdown 698 label="select tag" 699 value={selectedTag ? `tag: ${selectedTag}` : 'select tag'} 700 onItemClick={tagKeys.length > 0 ? handleGroupByClick : undefined} 701 menuButtonClassName={ 702 selectedTag === '' ? styles.notSelectedTagDropdown : undefined 703 } 704 > 705 {groupByDropdownItems.map((tagName) => ( 706 <MenuItem key={tagName} value={tagName}> 707 {tagName} 708 </MenuItem> 709 ))} 710 </Dropdown> 711 </div> 712 <div className={styles.query}> 713 <span className={styles.selectName}>where</span> 714 <Dropdown 715 label="select where" 716 value={`${selectedTag ? `${selectedTag} = ` : selectedTag} ${ 717 selectedTagValue || ALL_TAGS 718 }`} 719 onItemClick={handleGroupByValueClick} 720 menuButtonClassName={styles.whereSelectButton} 721 > 722 {/* always show "All" option */} 723 {[ALL_TAGS, ...whereDropdownItems].map((tagGroupName) => ( 724 <MenuItem key={tagGroupName} value={tagGroupName}> 725 {tagGroupName} 726 </MenuItem> 727 ))} 728 </Dropdown> 729 </div> 730 </div> 731 ); 732 } 733 734 export default TagExplorerView;