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                      &nbsp;{`(${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;