From c3941bfb42f50f54fe0ea8c404bf09deb515c412 Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Thu, 19 Dec 2024 13:45:37 +0100 Subject: [PATCH 1/8] feat(FlameGraph): Keep focus whenever the profile data changes --- package.json | 9 +- .../SceneFlameGraph.tsx | 3 +- .../domain/useGitHubIntegration.ts | 2 +- .../components/FlameGraph/FlameGraph.tsx | 3 +- src/tmp/grafana-flamegraph/.eslintrc | 9 + .../src/FlameGraph/FlameGraph.tsx | 212 +++++++++ .../src/FlameGraph/FlameGraphCanvas.tsx | 310 ++++++++++++ .../src/FlameGraph/FlameGraphContextMenu.tsx | 156 ++++++ .../src/FlameGraph/FlameGraphMetadata.tsx | 121 +++++ .../src/FlameGraph/FlameGraphTooltip.tsx | 213 +++++++++ .../src/FlameGraph/colors.ts | 138 ++++++ .../src/FlameGraph/dataTransform.ts | 423 +++++++++++++++++ .../src/FlameGraph/murmur3.ts | 84 ++++ .../src/FlameGraph/rendering.ts | 449 ++++++++++++++++++ .../src/FlameGraph/treeTransforms.ts | 131 +++++ .../src/FlameGraphContainer.tsx | 364 ++++++++++++++ .../src/FlameGraphHeader.tsx | 342 +++++++++++++ .../TopTable/FlameGraphTopTableContainer.tsx | 367 ++++++++++++++ src/tmp/grafana-flamegraph/src/constants.ts | 12 + src/tmp/grafana-flamegraph/src/index.ts | 2 + src/tmp/grafana-flamegraph/src/types.ts | 43 ++ .../src/types/emotion-core-stub.d.ts | 4 + yarn.lock | 339 +++++++++++-- 23 files changed, 3689 insertions(+), 47 deletions(-) create mode 100644 src/tmp/grafana-flamegraph/.eslintrc create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/colors.ts create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/dataTransform.ts create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/murmur3.ts create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/rendering.ts create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraph/treeTransforms.ts create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx create mode 100644 src/tmp/grafana-flamegraph/src/FlameGraphHeader.tsx create mode 100644 src/tmp/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx create mode 100644 src/tmp/grafana-flamegraph/src/constants.ts create mode 100644 src/tmp/grafana-flamegraph/src/index.ts create mode 100644 src/tmp/grafana-flamegraph/src/types.ts create mode 100644 src/tmp/grafana-flamegraph/src/types/emotion-core-stub.d.ts diff --git a/package.json b/package.json index c2e7b3f4..0d373e3f 100644 --- a/package.json +++ b/package.json @@ -38,16 +38,17 @@ "@emotion/css": "11.10.6", "@grafana/data": "11.3.2", "@grafana/faro-web-sdk": "^1.10.0", - "@grafana/flamegraph": "11.3.2", "@grafana/llm": "^0.10.7", "@grafana/runtime": "11.3.2", "@grafana/scenes": "^4.22.0", "@grafana/schema": "11.3.2", "@grafana/ui": "11.3.2", + "@leeoniya/ufuzzy": "^1.0.17", "@react-aria/utils": "^3.25.3", "@tanstack/react-query": "^5.17.19", "color": "^4.2.3", "compression-streams-polyfill": "^0.1.7", + "d3": "^7.9.0", "file-saver": "^2.0.5", "history": "^5.3.0", "markdown-to-jsx": "^7.3.2", @@ -59,7 +60,10 @@ "react-helmet": "^6.1.0", "react-inlinesvg": "^4.1.3", "react-router-dom": "^6.22.0", + "react-use": "^17.6.0", + "react-virtualized-auto-sizer": "^1.0.25", "rxjs": "7.8.1", + "tinycolor2": "^1.6.0", "tslib": "2.5.3", "xstate": "^4.38.3" }, @@ -79,18 +83,19 @@ "@testing-library/react-hooks": "^8.0.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/color": "^3.0.2", + "@types/d3": "^7", "@types/d3-scale": "^4.0.2", "@types/file-saver": "^2.0.7", "@types/flot": "^0.0.32", "@types/jest": "^29.5.0", "@types/jquery": "^3.5.16", - "@types/lodash": "^4.14.188", "@types/node": "^22.7.4", "@types/prismjs": "^1.26.0", "@types/react": "18.2.37", "@types/react-dom": "18.2.15", "@types/react-helmet": "^6.1.5", "@types/testing-library__jest-dom": "5.14.8", + "@types/tinycolor2": "^1", "@typescript-eslint/eslint-plugin": "6.18.1", "@typescript-eslint/parser": "6.18.1", "bundlewatch": "^0.4.0", diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx index 7f131df5..843ce856 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import { createTheme, GrafanaTheme2, LoadingState, TimeRange } from '@grafana/data'; -import { FlameGraph } from '@grafana/flamegraph'; import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneQueryRunner } from '@grafana/scenes'; import { Spinner, useStyles2, useTheme2 } from '@grafana/ui'; import { displayWarning } from '@shared/domain/displayStatus'; @@ -14,6 +13,7 @@ import { PyroscopeLogo } from '@shared/ui/PyroscopeLogo'; import React, { useEffect, useMemo } from 'react'; import { Unsubscribable } from 'rxjs'; +import { FlameGraph } from '../../../../tmp/grafana-flamegraph/src/'; import { useBuildPyroscopeQuery } from '../../domain/useBuildPyroscopeQuery'; import { getSceneVariableValue } from '../../helpers/getSceneVariableValue'; import { buildFlameGraphQueryRunner } from '../../infrastructure/flame-graph/buildFlameGraphQueryRunner'; @@ -201,6 +201,7 @@ export class SceneFlameGraph extends SceneObjectBase { timeRange={data.export.timeRange} /> } + keepFocusOnDataChange /> diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/components/SceneFunctionDetailsPanel/domain/useGitHubIntegration.ts b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/components/SceneFunctionDetailsPanel/domain/useGitHubIntegration.ts index 794c047e..395fd67b 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/components/SceneFunctionDetailsPanel/domain/useGitHubIntegration.ts +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/components/SceneFunctionDetailsPanel/domain/useGitHubIntegration.ts @@ -1,10 +1,10 @@ import { IconName } from '@grafana/data'; -import { Props as FlameGraphProps } from '@grafana/flamegraph'; import { reportInteraction } from '@shared/domain/reportInteraction'; import { useFetchPluginSettings } from '@shared/infrastructure/settings/useFetchPluginSettings'; import { DomainHookReturnValue } from '@shared/types/DomainHookReturnValue'; import { useCallback, useState } from 'react'; +import { Props as FlameGraphProps } from '../../../../../../../tmp/grafana-flamegraph/src'; import { useGitHubContext } from '../components/GitHubContextProvider/useGitHubContext'; import { buildStackTrace } from './buildStackTrace'; diff --git a/src/shared/components/FlameGraph/FlameGraph.tsx b/src/shared/components/FlameGraph/FlameGraph.tsx index 6703db26..dbf014e0 100644 --- a/src/shared/components/FlameGraph/FlameGraph.tsx +++ b/src/shared/components/FlameGraph/FlameGraph.tsx @@ -1,8 +1,8 @@ import { createTheme } from '@grafana/data'; -import { FlameGraph as GrafanaFlameGraph, Props } from '@grafana/flamegraph'; import { useTheme2 } from '@grafana/ui'; import React, { memo, useMemo } from 'react'; +import { FlameGraph as GrafanaFlameGraph, Props } from '../../../tmp/grafana-flamegraph/src/'; import type { FlamebearerProfile } from '../../types/FlamebearerProfile'; import { ExportData } from './components/ExportData'; import { flamebearerToDataFrameDTO } from './domain/flamebearerToDataFrameDTO'; @@ -46,6 +46,7 @@ function FlameGraphComponent({ vertical={vertical} getTheme={getTheme as any} getExtraContextMenuButtons={getExtraContextMenuButtons} + keepFocusOnDataChange /> ); } diff --git a/src/tmp/grafana-flamegraph/.eslintrc b/src/tmp/grafana-flamegraph/.eslintrc new file mode 100644 index 00000000..23985080 --- /dev/null +++ b/src/tmp/grafana-flamegraph/.eslintrc @@ -0,0 +1,9 @@ +{ + "rules": { + "no-unused-vars": "off", + "react/react-in-jsx-scope": "off", + "@grafana/no-border-radius-literal": "off", + "sonarjs/cognitive-complexity": "off", + "sonarjs/no-collapsible-if": "off" + } +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx new file mode 100644 index 00000000..3bcf79e2 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -0,0 +1,212 @@ +// This component is based on logic from the flamebearer project +// https://github.com/mapbox/flamebearer +// ISC License +// Copyright (c) 2018, Mapbox +// Permission to use, copy, modify, and/or distribute this software for any purpose +// with or without fee is hereby granted, provided that the above copyright notice +// and this permission notice appear in all copies. +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. +import * as React from 'react'; +import { css, cx } from '@emotion/css'; +import { Icon } from '@grafana/ui'; +import { useEffect, useState } from 'react'; + +import { PIXELS_PER_LEVEL } from '../constants'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types'; +import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; +import FlameGraphCanvas from './FlameGraphCanvas'; +import { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu'; +import FlameGraphMetadata from './FlameGraphMetadata'; + +type Props = { + data: FlameGraphDataContainer; + rangeMin: number; + rangeMax: number; + matchedLabels?: Set; + setRangeMin: (range: number) => void; + setRangeMax: (range: number) => void; + style?: React.CSSProperties; + onItemFocused: (data: ClickedItemData) => void; + focusedItemData?: ClickedItemData; + textAlign: TextAlign; + sandwichItem?: string; + onSandwich: (label: string) => void; + onFocusPillClick: () => void; + onSandwichPillClick: () => void; + colorScheme: ColorScheme | ColorSchemeDiff; + showFlameGraphOnly?: boolean; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + collapsing?: boolean; + selectedView: SelectedView; + search: string; + collapsedMap: CollapsedMap; + setCollapsedMap: (collapsedMap: CollapsedMap) => void; +}; + +const FlameGraph = ({ + data, + rangeMin, + rangeMax, + matchedLabels, + setRangeMin, + setRangeMax, + onItemFocused, + focusedItemData, + textAlign, + onSandwich, + sandwichItem, + onFocusPillClick, + onSandwichPillClick, + colorScheme, + showFlameGraphOnly, + getExtraContextMenuButtons, + collapsing, + selectedView, + search, + collapsedMap, + setCollapsedMap, +}: Props) => { + const styles = getStyles(); + + const [levels, setLevels] = useState(); + const [levelsCallers, setLevelsCallers] = useState(); + const [totalProfileTicks, setTotalProfileTicks] = useState(0); + const [totalProfileTicksRight, setTotalProfileTicksRight] = useState(); + const [totalViewTicks, setTotalViewTicks] = useState(0); + + useEffect(() => { + if (data) { + let levels = data.getLevels(); + let totalProfileTicks = levels.length ? levels[0][0].value : 0; + let totalProfileTicksRight = levels.length ? levels[0][0].valueRight : undefined; + let totalViewTicks = totalProfileTicks; + let levelsCallers = undefined; + + if (sandwichItem) { + const [callers, callees] = data.getSandwichLevels(sandwichItem); + levels = callees; + levelsCallers = callers; + // We need this separate as in case of diff profile we want to compute diff colors based on the original ticks. + totalViewTicks = callees[0]?.[0]?.value ?? 0; + } + setLevels(levels); + setLevelsCallers(levelsCallers); + setTotalProfileTicks(totalProfileTicks); + setTotalProfileTicksRight(totalProfileTicksRight); + setTotalViewTicks(totalViewTicks); + } + }, [data, sandwichItem]); + + if (!levels) { + return null; + } + + const commonCanvasProps = { + data, + rangeMin, + rangeMax, + matchedLabels, + setRangeMin, + setRangeMax, + onItemFocused, + focusedItemData, + textAlign, + onSandwich, + colorScheme, + totalProfileTicks, + totalProfileTicksRight, + totalViewTicks, + showFlameGraphOnly, + collapsedMap, + setCollapsedMap, + getExtraContextMenuButtons, + collapsing, + search, + selectedView, + }; + const canvas = levelsCallers ? ( + <> +
+
+ Callers + +
+ +
+ +
+
+ + Callees +
+ +
+ + ) : ( + + ); + + return ( +
+ + {canvas} +
+ ); +}; + +const getStyles = () => ({ + graph: css({ + label: 'graph', + overflow: 'auto', + flexGrow: 1, + flexBasis: '50%', + }), + sandwichCanvasWrapper: css({ + label: 'sandwichCanvasWrapper', + display: 'flex', + marginBottom: `${PIXELS_PER_LEVEL / window.devicePixelRatio}px`, + }), + sandwichMarker: css({ + label: 'sandwichMarker', + writingMode: 'vertical-lr', + transform: 'rotate(180deg)', + overflow: 'hidden', + whiteSpace: 'nowrap', + }), + sandwichMarkerCalees: css({ + label: 'sandwichMarkerCalees', + textAlign: 'right', + }), + sandwichMarkerIcon: css({ + label: 'sandwichMarkerIcon', + verticalAlign: 'baseline', + }), +}); + +export default FlameGraph; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx new file mode 100644 index 00000000..f0213de8 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx @@ -0,0 +1,310 @@ +import * as React from 'react'; +import { css } from '@emotion/css'; +import { MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { PIXELS_PER_LEVEL } from '../constants'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types'; +import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; +import FlameGraphContextMenu, { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu'; +import FlameGraphTooltip from './FlameGraphTooltip'; +import { getBarX, useFlameRender } from './rendering'; + +type Props = { + data: FlameGraphDataContainer; + rangeMin: number; + rangeMax: number; + matchedLabels: Set | undefined; + setRangeMin: (range: number) => void; + setRangeMax: (range: number) => void; + style?: React.CSSProperties; + onItemFocused: (data: ClickedItemData) => void; + focusedItemData?: ClickedItemData; + textAlign: TextAlign; + onSandwich: (label: string) => void; + colorScheme: ColorScheme | ColorSchemeDiff; + + root: LevelItem; + direction: 'children' | 'parents'; + // Depth in number of levels + depth: number; + + totalProfileTicks: number; + totalProfileTicksRight?: number; + totalViewTicks: number; + showFlameGraphOnly?: boolean; + + collapsedMap: CollapsedMap; + setCollapsedMap: (collapsedMap: CollapsedMap) => void; + collapsing?: boolean; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + + selectedView: SelectedView; + search: string; +}; + +const FlameGraphCanvas = ({ + data, + rangeMin, + rangeMax, + matchedLabels, + setRangeMin, + setRangeMax, + onItemFocused, + focusedItemData, + textAlign, + onSandwich, + colorScheme, + totalProfileTicks, + totalProfileTicksRight, + totalViewTicks, + root, + direction, + depth, + showFlameGraphOnly, + collapsedMap, + setCollapsedMap, + collapsing, + getExtraContextMenuButtons, + selectedView, + search, +}: Props) => { + const styles = getStyles(); + + const [sizeRef, { width: wrapperWidth }] = useMeasure(); + const graphRef = useRef(null); + const [tooltipItem, setTooltipItem] = useState(); + + const [clickedItemData, setClickedItemData] = useState(); + + useFlameRender({ + canvasRef: graphRef, + colorScheme, + data, + focusedItemData, + root, + direction, + depth, + rangeMax, + rangeMin, + matchedLabels, + textAlign, + totalViewTicks, + // We need this so that if we have a diff profile and are in sandwich view we still show the same diff colors. + totalColorTicks: data.isDiffFlamegraph() ? totalProfileTicks : totalViewTicks, + totalTicksRight: totalProfileTicksRight, + wrapperWidth, + collapsedMap, + }); + + const onGraphClick = useCallback( + (e: ReactMouseEvent) => { + setTooltipItem(undefined); + const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin); + const item = convertPixelCoordinatesToBarCoordinates( + { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, + root, + direction, + depth, + pixelsPerTick, + totalViewTicks, + rangeMin, + collapsedMap + ); + + // if clicking on a block in the canvas + if (item) { + setClickedItemData({ + posY: e.clientY, + posX: e.clientX, + item, + label: data.getLabel(item.itemIndexes[0]), + }); + } else { + // if clicking on the canvas but there is no block beneath the cursor + setClickedItemData(undefined); + } + }, + [data, rangeMin, rangeMax, totalViewTicks, root, direction, depth, collapsedMap] + ); + + const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>(); + const onGraphMouseMove = useCallback( + (e: ReactMouseEvent) => { + if (clickedItemData === undefined) { + setTooltipItem(undefined); + setMousePosition(undefined); + const pixelsPerTick = graphRef.current!.clientWidth / totalViewTicks / (rangeMax - rangeMin); + const item = convertPixelCoordinatesToBarCoordinates( + { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, + root, + direction, + depth, + pixelsPerTick, + totalViewTicks, + rangeMin, + collapsedMap + ); + + if (item) { + setMousePosition({ x: e.clientX, y: e.clientY }); + setTooltipItem(item); + } + } + }, + [rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth, collapsedMap] + ); + + const onGraphMouseLeave = useCallback(() => { + setTooltipItem(undefined); + }, []); + + // hide context menu if outside the flame graph canvas is clicked + useEffect(() => { + const handleOnClick = (e: MouseEvent) => { + if ( + e.target instanceof HTMLElement && + e.target.parentElement?.id !== 'flameGraphCanvasContainer_clickOutsideCheck' + ) { + setClickedItemData(undefined); + } + }; + window.addEventListener('click', handleOnClick); + return () => window.removeEventListener('click', handleOnClick); + }, [setClickedItemData]); + + return ( +
+
+ +
+ + {!showFlameGraphOnly && clickedItemData && ( + { + setClickedItemData(undefined); + }} + onItemFocus={() => { + setRangeMin(clickedItemData.item.start / totalViewTicks); + setRangeMax((clickedItemData.item.start + clickedItemData.item.value) / totalViewTicks); + onItemFocused(clickedItemData); + }} + onSandwich={() => { + onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0])); + }} + onExpandGroup={() => { + setCollapsedMap(collapsedMap.setCollapsedStatus(clickedItemData.item, false)); + }} + onCollapseGroup={() => { + setCollapsedMap(collapsedMap.setCollapsedStatus(clickedItemData.item, true)); + }} + onExpandAllGroups={() => { + setCollapsedMap(collapsedMap.setAllCollapsedStatus(false)); + }} + onCollapseAllGroups={() => { + setCollapsedMap(collapsedMap.setAllCollapsedStatus(true)); + }} + allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)} + allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)} + getExtraContextMenuButtons={getExtraContextMenuButtons} + selectedView={selectedView} + search={search} + /> + )} +
+ ); +}; + +const getStyles = () => ({ + graph: css({ + label: 'graph', + overflow: 'auto', + flexGrow: 1, + flexBasis: '50%', + }), + canvasContainer: css({ + label: 'canvasContainer', + display: 'flex', + }), + canvasWrapper: css({ + label: 'canvasWrapper', + cursor: 'pointer', + flex: 1, + overflow: 'hidden', + }), + sandwichMarker: css({ + label: 'sandwichMarker', + writingMode: 'vertical-lr', + transform: 'rotate(180deg)', + overflow: 'hidden', + whiteSpace: 'nowrap', + }), + sandwichMarkerIcon: css({ + label: 'sandwichMarkerIcon', + verticalAlign: 'baseline', + }), +}); + +export const convertPixelCoordinatesToBarCoordinates = ( + // position relative to the start of the graph + pos: { x: number; y: number }, + root: LevelItem, + direction: 'children' | 'parents', + depth: number, + pixelsPerTick: number, + totalTicks: number, + rangeMin: number, + collapsedMap: CollapsedMap +): LevelItem | undefined => { + let next: LevelItem | undefined = root; + let currentLevel = direction === 'children' ? 0 : depth - 1; + const levelIndex = Math.floor(pos.y / (PIXELS_PER_LEVEL / window.devicePixelRatio)); + let found = undefined; + + while (next) { + const node: LevelItem = next; + next = undefined; + if (currentLevel === levelIndex) { + found = node; + break; + } + + const nextList = direction === 'children' ? node.children : node.parents || []; + + for (const child of nextList) { + const xStart = getBarX(child.start, totalTicks, rangeMin, pixelsPerTick); + const xEnd = getBarX(child.start + child.value, totalTicks, rangeMin, pixelsPerTick); + if (xStart <= pos.x && pos.x < xEnd) { + next = child; + // Check if item is a collapsed item. if so also check if the item is the first collapsed item in the chain, + // which we render, or a child which we don't render. If it's a child in the chain then don't increase the + // level end effectively skip it. + const collapsedConfig = collapsedMap.get(child); + if (!collapsedConfig || !collapsedConfig.collapsed || collapsedConfig.items[0] === child) { + currentLevel = currentLevel + (direction === 'children' ? 1 : -1); + } + break; + } + } + } + + return found; +}; + +export default FlameGraphCanvas; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx new file mode 100644 index 00000000..14b58469 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphContextMenu.tsx @@ -0,0 +1,156 @@ +import { DataFrame } from '@grafana/data'; +import { ContextMenu, IconName, MenuGroup, MenuItem } from '@grafana/ui'; +import React from 'react'; + +import { ClickedItemData, SelectedView } from '../types'; +import { CollapseConfig, FlameGraphDataContainer } from './dataTransform'; + +export type GetExtraContextMenuButtonsFunction = ( + clickedItemData: ClickedItemData, + data: DataFrame, + state: { selectedView: SelectedView; isDiff: boolean; search: string; collapseConfig?: CollapseConfig } +) => ExtraContextMenuButton[]; + +export type ExtraContextMenuButton = { + label: string; + icon: IconName; + onClick: () => void; +}; + +type Props = { + data: FlameGraphDataContainer; + itemData: ClickedItemData; + onMenuItemClick: () => void; + onItemFocus: () => void; + onSandwich: () => void; + onExpandGroup: () => void; + onCollapseGroup: () => void; + onExpandAllGroups: () => void; + onCollapseAllGroups: () => void; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + collapseConfig?: CollapseConfig; + collapsing?: boolean; + allGroupsCollapsed?: boolean; + allGroupsExpanded?: boolean; + selectedView: SelectedView; + search: string; +}; + +const FlameGraphContextMenu = ({ + data, + itemData, + onMenuItemClick, + onItemFocus, + onSandwich, + collapseConfig, + onExpandGroup, + onCollapseGroup, + onExpandAllGroups, + onCollapseAllGroups, + getExtraContextMenuButtons, + collapsing, + allGroupsExpanded, + allGroupsCollapsed, + selectedView, + search, +}: Props) => { + function renderItems() { + const extraButtons = + getExtraContextMenuButtons?.(itemData, data.data, { + selectedView, + isDiff: data.isDiffFlamegraph(), + search, + collapseConfig, + }) || []; + return ( + <> + { + onItemFocus(); + onMenuItemClick(); + }} + /> + { + navigator.clipboard.writeText(itemData.label).then(() => { + onMenuItemClick(); + }); + }} + /> + { + onSandwich(); + onMenuItemClick(); + }} + /> + {extraButtons.map(({ label, icon, onClick }) => { + return onClick()} key={label} />; + })} + {collapsing && ( + + {collapseConfig ? ( + collapseConfig.collapsed ? ( + { + onExpandGroup(); + onMenuItemClick(); + }} + /> + ) : ( + { + onCollapseGroup(); + onMenuItemClick(); + }} + /> + ) + ) : null} + {!allGroupsExpanded && ( + { + onExpandAllGroups(); + onMenuItemClick(); + }} + /> + )} + {!allGroupsCollapsed && ( + { + onCollapseAllGroups(); + onMenuItemClick(); + }} + /> + )} + + )} + + ); + } + + return ( +
+ +
+ ); +}; + +export default FlameGraphContextMenu; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx new file mode 100644 index 00000000..e624a199 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import { getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { Icon, IconButton, useStyles2 } from '@grafana/ui'; +import React, { memo, ReactNode } from 'react'; + +import { ClickedItemData } from '../types'; +import { FlameGraphDataContainer } from './dataTransform'; + +type Props = { + data: FlameGraphDataContainer; + totalTicks: number; + onFocusPillClick: () => void; + onSandwichPillClick: () => void; + focusedItem?: ClickedItemData; + sandwichedLabel?: string; +}; + +const FlameGraphMetadata = memo( + ({ data, focusedItem, totalTicks, sandwichedLabel, onFocusPillClick, onSandwichPillClick }: Props) => { + const styles = useStyles2(getStyles); + const parts: ReactNode[] = []; + const ticksVal = getValueFormat('short')(totalTicks); + + const displayValue = data.valueDisplayProcessor(totalTicks); + let unitValue = displayValue.text + displayValue.suffix; + const unitTitle = data.getUnitTitle(); + if (unitTitle === 'Count') { + if (!displayValue.suffix) { + // Makes sure we don't show 123undefined or something like that if suffix isn't defined + unitValue = displayValue.text; + } + } + + parts.push( +
+ {unitValue} | {ticksVal.text} + {ticksVal.suffix} samples ({unitTitle}) +
+ ); + + if (sandwichedLabel) { + parts.push( + + +
+ {' '} + + {sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)} + + +
+
+ ); + } + + if (focusedItem) { + const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100; + parts.push( + + +
+ {percentValue}% of total + +
+
+ ); + } + + return
{parts}
; + } +); + +FlameGraphMetadata.displayName = 'FlameGraphMetadata'; + +const getStyles = (theme: GrafanaTheme2) => ({ + metadataPill: css({ + label: 'metadataPill', + display: 'inline-flex', + alignItems: 'center', + background: theme.colors.background.secondary, + borderRadius: theme.shape.borderRadius(8), + padding: theme.spacing(0.5, 1), + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + lineHeight: theme.typography.bodySmall.lineHeight, + color: theme.colors.text.secondary, + }), + pillCloseButton: css({ + label: 'pillCloseButton', + verticalAlign: 'text-bottom', + margin: theme.spacing(0, 0.5), + }), + metadata: css({ + margin: '8px 0', + textAlign: 'center', + }), + metadataPillName: css({ + label: 'metadataPillName', + maxWidth: '200px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginLeft: theme.spacing(0.5), + }), +}); + +export default FlameGraphMetadata; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx new file mode 100644 index 00000000..ed23d54f --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphTooltip.tsx @@ -0,0 +1,213 @@ +import { css } from '@emotion/css'; +import { DisplayValue, getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { InteractiveTable, Portal, useStyles2, VizTooltipContainer } from '@grafana/ui'; +import React from 'react'; + +import { CollapseConfig, FlameGraphDataContainer, LevelItem } from './dataTransform'; + +type Props = { + data: FlameGraphDataContainer; + totalTicks: number; + position?: { x: number; y: number }; + item?: LevelItem; + collapseConfig?: CollapseConfig; +}; + +const FlameGraphTooltip = ({ data, item, totalTicks, position, collapseConfig }: Props) => { + const styles = useStyles2(getStyles); + + if (!(item && position)) { + return null; + } + + let content; + + if (data.isDiffFlamegraph()) { + const tableData = getDiffTooltipData(data, item, totalTicks); + content = ( + originalRow.rowId} + /> + ); + } else { + const tooltipData = getTooltipData(data, item, totalTicks); + content = ( +

+ {tooltipData.unitTitle} +
+ Total: {tooltipData.unitValue} ({tooltipData.percentValue}%) +
+ Self: {tooltipData.unitSelf} ({tooltipData.percentSelf}%) +
+ Samples: {tooltipData.samples} +

+ ); + } + + return ( + + +
+

+ {data.getLabel(item.itemIndexes[0])} + {collapseConfig && collapseConfig.collapsed ? ( + +
+ and {collapseConfig.items.length} similar items +
+ ) : ( + '' + )} +

+ {content} +
+
+
+ ); +}; + +type TooltipData = { + percentValue: number; + percentSelf: number; + unitTitle: string; + unitValue: string; + unitSelf: string; + samples: string; +}; + +export const getTooltipData = (data: FlameGraphDataContainer, item: LevelItem, totalTicks: number): TooltipData => { + const displayValue = data.valueDisplayProcessor(item.value); + const displaySelf = data.getSelfDisplay(item.itemIndexes); + + const percentValue = Math.round(10000 * (displayValue.numeric / totalTicks)) / 100; + const percentSelf = Math.round(10000 * (displaySelf.numeric / totalTicks)) / 100; + let unitValue = displayValue.text + displayValue.suffix; + let unitSelf = displaySelf.text + displaySelf.suffix; + + const unitTitle = data.getUnitTitle(); + if (unitTitle === 'Count') { + if (!displayValue.suffix) { + // Makes sure we don't show 123undefined or something like that if suffix isn't defined + unitValue = displayValue.text; + } + if (!displaySelf.suffix) { + // Makes sure we don't show 123undefined or something like that if suffix isn't defined + unitSelf = displaySelf.text; + } + } + + return { + percentValue, + percentSelf, + unitTitle, + unitValue, + unitSelf, + samples: displayValue.numeric.toLocaleString(), + }; +}; + +type DiffTableData = { + rowId: string; + label: string; + baseline: string | number; + comparison: string | number; + diff: string | number; +}; + +export const getDiffTooltipData = ( + data: FlameGraphDataContainer, + item: LevelItem, + totalTicks: number +): DiffTableData[] => { + const levels = data.getLevels(); + const totalTicksRight = levels[0][0].valueRight!; + const totalTicksLeft = totalTicks - totalTicksRight; + const valueLeft = item.value - item.valueRight!; + + const percentageLeft = Math.round((10000 * valueLeft) / totalTicksLeft) / 100; + const percentageRight = Math.round((10000 * item.valueRight!) / totalTicksRight) / 100; + + const diff = ((percentageRight - percentageLeft) / percentageLeft) * 100; + + const displayValueLeft = getValueWithUnit(data, data.valueDisplayProcessor(valueLeft)); + const displayValueRight = getValueWithUnit(data, data.valueDisplayProcessor(item.valueRight!)); + + const shortValFormat = getValueFormat('short'); + + return [ + { + rowId: '1', + label: '% of total', + baseline: percentageLeft + '%', + comparison: percentageRight + '%', + diff: shortValFormat(diff).text + '%', + }, + { + rowId: '2', + label: 'Value', + baseline: displayValueLeft, + comparison: displayValueRight, + diff: getValueWithUnit(data, data.valueDisplayProcessor(item.valueRight! - valueLeft)), + }, + { + rowId: '3', + label: 'Samples', + baseline: shortValFormat(valueLeft).text, + comparison: shortValFormat(item.valueRight!).text, + diff: shortValFormat(item.valueRight! - valueLeft).text, + }, + ]; +}; + +function getValueWithUnit(data: FlameGraphDataContainer, displayValue: DisplayValue) { + let unitValue = displayValue.text + displayValue.suffix; + + const unitTitle = data.getUnitTitle(); + if (unitTitle === 'Count') { + if (!displayValue.suffix) { + // Makes sure we don't show 123undefined or something like that if suffix isn't defined + unitValue = displayValue.text; + } + } + return unitValue; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + tooltipContainer: css({ + title: 'tooltipContainer', + overflow: 'hidden', + }), + tooltipContent: css({ + title: 'tooltipContent', + fontSize: theme.typography.bodySmall.fontSize, + width: '100%', + }), + tooltipName: css({ + title: 'tooltipName', + marginTop: 0, + wordBreak: 'break-all', + }), + lastParagraph: css({ + title: 'lastParagraph', + marginBottom: 0, + }), + name: css({ + title: 'name', + marginBottom: '10px', + }), + + tooltipTable: css({ + title: 'tooltipTable', + maxWidth: '400px', + }), +}); + +export default FlameGraphTooltip; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/colors.ts b/src/tmp/grafana-flamegraph/src/FlameGraph/colors.ts new file mode 100644 index 00000000..c2da59c5 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/colors.ts @@ -0,0 +1,138 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import { scaleLinear } from 'd3'; +import color from 'tinycolor2'; + +import { ColorSchemeDiff } from '../types'; +import murmurhash3_32_gc from './murmur3'; + +// Colors taken from pyroscope, they should be from Grafana originally, but I didn't find from where exactly. +const packageColors = [ + color({ h: 24, s: 69, l: 60 }), + color({ h: 34, s: 65, l: 65 }), + color({ h: 194, s: 52, l: 61 }), + color({ h: 163, s: 45, l: 55 }), + color({ h: 211, s: 48, l: 60 }), + color({ h: 246, s: 40, l: 65 }), + color({ h: 305, s: 63, l: 79 }), + color({ h: 47, s: 100, l: 73 }), + + color({ r: 183, g: 219, b: 171 }), + color({ r: 244, g: 213, b: 152 }), + color({ r: 78, g: 146, b: 249 }), + color({ r: 249, g: 186, b: 143 }), + color({ r: 242, g: 145, b: 145 }), + color({ r: 130, g: 181, b: 216 }), + color({ r: 229, g: 168, b: 226 }), + color({ r: 174, g: 162, b: 224 }), + color({ r: 154, g: 196, b: 138 }), + color({ r: 242, g: 201, b: 109 }), + color({ r: 101, g: 197, b: 219 }), + color({ r: 249, g: 147, b: 78 }), + color({ r: 234, g: 100, b: 96 }), + color({ r: 81, g: 149, b: 206 }), + color({ r: 214, g: 131, b: 206 }), + color({ r: 128, g: 110, b: 183 }), +]; + +const byValueMinColor = getBarColorByValue(1, 100, 0, 1); +const byValueMaxColor = getBarColorByValue(100, 100, 0, 1); +export const byValueGradient = `linear-gradient(90deg, ${byValueMinColor} 0%, ${byValueMaxColor} 100%)`; + +// Handpicked some vaguely rainbow-ish colors +export const byPackageGradient = `linear-gradient(90deg, ${packageColors[0]} 0%, ${packageColors[2]} 30%, ${packageColors[6]} 50%, ${packageColors[7]} 70%, ${packageColors[8]} 100%)`; + +export function getBarColorByValue(value: number, totalTicks: number, rangeMin: number, rangeMax: number) { + // / (rangeMax - rangeMin) here so when you click a bar it will adjust the top (clicked)bar to the most 'intense' color + const intensity = Math.min(1, value / totalTicks / (rangeMax - rangeMin)); + const h = 50 - 50 * intensity; + const l = 65 + 7 * intensity; + + return color({ h, s: 100, l }); +} + +export function getBarColorByPackage(label: string, theme: GrafanaTheme2) { + const packageName = getPackageName(label); + // TODO: similar thing happens in trace view with selecting colors of the spans, so maybe this could be unified. + const hash = murmurhash3_32_gc(packageName || '', 0); + const colorIndex = hash % packageColors.length; + let packageColor = packageColors[colorIndex].clone(); + if (theme.isLight) { + packageColor = packageColor.brighten(15); + } + return packageColor; +} + +// green to red +export const diffDefaultColors = ['rgb(0, 170, 0)', 'rgb(148, 142, 142)', 'rgb(200, 0, 0)']; +export const diffDefaultGradient = `linear-gradient(90deg, ${diffDefaultColors[0]} 0%, ${diffDefaultColors[1]} 50%, ${diffDefaultColors[2]} 100%)`; +export const diffColorBlindColors = ['rgb(26, 133, 255)', 'rgb(148, 142, 142)', 'rgb(220, 50, 32)']; +export const diffColorBlindGradient = `linear-gradient(90deg, ${diffColorBlindColors[0]} 0%, ${diffColorBlindColors[1]} 50%, ${diffColorBlindColors[2]} 100%)`; + +export function getBarColorByDiff( + ticks: number, + ticksRight: number, + totalTicks: number, + totalTicksRight: number, + colorScheme: ColorSchemeDiff +) { + const range = colorScheme === ColorSchemeDiff.Default ? diffDefaultColors : diffColorBlindColors; + const colorScale = scaleLinear() + .domain([-100, 0, 100]) + // TODO types from DefinitelyTyped seem to mismatch + // @ts-ignore + .range(range); + + const ticksLeft = ticks - ticksRight; + const totalTicksLeft = totalTicks - totalTicksRight; + + if (totalTicksRight === 0 || totalTicksLeft === 0) { + // TODO types from DefinitelyTyped seem to mismatch + // @ts-ignore + const rgbString: string = colorScale(0); + // Fallback to neutral color as we probably have no data for one of the sides. + return color(rgbString); + } + + const percentageLeft = Math.round((10000 * ticksLeft) / totalTicksLeft) / 100; + const percentageRight = Math.round((10000 * ticksRight) / totalTicksRight) / 100; + + const diff = ((percentageRight - percentageLeft) / percentageLeft) * 100; + + // TODO types from DefinitelyTyped seem to mismatch + // @ts-ignore + const rgbString: string = colorScale(diff); + return color(rgbString); +} + +// const getColors = memoizeOne((theme) => getFilteredColors(colors, theme)); + +// Different regexes to get the package name and function name from the label. We may at some point get an info about +// the language from the backend and use the right regex but right now we just try all of them from most to least +// specific. +const matchers = [ + ['phpspy', /^(?([^\/]*\/)*)(?.*\.php+)(?.*)$/], + ['pyspy', /^(?([^\/]*\/)*)(?.*\.py+)(?.*)$/], + ['rbspy', /^(?([^\/]*\/)*)(?.*\.rb+)(?.*)$/], + [ + 'nodespy', + /^(\.\/node_modules\/)?(?[^/]*)(?.*\.?(jsx?|tsx?)?):(?.*):(?.*)$/, + ], + ['gospy', /^(?.*?\/.*?\.|.*?\.|.+)(?.*)$/], // also 'scrape' + ['javaspy', /^(?.+\/)(?.+\.)(?.+)$/], + ['dotnetspy', /^(?.+)\.(.+)\.(.+)\(.*\)$/], + ['tracing', /^(?.+?):.*$/], + ['pyroscope-rs', /^(?[^::]+)/], + ['ebpfspy', /^(?.+)$/], + ['unknown', /^(?.+)$/], +]; + +// Get the package name from the symbol. Try matchers from the list and return first one that matches. +function getPackageName(name: string): string | undefined { + for (const [_, matcher] of matchers) { + const match = name.match(matcher); + if (match) { + return match.groups?.packageName || ''; + } + } + return undefined; +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/dataTransform.ts b/src/tmp/grafana-flamegraph/src/FlameGraph/dataTransform.ts new file mode 100644 index 00000000..0706e763 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/dataTransform.ts @@ -0,0 +1,423 @@ +import { + createTheme, + DataFrame, + DisplayProcessor, + Field, + FieldType, + getDisplayProcessor, + GrafanaTheme2, +} from '@grafana/data'; + +import { SampleUnit } from '../types'; +import { mergeParentSubtrees, mergeSubtrees } from './treeTransforms'; + +export type LevelItem = { + // Offset from the start of the level. + start: number; + // Value here can be different from a value of items in the data frame as for callers tree in sandwich view we have + // to trim the value to correspond only to the part used by the children in the subtree. + // In case of diff profile this is actually left + right value. + value: number; + // Only exists for diff profiles. + valueRight?: number; + // Index into the data frame. It is an array because for sandwich views we may be merging multiple items into single + // node. + itemIndexes: number[]; + children: LevelItem[]; + level: number; + parents?: LevelItem[]; +}; + +export type CollapseConfig = { + items: LevelItem[]; + collapsed: boolean; +}; + +/** + * Convert data frame with nested set format into array of level. This is mainly done for compatibility with current + * rendering code. + */ +export function nestedSetToLevels( + container: FlameGraphDataContainer, + options?: Options +): [LevelItem[][], Record, CollapsedMap] { + const levels: LevelItem[][] = []; + let offset = 0; + + let parent: LevelItem | undefined = undefined; + const uniqueLabels: Record = {}; + + for (let i = 0; i < container.data.length; i++) { + const currentLevel = container.getLevel(i); + const prevLevel = i > 0 ? container.getLevel(i - 1) : undefined; + + levels[currentLevel] = levels[currentLevel] || []; + + if (prevLevel && prevLevel >= currentLevel) { + // We are going down a level or staying at the same level, so we are adding a sibling to the last item in a level. + // So we have to compute the correct offset based on the last sibling. + const lastSibling = levels[currentLevel][levels[currentLevel].length - 1]; + offset = + lastSibling.start + + container.getValue(lastSibling.itemIndexes[0]) + + container.getValueRight(lastSibling.itemIndexes[0]); + // we assume there is always a single root node so lastSibling should always have a parent. + // Also it has to have the same parent because of how the items are ordered. + parent = lastSibling.parents![0]; + } + + const newItem: LevelItem = { + itemIndexes: [i], + value: container.getValue(i) + container.getValueRight(i), + valueRight: container.isDiffFlamegraph() ? container.getValueRight(i) : undefined, + start: offset, + parents: parent && [parent], + children: [], + level: currentLevel, + }; + + if (uniqueLabels[container.getLabel(i)]) { + uniqueLabels[container.getLabel(i)].push(newItem); + } else { + uniqueLabels[container.getLabel(i)] = [newItem]; + } + + if (parent) { + parent.children.push(newItem); + } + + parent = newItem; + levels[currentLevel].push(newItem); + } + + const collapsedMapContainer = new CollapsedMapBuilder(options?.collapsingThreshold); + if (options?.collapsing) { + // We collapse similar items here, where it seems like parent and child are the same thing and so the distinction + // isn't that important. We create a map of items that should be collapsed together. We need to do it with complete + // tree as we need to know how many children an item has to know if we can collapse it. + collapsedMapContainer.addTree(levels[0][0]); + } + + return [levels, uniqueLabels, collapsedMapContainer.getCollapsedMap()]; +} + +/** + * Small wrapper around the map of items that should be visually collapsed in the flame graph. Reason this is a wrapper + * is that we want to make sure that when this is in the state we don't update the map directly but create a new map + * and to have a place for the methods to collapse/expand either single item or all the items. + */ +export class CollapsedMap { + // The levelItem used as a key is the item that will always be rendered in the flame graph. The config.items are all + // the items that are in the group and if the config.collapsed is true they will be hidden. + private map: Map = new Map(); + + constructor(map?: Map) { + this.map = map || new Map(); + } + + get(item: LevelItem) { + return this.map.get(item); + } + + keys() { + return this.map.keys(); + } + + values() { + return this.map.values(); + } + + size() { + return this.map.size; + } + + setCollapsedStatus(item: LevelItem, collapsed: boolean) { + const newMap = new Map(this.map); + const collapsedConfig = this.map.get(item)!; + const newConfig = { ...collapsedConfig, collapsed }; + for (const item of collapsedConfig.items) { + newMap.set(item, newConfig); + } + return new CollapsedMap(newMap); + } + + setAllCollapsedStatus(collapsed: boolean) { + const newMap = new Map(this.map); + for (const item of this.map.keys()) { + const collapsedConfig = this.map.get(item)!; + const newConfig = { ...collapsedConfig, collapsed }; + newMap.set(item, newConfig); + } + + return new CollapsedMap(newMap); + } +} + +/** + * Similar to CollapsedMap but this one is mutable and used during transformation of the dataFrame data into structure + * we use for rendering. This should not be passed to the React components. + */ +export class CollapsedMapBuilder { + private map = new Map(); + private threshold = 0.99; + + constructor(threshold?: number) { + if (threshold !== undefined) { + this.threshold = threshold; + } + } + + addTree(root: LevelItem) { + const stack = [root]; + while (stack.length) { + const current = stack.shift()!; + + if (current.parents?.length) { + this.addItem(current, current.parents[0]); + } + + if (current.children.length) { + stack.unshift(...current.children); + } + } + } + + // The heuristics here is pretty simple right now. Just check if it's single child and if we are within threshold. + // We assume items with small self just aren't too important while we cannot really collapse items with siblings + // as it's not clear what to do with said sibling. + addItem(item: LevelItem, parent?: LevelItem) { + if (parent && item.value > parent.value * this.threshold && parent.children.length === 1) { + if (this.map.has(parent)) { + const config = this.map.get(parent)!; + this.map.set(item, config); + config.items.push(item); + } else { + const config = { items: [parent, item], collapsed: true }; + this.map.set(parent, config); + this.map.set(item, config); + } + } + } + + getCollapsedMap() { + return new CollapsedMap(this.map); + } +} + +export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) { + if (wrongFields.missingFields.length) { + return `Data is missing fields: ${wrongFields.missingFields.join(', ')}`; + } + + if (wrongFields.wrongTypeFields.length) { + return `Data has fields of wrong type: ${wrongFields.wrongTypeFields + .map((f) => `${f.name} has type ${f.type} but should be ${f.expectedTypes.join(' or ')}`) + .join(', ')}`; + } + + return ''; +} + +export type CheckFieldsResult = { + wrongTypeFields: Array<{ name: string; expectedTypes: FieldType[]; type: FieldType }>; + missingFields: string[]; +}; + +export function checkFields(data: DataFrame): CheckFieldsResult | undefined { + const fields: Array<[string, FieldType[]]> = [ + ['label', [FieldType.string, FieldType.enum]], + ['level', [FieldType.number]], + ['value', [FieldType.number]], + ['self', [FieldType.number]], + ]; + + const missingFields = []; + const wrongTypeFields = []; + + for (const field of fields) { + const [name, types] = field; + const frameField = data?.fields.find((f) => f.name === name); + if (!frameField) { + missingFields.push(name); + continue; + } + if (!types.includes(frameField.type)) { + wrongTypeFields.push({ name, expectedTypes: types, type: frameField.type }); + } + } + + if (missingFields.length > 0 || wrongTypeFields.length > 0) { + return { + wrongTypeFields, + missingFields, + }; + } + return undefined; +} + +export type Options = { + collapsing: boolean; + collapsingThreshold?: number; +}; + +export class FlameGraphDataContainer { + data: DataFrame; + options: Options; + + labelField: Field; + levelField: Field; + valueField: Field; + selfField: Field; + + // Optional fields for diff view + valueRightField?: Field; + selfRightField?: Field; + + labelDisplayProcessor: DisplayProcessor; + valueDisplayProcessor: DisplayProcessor; + uniqueLabels: string[]; + + private levels: LevelItem[][] | undefined; + private uniqueLabelsMap: Record | undefined; + private collapsedMap: CollapsedMap | undefined; + + constructor(data: DataFrame, options: Options, theme: GrafanaTheme2 = createTheme()) { + this.data = data; + this.options = options; + + const wrongFields = checkFields(data); + if (wrongFields) { + throw new Error(getMessageCheckFieldsResult(wrongFields)); + } + + this.labelField = data.fields.find((f) => f.name === 'label')!; + this.levelField = data.fields.find((f) => f.name === 'level')!; + this.valueField = data.fields.find((f) => f.name === 'value')!; + this.selfField = data.fields.find((f) => f.name === 'self')!; + + this.valueRightField = data.fields.find((f) => f.name === 'valueRight')!; + this.selfRightField = data.fields.find((f) => f.name === 'selfRight')!; + + if ((this.valueField || this.selfField) && !(this.valueField && this.selfField)) { + throw new Error( + 'Malformed dataFrame: both valueRight and selfRight has to be present if one of them is present.' + ); + } + + const enumConfig = this.labelField?.config?.type?.enum; + // Label can actually be an enum field so depending on that we have to access it through display processor. This is + // both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow + // users to use this panel with correct query from data sources that do not return profiles natively. + if (enumConfig) { + this.labelDisplayProcessor = getDisplayProcessor({ field: this.labelField, theme }); + this.uniqueLabels = enumConfig.text || []; + } else { + this.labelDisplayProcessor = (value) => ({ + text: value + '', + numeric: 0, + }); + this.uniqueLabels = [...new Set(this.labelField.values)]; + } + + this.valueDisplayProcessor = getDisplayProcessor({ + field: this.valueField, + theme, + }); + } + + isDiffFlamegraph() { + return Boolean(this.valueRightField && this.selfRightField); + } + + getLabel(index: number) { + return this.labelDisplayProcessor(this.labelField.values[index]).text; + } + + getLevel(index: number) { + return this.levelField.values[index]; + } + + getValue(index: number | number[]) { + return fieldAccessor(this.valueField, index); + } + + getValueRight(index: number | number[]) { + return fieldAccessor(this.valueRightField, index); + } + + getSelf(index: number | number[]) { + return fieldAccessor(this.selfField, index); + } + + getSelfRight(index: number | number[]) { + return fieldAccessor(this.selfRightField, index); + } + + getSelfDisplay(index: number | number[]) { + return this.valueDisplayProcessor(this.getSelf(index)); + } + + getUniqueLabels() { + return this.uniqueLabels; + } + + getUnitTitle() { + switch (this.valueField.config.unit) { + case SampleUnit.Bytes: + return 'RAM'; + case SampleUnit.Nanoseconds: + return 'Time'; + } + + return 'Count'; + } + + getLevels() { + this.initLevels(); + return this.levels!; + } + + getSandwichLevels(label: string): [LevelItem[][], LevelItem[][]] { + const nodes = this.getNodesWithLabel(label); + + if (!nodes?.length) { + return [[], []]; + } + + const callers = mergeParentSubtrees(nodes, this); + const callees = mergeSubtrees(nodes, this); + + return [callers, callees]; + } + + getNodesWithLabel(label: string) { + this.initLevels(); + return this.uniqueLabelsMap![label]; + } + + getCollapsedMap() { + this.initLevels(); + return this.collapsedMap!; + } + + private initLevels() { + if (!this.levels) { + const [levels, uniqueLabelsMap, collapsedMap] = nestedSetToLevels(this, this.options); + this.levels = levels; + this.uniqueLabelsMap = uniqueLabelsMap; + this.collapsedMap = collapsedMap; + } + } +} + +// Access field value with either single index or array of indexes. This is needed as we sometimes merge multiple +// into one, and we want to access aggregated values. +function fieldAccessor(field: Field | undefined, index: number | number[]) { + if (!field) { + return 0; + } + let indexArray: number[] = typeof index === 'number' ? [index] : index; + return indexArray.reduce((acc, index) => { + return acc + field.values[index]; + }, 0); +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/murmur3.ts b/src/tmp/grafana-flamegraph/src/FlameGraph/murmur3.ts new file mode 100644 index 00000000..3847798d --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/murmur3.ts @@ -0,0 +1,84 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +/* eslint-disable @typescript-eslint/restrict-plus-operands */ +/* + +Copyright (c) 2011 Gary Court + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +*/ + +/* eslint-disable no-plusplus */ +/* eslint-disable prefer-const */ +/* eslint-disable no-bitwise */ +/* eslint-disable camelcase */ + +export default function murmurhash3_32_gc(key: string, seed = 0) { + let remainder; + let bytes; + let h1; + let h1b; + let c1; + let c2; + let k1; + let i; + + remainder = key.length & 3; // key.length % 4 + bytes = key.length - remainder; + h1 = seed; + c1 = 0xcc9e2d51; + c2 = 0x1b873593; + i = 0; + + while (i < bytes) { + k1 = + (key.charCodeAt(i) & 0xff) | + ((key.charCodeAt(++i) & 0xff) << 8) | + ((key.charCodeAt(++i) & 0xff) << 16) | + ((key.charCodeAt(++i) & 0xff) << 24); + ++i; + + k1 = ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); + h1b = ((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff; + h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16); + } + + k1 = 0; + + switch (remainder) { + case 3: + k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; + // fall through + case 2: + k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; + // fall through + case 1: + k1 ^= key.charCodeAt(i) & 0xff; + // fall through + default: + k1 = ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + h1 ^= k1; + } + + h1 ^= key.length; + + h1 ^= h1 >>> 16; + h1 = ((h1 & 0xffff) * 0x85ebca6b + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; + h1 ^= h1 >>> 13; + h1 = ((h1 & 0xffff) * 0xc2b2ae35 + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & 0xffffffff; + h1 ^= h1 >>> 16; + + return h1 >>> 0; +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/rendering.ts b/src/tmp/grafana-flamegraph/src/FlameGraph/rendering.ts new file mode 100644 index 00000000..ae45b278 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/rendering.ts @@ -0,0 +1,449 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; +import React, { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import color from 'tinycolor2'; + +import { + BAR_BORDER_WIDTH, + BAR_TEXT_PADDING_LEFT, + GROUP_STRIP_MARGIN_LEFT, + GROUP_STRIP_PADDING, + GROUP_STRIP_WIDTH, + GROUP_TEXT_OFFSET, + HIDE_THRESHOLD, + LABEL_THRESHOLD, + MUTE_THRESHOLD, + PIXELS_PER_LEVEL, +} from '../constants'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types'; +import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './colors'; +import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; + +type RenderOptions = { + canvasRef: RefObject; + data: FlameGraphDataContainer; + root: LevelItem; + direction: 'children' | 'parents'; + + // Depth in number of levels + depth: number; + wrapperWidth: number; + + // If we are rendering only zoomed in part of the graph. + rangeMin: number; + rangeMax: number; + + matchedLabels: Set | undefined; + textAlign: TextAlign; + + // Total ticks that will be used for sizing + totalViewTicks: number; + // Total ticks that will be used for computing colors as some color scheme (like in diff view) should not be affected + // by sandwich or focus view. + totalColorTicks: number; + // Total ticks used to compute the diff colors + totalTicksRight: number | undefined; + colorScheme: ColorScheme | ColorSchemeDiff; + focusedItemData?: ClickedItemData; + collapsedMap: CollapsedMap; +}; + +export function useFlameRender(options: RenderOptions) { + const { + canvasRef, + data, + root, + depth, + direction, + wrapperWidth, + rangeMin, + rangeMax, + matchedLabels, + textAlign, + totalViewTicks, + totalColorTicks, + totalTicksRight, + colorScheme, + focusedItemData, + collapsedMap, + } = options; + const ctx = useSetupCanvas(canvasRef, wrapperWidth, depth); + const theme = useTheme2(); + + // There is a bit of dependency injections here that does not add readability, mainly to prevent recomputing some + // common stuff for all the nodes in the graph when only once is enough. perf/readability tradeoff. + + const mutedColor = useMemo(() => { + const barMutedColor = color(theme.colors.background.secondary); + return theme.isLight ? barMutedColor.darken(10).toHexString() : barMutedColor.lighten(10).toHexString(); + }, [theme]); + + const getBarColor = useColorFunction( + totalColorTicks, + totalTicksRight, + colorScheme, + theme, + mutedColor, + rangeMin, + rangeMax, + matchedLabels, + focusedItemData ? focusedItemData.item.level : 0 + ); + + const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign, collapsedMap); + + useEffect(() => { + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + const mutedPath2D = new Path2D(); + + // + // Walk the tree and compute the dimensions for each item in the flamegraph. + // + walkTree( + root, + direction, + data, + totalViewTicks, + rangeMin, + rangeMax, + wrapperWidth, + collapsedMap, + (item, x, y, width, height, label, muted) => { + if (muted) { + // We do a bit of optimization for muted regions, and we render them all in single fill later on as they don't + // have labels and are the same color. + mutedPath2D.rect(x, y, width, height); + } else { + renderFunc(item, x, y, width, height, label); + } + } + ); + + // Only fill the muted rects + ctx.fillStyle = mutedColor; + ctx.fill(mutedPath2D); + }, [ + ctx, + data, + root, + wrapperWidth, + rangeMin, + rangeMax, + totalViewTicks, + direction, + renderFunc, + collapsedMap, + mutedColor, + ]); +} + +type RenderFunc = (item: LevelItem, x: number, y: number, width: number, height: number, label: string) => void; + +type RenderFuncWrap = ( + item: LevelItem, + x: number, + y: number, + width: number, + height: number, + label: string, + muted: boolean +) => void; + +/** + * Create a render function with some memoization to prevent excesive repainting of the canvas. + * @param ctx + * @param data + * @param getBarColor + * @param textAlign + * @param collapsedMap + */ +function useRenderFunc( + ctx: CanvasRenderingContext2D | undefined, + data: FlameGraphDataContainer, + getBarColor: (item: LevelItem, label: string, muted: boolean) => string, + textAlign: TextAlign, + collapsedMap: CollapsedMap +) { + return useMemo(() => { + if (!ctx) { + return () => {}; + } + + const renderFunc: RenderFunc = (item, x, y, width, height, label) => { + ctx.beginPath(); + ctx.rect(x + BAR_BORDER_WIDTH, y, width, height); + ctx.fillStyle = getBarColor(item, label, false); + ctx.stroke(); + ctx.fill(); + + const collapsedItemConfig = collapsedMap.get(item); + let finalLabel = label; + if (collapsedItemConfig && collapsedItemConfig.collapsed) { + const numberOfCollapsedItems = collapsedItemConfig.items.length; + finalLabel = `(${numberOfCollapsedItems}) ` + label; + } + + if (width >= LABEL_THRESHOLD) { + if (collapsedItemConfig) { + renderLabel( + ctx, + data, + finalLabel, + item, + width, + textAlign === 'left' ? x + GROUP_STRIP_MARGIN_LEFT + GROUP_TEXT_OFFSET : x, + y, + textAlign + ); + + renderGroupingStrip(ctx, x, y, height, item, collapsedItemConfig); + } else { + renderLabel(ctx, data, finalLabel, item, width, x, y, textAlign); + } + } + }; + + return renderFunc; + }, [ctx, getBarColor, textAlign, data, collapsedMap]); +} + +/** + * Render small strip on the left side of the bar to indicate that this item is part of a group that can be collapsed. + * @param ctx + * @param x + * @param y + * @param height + * @param item + * @param collapsedItemConfig + */ +function renderGroupingStrip( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + height: number, + item: LevelItem, + collapsedItemConfig: CollapseConfig +) { + const groupStripX = x + GROUP_STRIP_MARGIN_LEFT; + + // This is to mask the label in case we align it right to left. + ctx.beginPath(); + ctx.rect(x, y, groupStripX - x + GROUP_STRIP_WIDTH + GROUP_STRIP_PADDING, height); + ctx.fill(); + + // For item in a group that can be collapsed, we draw a small strip to mark them. On the items that are at the + // start or and end of a group we draw just half the strip so 2 groups next to each other are separated + // visually. + ctx.beginPath(); + if (collapsedItemConfig.collapsed) { + ctx.rect(groupStripX, y + height / 4, GROUP_STRIP_WIDTH, height / 2); + } else { + if (collapsedItemConfig.items[0] === item) { + // Top item + ctx.rect(groupStripX, y + height / 2, GROUP_STRIP_WIDTH, height / 2); + } else if (collapsedItemConfig.items[collapsedItemConfig.items.length - 1] === item) { + // Bottom item + ctx.rect(groupStripX, y, GROUP_STRIP_WIDTH, height / 2); + } else { + ctx.rect(groupStripX, y, GROUP_STRIP_WIDTH, height); + } + } + + ctx.fillStyle = '#666'; + ctx.fill(); +} + +/** + * Exported for testing don't use directly + * Walks the tree and computes coordinates, dimensions and other data needed for rendering. For each item in the tree + * it defers the rendering to the renderFunc. + */ +export function walkTree( + root: LevelItem, + // In sandwich view we use parents direction to show all callers. + direction: 'children' | 'parents', + data: FlameGraphDataContainer, + totalViewTicks: number, + rangeMin: number, + rangeMax: number, + wrapperWidth: number, + collapsedMap: CollapsedMap, + renderFunc: RenderFuncWrap +) { + // The levelOffset here is to keep track if items that we don't render because they are collapsed into single row. + // That means we have to render next items with an offset of some rows up in the stack. + const stack: Array<{ item: LevelItem; levelOffset: number }> = []; + stack.push({ item: root, levelOffset: 0 }); + + const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalViewTicks / (rangeMax - rangeMin); + let collapsedItemRendered: LevelItem | undefined = undefined; + + while (stack.length > 0) { + const { item, levelOffset } = stack.shift()!; + let curBarTicks = item.value; + const muted = curBarTicks * pixelsPerTick <= MUTE_THRESHOLD; + const width = curBarTicks * pixelsPerTick - (muted ? 0 : BAR_BORDER_WIDTH * 2); + const height = PIXELS_PER_LEVEL; + + if (width < HIDE_THRESHOLD) { + // We don't render nor it's children + continue; + } + + let offsetModifier = 0; + let skipRender = false; + const collapsedItemConfig = collapsedMap.get(item); + const isCollapsedItem = collapsedItemConfig && collapsedItemConfig.collapsed; + + if (isCollapsedItem) { + if (collapsedItemRendered === collapsedItemConfig.items[0]) { + offsetModifier = direction === 'children' ? -1 : +1; + skipRender = true; + } else { + // This is a case where we have another collapsed group right after different collapsed group, so we need to + // reset. + collapsedItemRendered = undefined; + } + } else { + collapsedItemRendered = undefined; + } + + if (!skipRender) { + const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick); + const barY = (item.level + levelOffset) * PIXELS_PER_LEVEL; + + let label = data.getLabel(item.itemIndexes[0]); + if (isCollapsedItem) { + collapsedItemRendered = item; + } + + renderFunc(item, barX, barY, width, height, label, muted); + } + + const nextList = direction === 'children' ? item.children : item.parents; + if (nextList) { + stack.unshift(...nextList.map((c) => ({ item: c, levelOffset: levelOffset + offsetModifier }))); + } + } +} + +function useColorFunction( + totalTicks: number, + totalTicksRight: number | undefined, + colorScheme: ColorScheme | ColorSchemeDiff, + theme: GrafanaTheme2, + mutedColor: string, + rangeMin: number, + rangeMax: number, + matchedLabels: Set | undefined, + topLevel: number +) { + return useCallback( + function getColor(item: LevelItem, label: string, muted: boolean) { + // If collapsed and no search we can quickly return the muted color + if (muted && !matchedLabels) { + // Collapsed are always grayed + return mutedColor; + } + + const barColor = + item.valueRight !== undefined && + (colorScheme === ColorSchemeDiff.Default || colorScheme === ColorSchemeDiff.DiffColorBlind) + ? getBarColorByDiff(item.value, item.valueRight!, totalTicks, totalTicksRight!, colorScheme) + : colorScheme === ColorScheme.ValueBased + ? getBarColorByValue(item.value, totalTicks, rangeMin, rangeMax) + : getBarColorByPackage(label, theme); + + if (matchedLabels) { + // Means we are searching, we use color for matches and gray the rest + return matchedLabels.has(label) ? barColor.toHslString() : mutedColor; + } + + // Mute if we are above the focused symbol + return item.level > topLevel - 1 ? barColor.toHslString() : barColor.lighten(15).toHslString(); + }, + [totalTicks, totalTicksRight, colorScheme, theme, rangeMin, rangeMax, matchedLabels, topLevel, mutedColor] + ); +} + +function useSetupCanvas(canvasRef: RefObject, wrapperWidth: number, numberOfLevels: number) { + const [ctx, setCtx] = useState(); + + useEffect(() => { + if (!(numberOfLevels && canvasRef.current)) { + return; + } + const ctx = canvasRef.current.getContext('2d')!; + + const height = PIXELS_PER_LEVEL * numberOfLevels; + canvasRef.current.width = Math.round(wrapperWidth * window.devicePixelRatio); + canvasRef.current.height = Math.round(height); + canvasRef.current.style.width = `${wrapperWidth}px`; + canvasRef.current.style.height = `${height / window.devicePixelRatio}px`; + + ctx.textBaseline = 'middle'; + ctx.font = 12 * window.devicePixelRatio + 'px monospace'; + ctx.strokeStyle = 'white'; + setCtx(ctx); + }, [canvasRef, setCtx, wrapperWidth, numberOfLevels]); + return ctx; +} + +// Renders a text inside the node rectangle. It allows setting alignment of the text left or right which takes effect +// when text is too long to fit in the rectangle. +function renderLabel( + ctx: CanvasRenderingContext2D, + data: FlameGraphDataContainer, + label: string, + item: LevelItem, + width: number, + x: number, + y: number, + textAlign: TextAlign +) { + ctx.save(); + ctx.clip(); // so text does not overflow + ctx.fillStyle = '#222'; + + const displayValue = data.valueDisplayProcessor(item.value); + const unit = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text; + + // We only measure name here instead of full label because of how we deal with the units and aligning later. + const measure = ctx.measureText(label); + const spaceForTextInRect = width - BAR_TEXT_PADDING_LEFT; + + let fullLabel = `${label} (${unit})`; + let labelX = Math.max(x, 0) + BAR_TEXT_PADDING_LEFT; + + // We use the desired alignment only if there is not enough space for the text, otherwise we keep left alignment as + // that will already show full text. + if (measure.width > spaceForTextInRect) { + ctx.textAlign = textAlign; + // If aligned to the right we don't want to take the space with the unit label as the assumption is user wants to + // mainly see the name. This also reflects how pyro/flamegraph works. + if (textAlign === 'right') { + fullLabel = label; + labelX = x + width - BAR_TEXT_PADDING_LEFT; + } + } + + ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2 + 2); + ctx.restore(); +} + +/** + * Returns the X position of the bar. totalTicks * rangeMin is to adjust for any current zoom. So if we zoom to a + * section of the graph we align and shift the X coordinates accordingly. + * @param offset + * @param totalTicks + * @param rangeMin + * @param pixelsPerTick + */ +export function getBarX(offset: number, totalTicks: number, rangeMin: number, pixelsPerTick: number) { + return (offset - totalTicks * rangeMin) * pixelsPerTick; +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/treeTransforms.ts b/src/tmp/grafana-flamegraph/src/FlameGraph/treeTransforms.ts new file mode 100644 index 00000000..ee4e98f2 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/treeTransforms.ts @@ -0,0 +1,131 @@ +import { groupBy } from 'lodash'; + +import { LevelItem } from './dataTransform'; + +type DataInterface = { + getLabel: (index: number) => string; +}; + +// Merge parent subtree of the roots for the callers tree in the sandwich view of the flame graph. +export function mergeParentSubtrees(roots: LevelItem[], data: DataInterface): LevelItem[][] { + const newRoots = getParentSubtrees(roots); + return mergeSubtrees(newRoots, data, 'parents'); +} + +// Returns a subtrees per root that will have the parents resized to the same value as the root. When doing callers +// tree we need to keep proper sizes of the parents, before we merge them, so we correctly attribute to the parents +// only the value it contributed to the root. +// So if we have something like: +// [0/////////////] +// [1//][4/////][6] +// [2] [5/////] +// [6] [6/][8/] +// [7] +// Taking all the node with '6' will create: +// [0][0/] +// [1][4/] +// [2][5/][0] +// [6][6/][6] +// Which we can later merge. +function getParentSubtrees(roots: LevelItem[]) { + return roots.map((r) => { + if (!r.parents?.length) { + return r; + } + + const newRoot = { + ...r, + children: [], + }; + const stack: Array<{ child: undefined | LevelItem; parent: LevelItem }> = [ + { child: newRoot, parent: r.parents[0] }, + ]; + + while (stack.length) { + const args = stack.shift()!; + const newNode = { + ...args.parent, + children: args.child ? [args.child] : [], + parents: [], + }; + + if (args.child) { + newNode.value = args.child.value; + args.child.parents = [newNode]; + } + + if (args.parent.parents?.length) { + stack.push({ child: newNode, parent: args.parent.parents[0] }); + } + } + return newRoot; + }); +} + +// Merge subtrees into a single tree. Returns an array of levels for easy rendering. It assumes roots are mergeable, +// meaning they represent the same unit of work (same label). Then we walk the tree in a specified direction, +// merging nodes with the same label and same parent/child into single bigger node. This copies the tree (and all nodes) +// as we are creating new merged nodes and modifying the parents/children. +export function mergeSubtrees( + roots: LevelItem[], + data: DataInterface, + direction: 'parents' | 'children' = 'children' +): LevelItem[][] { + const oppositeDirection = direction === 'parents' ? 'children' : 'parents'; + const levels: LevelItem[][] = []; + + // Loop instead of recursion to be sure we don't blow stack size limit and save some memory. Each stack item is + // basically a list of arrays you would pass to each level of recursion. + const stack: Array<{ previous: undefined | LevelItem; items: LevelItem[]; level: number }> = [ + { previous: undefined, items: roots, level: 0 }, + ]; + + while (stack.length) { + const args = stack.shift()!; + const indexes = args.items.flatMap((i) => i.itemIndexes); + const newItem: LevelItem = { + // We use the items value instead of value from the data frame, cause we could have changed it in the process + value: args.items.reduce((acc, i) => acc + i.value, 0), + itemIndexes: indexes, + // these will change later + children: [], + parents: [], + start: 0, + level: args.level, + }; + + levels[args.level] = levels[args.level] || []; + levels[args.level].push(newItem); + + if (args.previous) { + // Not the first level, so we need to make sure we update previous items to keep the child/parent relationships + // and compute correct new start offset for the item. + newItem[oppositeDirection] = [args.previous]; + const prevSiblingsVal = + args.previous[direction]?.reduce((acc, node) => { + return acc + node.value; + }, 0) || 0; + newItem.start = args.previous.start + prevSiblingsVal; + args.previous[direction]!.push(newItem); + } + + const nextItems = args.items.flatMap((i) => i[direction] || []); + // Group by label which for now is the only identifier by which we decide if node represents the same unit of work. + const nextGroups = groupBy(nextItems, (c) => data.getLabel(c.itemIndexes[0])); + for (const g of Object.values(nextGroups)) { + stack.push({ previous: newItem, items: g, level: args.level + 1 }); + } + } + + // Reverse the levels if we are doing callers tree, so we return levels in the correct order. + if (direction === 'parents') { + levels.reverse(); + levels.forEach((level, index) => { + level.forEach((item) => { + item.level = index; + }); + }); + } + + return levels; +} diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx new file mode 100644 index 00000000..1039ba77 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -0,0 +1,364 @@ +import * as React from 'react'; +import { css } from '@emotion/css'; +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { ThemeContext } from '@grafana/ui'; +import uFuzzy from '@leeoniya/ufuzzy'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; +import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform'; +import FlameGraph from './FlameGraph/FlameGraph'; +import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu'; +import FlameGraphHeader from './FlameGraphHeader'; +import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; + +const ufuzzy = new uFuzzy(); + +export type Props = { + /** + * DataFrame with the profile data. The dataFrame needs to have the following fields: + * label: string - the label of the node + * level: number - the nesting level of the node + * value: number - the total value of the node + * self: number - the self value of the node + * Optionally if it represents diff of 2 different profiles it can also have fields: + * valueRight: number - the total value of the node in the right profile + * selfRight: number - the self value of the node in the right profile + */ + data?: DataFrame; + + /** + * Whether the header should be sticky and be always visible on the top when scrolling. + */ + stickyHeader?: boolean; + + /** + * Provides a theme for the visualization on which colors and some sizes are based. + */ + getTheme: () => GrafanaTheme2; + + /** + * Various interaction hooks that can be used to report on the interaction. + */ + onTableSymbolClick?: (symbol: string) => void; + onViewSelected?: (view: string) => void; + onTextAlignSelected?: (align: string) => void; + onTableSort?: (sort: string) => void; + + /** + * Elements that will be shown in the header on the right side of the header buttons. Useful for additional + * functionality. + */ + extraHeaderElements?: React.ReactNode; + + /** + * Extra buttons that will be shown in the context menu when user clicks on a Node. + */ + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + + /** + * If true the flamegraph will be rendered on top of the table. + */ + vertical?: boolean; + + /** + * If true only the flamegraph will be rendered. + */ + showFlameGraphOnly?: boolean; + + /** + * Disable behaviour where similar items in the same stack will be collapsed into single item. + */ + disableCollapsing?: boolean; + /** + * Whether or not to keep any focused item when the profile data changes. + */ + keepFocusOnDataChange?: boolean; +}; + +const FlameGraphContainer = ({ + data, + onTableSymbolClick, + onViewSelected, + onTextAlignSelected, + onTableSort, + getTheme, + stickyHeader, + extraHeaderElements, + vertical, + showFlameGraphOnly, + disableCollapsing, + keepFocusOnDataChange, + getExtraContextMenuButtons, +}: Props) => { + const [focusedItemData, setFocusedItemData] = useState(); + + const [rangeMin, setRangeMin] = useState(0); + const [rangeMax, setRangeMax] = useState(1); + const [search, setSearch] = useState(''); + const [selectedView, setSelectedView] = useState(SelectedView.Both); + const [sizeRef, { width: containerWidth }] = useMeasure(); + const [textAlign, setTextAlign] = useState('left'); + // This is a label of the item because in sandwich view we group all items by label and present a merged graph + const [sandwichItem, setSandwichItem] = useState(); + const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap()); + + const theme = useMemo(() => getTheme(), [getTheme]); + const dataContainer = useMemo((): FlameGraphDataContainer | undefined => { + if (!data) { + return; + } + + const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme); + setCollapsedMap(container.getCollapsedMap()); + return container; + }, [data, theme, disableCollapsing]); + const [colorScheme, setColorScheme] = useColorScheme(dataContainer); + const styles = getStyles(theme); + const matchedLabels = useLabelSearch(search, dataContainer); + + // If user resizes window with both as the selected view + useEffect(() => { + if ( + containerWidth > 0 && + containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && + selectedView === SelectedView.Both && + !vertical + ) { + setSelectedView(SelectedView.FlameGraph); + } + }, [selectedView, setSelectedView, containerWidth, vertical]); + + const resetFocus = useCallback(() => { + setFocusedItemData(undefined); + setRangeMin(0); + setRangeMax(1); + }, [setFocusedItemData, setRangeMax, setRangeMin]); + + function resetSandwich() { + setSandwichItem(undefined); + } + + useEffect(() => { + if (!keepFocusOnDataChange) { + resetFocus(); + } + resetSandwich(); + }, [data, keepFocusOnDataChange, resetFocus]); + + const onSymbolClick = useCallback( + (symbol: string) => { + if (search === symbol) { + setSearch(''); + } else { + onTableSymbolClick?.(symbol); + setSearch(symbol); + resetFocus(); + } + }, + [setSearch, resetFocus, onTableSymbolClick, search] + ); + + if (!dataContainer) { + return null; + } + + const flameGraph = ( + setFocusedItemData(data)} + focusedItemData={focusedItemData} + textAlign={textAlign} + sandwichItem={sandwichItem} + onSandwich={(label: string) => { + resetFocus(); + setSandwichItem(label); + }} + onFocusPillClick={resetFocus} + onSandwichPillClick={resetSandwich} + colorScheme={colorScheme} + showFlameGraphOnly={showFlameGraphOnly} + collapsing={!disableCollapsing} + getExtraContextMenuButtons={getExtraContextMenuButtons} + selectedView={selectedView} + search={search} + collapsedMap={collapsedMap} + setCollapsedMap={setCollapsedMap} + /> + ); + + const table = ( + + ); + + let body; + if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) { + body = flameGraph; + } else if (selectedView === SelectedView.TopTable) { + body =
{table}
; + } else if (selectedView === SelectedView.Both) { + if (vertical) { + body = ( +
+
{flameGraph}
+
{table}
+
+ ); + } else { + body = ( +
+
{table}
+
{flameGraph}
+
+ ); + } + } + + return ( + // We add the theme context to bridge the gap if this is rendered in non grafana environment where the context + // isn't already provided. + +
+ {!showFlameGraphOnly && ( + { + setSelectedView(view); + onViewSelected?.(view); + }} + containerWidth={containerWidth} + onReset={() => { + resetFocus(); + resetSandwich(); + }} + textAlign={textAlign} + onTextAlignChange={(align) => { + setTextAlign(align); + onTextAlignSelected?.(align); + }} + showResetButton={Boolean(focusedItemData || sandwichItem)} + colorScheme={colorScheme} + onColorSchemeChange={setColorScheme} + stickyHeader={Boolean(stickyHeader)} + extraHeaderElements={extraHeaderElements} + vertical={vertical} + isDiffMode={dataContainer.isDiffFlamegraph()} + setCollapsedMap={setCollapsedMap} + collapsedMap={collapsedMap} + /> + )} + +
{body}
+
+
+ ); +}; + +function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { + const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased; + const [colorScheme, setColorScheme] = useState(defaultColorScheme); + + // This makes sure that if we change the data to/from diff profile we reset the color scheme. + useEffect(() => { + setColorScheme(defaultColorScheme); + }, [defaultColorScheme]); + + return [colorScheme, setColorScheme] as const; +} + +/** + * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. + */ +function useLabelSearch( + search: string | undefined, + data: FlameGraphDataContainer | undefined +): Set | undefined { + return useMemo(() => { + if (search && data) { + const foundLabels = new Set(); + let idxs = ufuzzy.filter(data.getUniqueLabels(), search); + + if (idxs) { + for (let idx of idxs) { + foundLabels.add(data.getUniqueLabels()[idx]); + } + } + + return foundLabels; + } + // In this case undefined means there was no search so no attempt to highlighting anything should be made. + return undefined; + }, [search, data]); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + label: 'container', + overflow: 'auto', + height: '100%', + display: 'flex', + flex: '1 1 0', + flexDirection: 'column', + minHeight: 0, + gap: theme.spacing(1), + }), + body: css({ + label: 'body', + flexGrow: 1, + }), + + tableContainer: css({ + // This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then + // in explore we need a specific height. + height: 800, + }), + + horizontalContainer: css({ + label: 'horizontalContainer', + display: 'flex', + minHeight: 0, + flexDirection: 'row', + columnGap: theme.spacing(1), + width: '100%', + }), + + horizontalGraphContainer: css({ + flexBasis: '50%', + }), + + horizontalTableContainer: css({ + flexBasis: '50%', + maxHeight: 800, + }), + + verticalGraphContainer: css({ + marginBottom: theme.spacing(1), + }), + + verticalTableContainer: css({ + height: 800, + }), + }; +} + +export default FlameGraphContainer; diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphHeader.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphHeader.tsx new file mode 100644 index 00000000..f6d3357e --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/FlameGraphHeader.tsx @@ -0,0 +1,342 @@ +import * as React from 'react'; +import { css, cx } from '@emotion/css'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { useEffect, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import usePrevious from 'react-use/lib/usePrevious'; + +import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; +import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors'; +import { CollapsedMap } from './FlameGraph/dataTransform'; +import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; + +type Props = { + search: string; + setSearch: (search: string) => void; + selectedView: SelectedView; + setSelectedView: (view: SelectedView) => void; + containerWidth: number; + onReset: () => void; + textAlign: TextAlign; + onTextAlignChange: (align: TextAlign) => void; + showResetButton: boolean; + colorScheme: ColorScheme | ColorSchemeDiff; + onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void; + stickyHeader: boolean; + vertical?: boolean; + isDiffMode: boolean; + setCollapsedMap: (collapsedMap: CollapsedMap) => void; + collapsedMap: CollapsedMap; + + extraHeaderElements?: React.ReactNode; +}; + +const FlameGraphHeader = ({ + search, + setSearch, + selectedView, + setSelectedView, + containerWidth, + onReset, + textAlign, + onTextAlignChange, + showResetButton, + colorScheme, + onColorSchemeChange, + stickyHeader, + extraHeaderElements, + vertical, + isDiffMode, + setCollapsedMap, + collapsedMap, +}: Props) => { + const styles = useStyles2(getStyles); + const [localSearch, setLocalSearch] = useSearchInput(search, setSearch); + + const suffix = + localSearch !== '' ? ( + + ) : null; + + return ( +
+
+ { + setLocalSearch(v.currentTarget.value); + }} + placeholder={'Search...'} + suffix={suffix} + /> +
+ +
+ {showResetButton && ( +
+
+ ); +}; + +type ColorSchemeButtonProps = { + value: ColorScheme | ColorSchemeDiff; + onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void; + isDiffMode: boolean; +}; +function ColorSchemeButton(props: ColorSchemeButtonProps) { + // TODO: probably create separate getStyles + const styles = useStyles2(getStyles); + let menu = ( + + props.onChange(ColorScheme.PackageBased)} /> + props.onChange(ColorScheme.ValueBased)} /> + + ); + + // Show a bit different gradient as a way to indicate selected value + const colorDotStyle = + { + [ColorScheme.ValueBased]: styles.colorDotByValue, + [ColorScheme.PackageBased]: styles.colorDotByPackage, + [ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind, + [ColorSchemeDiff.Default]: styles.colorDotDiffDefault, + }[props.value] || styles.colorDotByValue; + + let contents = ; + + if (props.isDiffMode) { + menu = ( + + props.onChange(ColorSchemeDiff.Default)} /> + props.onChange(ColorSchemeDiff.DiffColorBlind)} /> + + ); + + contents = ( +
+
-100% (removed)
+
0%
+
+100% (added)
+
+ ); + } + + return ( + + + + ); +} + +const alignOptions: Array> = [ + { value: 'left', description: 'Align text left', icon: 'align-left' }, + { value: 'right', description: 'Align text right', icon: 'align-right' }, +]; + +function getViewOptions(width: number, vertical?: boolean): Array> { + let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [ + { value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' }, + { value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' }, + ]; + + if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) { + viewOptions.push({ + value: SelectedView.Both, + label: 'Both', + description: 'Show both the top table and flame graph', + }); + } + + return viewOptions; +} + +function useSearchInput( + search: string, + setSearch: (search: string) => void +): [string | undefined, (search: string) => void] { + const [localSearchState, setLocalSearchState] = useState(search); + const prevSearch = usePrevious(search); + + // Debouncing cause changing parent search triggers rerender on both the flamegraph and table + useDebounce( + () => { + setSearch(localSearchState); + }, + 250, + [localSearchState] + ); + + // Make sure we still handle updates from parent (from clicking on a table item for example). We check if the parent + // search value changed to something that isn't our local value. + useEffect(() => { + if (prevSearch !== search && search !== localSearchState) { + setLocalSearchState(search); + } + }, [search, prevSearch, localSearchState]); + + return [localSearchState, setLocalSearchState]; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + header: css({ + label: 'header', + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + width: '100%', + top: 0, + gap: theme.spacing(1), + marginTop: theme.spacing(1), + }), + stickyHeader: css({ + zIndex: theme.zIndex.navbarFixed, + position: 'sticky', + background: theme.colors.background.primary, + }), + inputContainer: css({ + label: 'inputContainer', + flexGrow: 1, + minWidth: '150px', + maxWidth: '350px', + }), + rightContainer: css({ + label: 'rightContainer', + display: 'flex', + alignItems: 'flex-start', + flexWrap: 'wrap', + }), + buttonSpacing: css({ + label: 'buttonSpacing', + marginRight: theme.spacing(1), + }), + resetButton: css({ + label: 'resetButton', + display: 'flex', + marginRight: theme.spacing(2), + }), + resetButtonIconWrapper: css({ + label: 'resetButtonIcon', + padding: '0 5px', + color: theme.colors.text.disabled, + }), + colorDot: css({ + label: 'colorDot', + display: 'inline-block', + width: '10px', + height: '10px', + borderRadius: '50%', + }), + colorDotDiff: css({ + label: 'colorDotDiff', + display: 'flex', + width: '200px', + height: '12px', + color: 'white', + fontSize: 9, + lineHeight: 1.3, + fontWeight: 300, + justifyContent: 'space-between', + padding: '0 2px', + // We have a specific sizing for this so probably makes sense to use hardcoded value here + borderRadius: '2px', + }), + colorDotByValue: css({ + label: 'colorDotByValue', + background: byValueGradient, + }), + colorDotByPackage: css({ + label: 'colorDotByPackage', + background: byPackageGradient, + }), + colorDotDiffDefault: css({ + label: 'colorDotDiffDefault', + background: diffDefaultGradient, + }), + colorDotDiffColorBlind: css({ + label: 'colorDotDiffColorBlind', + background: diffColorBlindGradient, + }), + extraElements: css({ + label: 'extraElements', + marginLeft: theme.spacing(1), + }), +}); + +export default FlameGraphHeader; diff --git a/src/tmp/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx b/src/tmp/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx new file mode 100644 index 00000000..5e272f0d --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx @@ -0,0 +1,367 @@ +import { css } from '@emotion/css'; +import { + applyFieldOverrides, + DataFrame, + DataLinkClickEvent, + Field, + FieldType, + GrafanaTheme2, + MappingType, +} from '@grafana/data'; +import { + IconButton, + Table, + TableCellDisplayMode, + TableCustomCellOptions, + TableFieldOptions, + TableSortByFieldState, + useStyles2, + useTheme2, +} from '@grafana/ui'; +import React, { memo, useMemo, useState } from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { TOP_TABLE_COLUMN_WIDTH } from '../constants'; +import { diffColorBlindColors, diffDefaultColors } from '../FlameGraph/colors'; +import { FlameGraphDataContainer } from '../FlameGraph/dataTransform'; +import { ColorScheme, ColorSchemeDiff, TableData } from '../types'; + +type Props = { + data: FlameGraphDataContainer; + onSymbolClick: (symbol: string) => void; + // This is used for highlighting the search button in case there is exact match. + search?: string; + // We use these to filter out rows in the table if users is doing text search. + matchedLabels?: Set; + sandwichItem?: string; + onSearch: (str: string) => void; + onSandwich: (str?: string) => void; + onTableSort?: (sort: string) => void; + colorScheme: ColorScheme | ColorSchemeDiff; +}; + +const FlameGraphTopTableContainer = memo( + ({ + data, + onSymbolClick, + search, + matchedLabels, + onSearch, + sandwichItem, + onSandwich, + onTableSort, + colorScheme, + }: Props) => { + const table = useMemo(() => { + // Group the data by label, we show only one row per label and sum the values + // TODO: should be by filename + funcName + linenumber? + let filteredTable: { [key: string]: TableData } = {}; + for (let i = 0; i < data.data.length; i++) { + const value = data.getValue(i); + const valueRight = data.getValueRight(i); + const self = data.getSelf(i); + const label = data.getLabel(i); + + // If user is doing text search we filter out labels in the same way we highlight them in flame graph. + if (!matchedLabels || matchedLabels.has(label)) { + filteredTable[label] = filteredTable[label] || {}; + filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self; + filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value; + filteredTable[label].totalRight = filteredTable[label].totalRight + ? filteredTable[label].totalRight + valueRight + : valueRight; + } + } + return filteredTable; + }, [data, matchedLabels]); + + const styles = useStyles2(getStyles); + const theme = useTheme2(); + + const [sort, setSort] = useState([{ displayName: 'Self', desc: true }]); + + return ( +
+ + {({ width, height }) => { + if (width < 3 || height < 3) { + return null; + } + + const frame = buildTableDataFrame( + data, + table, + width, + onSymbolClick, + onSearch, + onSandwich, + theme, + colorScheme, + search, + sandwichItem + ); + return ( + { + if (s && s.length) { + onTableSort?.(s[0].displayName + '_' + (s[0].desc ? 'desc' : 'asc')); + } + setSort(s); + }} + data={frame} + width={width} + height={height} + /> + ); + }} + + + ); + } +); + +FlameGraphTopTableContainer.displayName = 'FlameGraphTopTableContainer'; + +function buildTableDataFrame( + data: FlameGraphDataContainer, + table: { [key: string]: TableData }, + width: number, + onSymbolClick: (str: string) => void, + onSearch: (str: string) => void, + onSandwich: (str?: string) => void, + theme: GrafanaTheme2, + colorScheme: ColorScheme | ColorSchemeDiff, + search?: string, + sandwichItem?: string +): DataFrame { + const actionField: Field = createActionField(onSandwich, onSearch, search, sandwichItem); + + const symbolField: Field = { + type: FieldType.string, + name: 'Symbol', + values: [], + config: { + custom: { width: width - actionColumnWidth - TOP_TABLE_COLUMN_WIDTH * 2 }, + links: [ + { + title: 'Highlight symbol', + url: '', + onClick: (e: DataLinkClickEvent) => { + const field: Field = e.origin.field; + const value = field.values[e.origin.rowIndex]; + onSymbolClick(value); + }, + }, + ], + }, + }; + + let frame; + + if (data.isDiffFlamegraph()) { + symbolField.config.custom.width = width - actionColumnWidth - TOP_TABLE_COLUMN_WIDTH * 3; + + const baselineField = createNumberField('Baseline', 'percent'); + const comparisonField = createNumberField('Comparison', 'percent'); + const diffField = createNumberField('Diff', 'percent'); + diffField.config.custom.cellOptions.type = TableCellDisplayMode.ColorText; + + const [removeColor, addColor] = + colorScheme === ColorSchemeDiff.DiffColorBlind + ? [diffColorBlindColors[0], diffColorBlindColors[2]] + : [diffDefaultColors[0], diffDefaultColors[2]]; + + diffField.config.mappings = [ + { type: MappingType.ValueToText, options: { [Infinity]: { text: 'new', color: addColor } } }, + { type: MappingType.ValueToText, options: { [-100]: { text: 'removed', color: removeColor } } }, + { type: MappingType.RangeToText, options: { from: 0, to: Infinity, result: { color: addColor } } }, + { type: MappingType.RangeToText, options: { from: -Infinity, to: 0, result: { color: removeColor } } }, + ]; + + // For this we don't really consider sandwich view even though you can switch it on. + const levels = data.getLevels(); + const totalTicks = levels.length ? levels[0][0].value : 0; + const totalTicksRight = levels.length ? levels[0][0].valueRight : undefined; + + for (let key in table) { + actionField.values.push(null); + symbolField.values.push(key); + + const ticksLeft = table[key].total; + const ticksRight = table[key].totalRight; + + // We are iterating over table of the data so totalTicksRight needs to be defined + const totalTicksLeft = totalTicks - totalTicksRight!; + + const percentageLeft = Math.round((10000 * ticksLeft) / totalTicksLeft) / 100; + const percentageRight = Math.round((10000 * ticksRight) / totalTicksRight!) / 100; + + const diff = ((percentageRight - percentageLeft) / percentageLeft) * 100; + + diffField.values.push(diff); + baselineField.values.push(percentageLeft); + comparisonField.values.push(percentageRight); + } + + frame = { + fields: [actionField, symbolField, baselineField, comparisonField, diffField], + length: symbolField.values.length, + }; + } else { + const selfField = createNumberField('Self', data.selfField.config.unit); + const totalField = createNumberField('Total', data.valueField.config.unit); + + for (let key in table) { + actionField.values.push(null); + symbolField.values.push(key); + selfField.values.push(table[key].self); + totalField.values.push(table[key].total); + } + + frame = { fields: [actionField, symbolField, selfField, totalField], length: symbolField.values.length }; + } + + const dataFrames = applyFieldOverrides({ + data: [frame], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (value: string) => value, + theme, + }); + + return dataFrames[0]; +} + +function createNumberField(name: string, unit?: string): Field { + const tableFieldOptions: TableFieldOptions = { + width: TOP_TABLE_COLUMN_WIDTH, + align: 'auto', + inspect: false, + cellOptions: { type: TableCellDisplayMode.Auto }, + }; + + return { + type: FieldType.number, + name, + values: [], + config: { + unit, + custom: tableFieldOptions, + }, + }; +} + +const actionColumnWidth = 61; + +function createActionField( + onSandwich: (str?: string) => void, + onSearch: (str: string) => void, + search?: string, + sandwichItem?: string +): Field { + const options: TableCustomCellOptions = { + type: TableCellDisplayMode.Custom, + cellComponent: (props) => { + return ( + + ); + }, + }; + + const actionFieldTableConfig: TableFieldOptions = { + filterable: false, + width: actionColumnWidth, + hideHeader: true, + inspect: false, + align: 'auto', + cellOptions: options, + }; + + return { + type: FieldType.number, + name: 'actions', + values: [], + config: { + custom: actionFieldTableConfig, + }, + }; +} + +type ActionCellProps = { + frame: DataFrame; + rowIndex: number; + search?: string; + sandwichItem?: string; + onSearch: (symbol: string) => void; + onSandwich: (symbol: string) => void; +}; + +function ActionCell(props: ActionCellProps) { + const styles = getStylesActionCell(); + const symbol = props.frame.fields.find((f: Field) => f.name === 'Symbol')?.values[props.rowIndex]; + const isSearched = props.search === symbol; + const isSandwiched = props.sandwichItem === symbol; + + return ( +
+ { + props.onSearch(isSearched ? '' : symbol); + }} + /> + { + props.onSandwich(isSandwiched ? undefined : symbol); + }} + /> +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + topTableContainer: css({ + label: 'topTableContainer', + padding: theme.spacing(1), + backgroundColor: theme.colors.background.secondary, + height: '100%', + }), + }; +}; + +const getStylesActionCell = () => { + return { + actionCellWrapper: css({ + label: 'actionCellWrapper', + display: 'flex', + height: '24px', + }), + actionCellButton: css({ + label: 'actionCellButton', + marginRight: 0, + width: '24px', + }), + }; +}; + +export default FlameGraphTopTableContainer; diff --git a/src/tmp/grafana-flamegraph/src/constants.ts b/src/tmp/grafana-flamegraph/src/constants.ts new file mode 100644 index 00000000..aa910799 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/constants.ts @@ -0,0 +1,12 @@ +export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio; +export const MUTE_THRESHOLD = 10 * window.devicePixelRatio; +export const HIDE_THRESHOLD = 0.5 * window.devicePixelRatio; +export const LABEL_THRESHOLD = 20 * window.devicePixelRatio; +export const BAR_BORDER_WIDTH = 0.5 * window.devicePixelRatio; +export const BAR_TEXT_PADDING_LEFT = 4 * window.devicePixelRatio; +export const GROUP_STRIP_WIDTH = 3 * window.devicePixelRatio; +export const GROUP_STRIP_PADDING = 3 * window.devicePixelRatio; +export const GROUP_STRIP_MARGIN_LEFT = 4 * window.devicePixelRatio; +export const GROUP_TEXT_OFFSET = 2 * window.devicePixelRatio; +export const MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH = 800; +export const TOP_TABLE_COLUMN_WIDTH = 120; diff --git a/src/tmp/grafana-flamegraph/src/index.ts b/src/tmp/grafana-flamegraph/src/index.ts new file mode 100644 index 00000000..1e91bd43 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/index.ts @@ -0,0 +1,2 @@ +export { default as FlameGraph, type Props } from './FlameGraphContainer'; +export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform'; diff --git a/src/tmp/grafana-flamegraph/src/types.ts b/src/tmp/grafana-flamegraph/src/types.ts new file mode 100644 index 00000000..aa8a84de --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/types.ts @@ -0,0 +1,43 @@ +import { LevelItem } from './FlameGraph/dataTransform'; + +export { type FlameGraphDataContainer } from './FlameGraph/dataTransform'; + +export { type ExtraContextMenuButton } from './FlameGraph/FlameGraphContextMenu'; + +export type ClickedItemData = { + posX: number; + posY: number; + label: string; + item: LevelItem; +}; + +export enum SampleUnit { + Bytes = 'bytes', + Short = 'short', + Nanoseconds = 'ns', +} + +export enum SelectedView { + TopTable = 'topTable', + FlameGraph = 'flameGraph', + Both = 'both', +} + +export interface TableData { + self: number; + total: number; + // For diff view + totalRight: number; +} + +export enum ColorScheme { + ValueBased = 'valueBased', + PackageBased = 'packageBased', +} + +export enum ColorSchemeDiff { + Default = 'default', + DiffColorBlind = 'diffColorBlind', +} + +export type TextAlign = 'left' | 'right'; diff --git a/src/tmp/grafana-flamegraph/src/types/emotion-core-stub.d.ts b/src/tmp/grafana-flamegraph/src/types/emotion-core-stub.d.ts new file mode 100644 index 00000000..837fc035 --- /dev/null +++ b/src/tmp/grafana-flamegraph/src/types/emotion-core-stub.d.ts @@ -0,0 +1,4 @@ +// This stub is required due to Storybook 6.x reliance on @emotion/core +// which causes conflicts with emotion 11 types resulting in bundled +// components throwing ts error `Property 'css' is missing in type...` +export {}; diff --git a/yarn.lock b/yarn.lock index d57521ce..819df7ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1557,28 +1557,6 @@ __metadata: languageName: node linkType: hard -"@grafana/flamegraph@npm:11.3.2": - version: 11.3.2 - resolution: "@grafana/flamegraph@npm:11.3.2" - dependencies: - "@emotion/css": "npm:11.13.4" - "@grafana/data": "npm:11.3.2" - "@grafana/ui": "npm:11.3.2" - "@leeoniya/ufuzzy": "npm:1.0.14" - d3: "npm:^7.8.5" - lodash: "npm:4.17.21" - react: "npm:18.2.0" - react-use: "npm:17.5.1" - react-virtualized-auto-sizer: "npm:1.0.24" - tinycolor2: "npm:1.6.0" - tslib: "npm:2.7.0" - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/0e004d67759904de56a661d7c9a3b609c6ab574db22556ee55aca29f58b0c5154b1740110a8e9db579515709b469c7c1b5d960c621ebb1a42e39e2619b7e0b62 - languageName: node - linkType: hard - "@grafana/llm@npm:^0.10.7": version: 0.10.7 resolution: "@grafana/llm@npm:0.10.7" @@ -2267,7 +2245,7 @@ __metadata: languageName: node linkType: hard -"@leeoniya/ufuzzy@npm:^1.0.14": +"@leeoniya/ufuzzy@npm:^1.0.14, @leeoniya/ufuzzy@npm:^1.0.17": version: 1.0.17 resolution: "@leeoniya/ufuzzy@npm:1.0.17" checksum: 10/8d4decc209dddae3b90908837f6064634e1d79af889fcfc799890e5598289238a348fabdff3a13a4efe22d1adf163334e746423324314a720fb4969b1b483769 @@ -3535,6 +3513,38 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:*": + version: 3.2.1 + resolution: "@types/d3-array@npm:3.2.1" + checksum: 10/4a9ecacaa859cff79e10dcec0c79053f027a4749ce0a4badeaff7400d69a9c44eb8210b147916b6ff5309be049030e7d68a0e333294ff3fa11c44aa1af4ba458 + languageName: node + linkType: hard + +"@types/d3-axis@npm:*": + version: 3.0.6 + resolution: "@types/d3-axis@npm:3.0.6" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10/8af56b629a0597ac8ef5051b6ad5390818462d8e588e1b52fb181808b1c0525d12a658730fad757e1ae256d0db170a0e29076acdef21acc98b954608d1c37b84 + languageName: node + linkType: hard + +"@types/d3-brush@npm:*": + version: 3.0.6 + resolution: "@types/d3-brush@npm:3.0.6" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10/4095cee2512d965732147493c471a8dd97dfb5967479d9aef43397f8b0e074b03296302423b8379c4274f9249b52bd1d74cc021f98d4f64b5a8a4a7e6fe48335 + languageName: node + linkType: hard + +"@types/d3-chord@npm:*": + version: 3.0.6 + resolution: "@types/d3-chord@npm:3.0.6" + checksum: 10/ca9ba8b00debd24a2b51527b9c3db63eafa5541c08dc721d1c52ca19960c5cec93a7b1acfc0ec072dbca31d134924299755e20a4d1d4ee04b961fc0de841b418 + languageName: node + linkType: hard + "@types/d3-color@npm:*": version: 3.1.3 resolution: "@types/d3-color@npm:3.1.3" @@ -3542,7 +3552,93 @@ __metadata: languageName: node linkType: hard -"@types/d3-interpolate@npm:^3.0.0": +"@types/d3-contour@npm:*": + version: 3.0.6 + resolution: "@types/d3-contour@npm:3.0.6" + dependencies: + "@types/d3-array": "npm:*" + "@types/geojson": "npm:*" + checksum: 10/e7b7e3972aa71003c21f2c864116ffb95a9175a62ec56ec656a855e5198a66a0830b2ad7fc26811214cfa8c98cdf4190d7d351913ca0913f799fbcf2a4c99b2d + languageName: node + linkType: hard + +"@types/d3-delaunay@npm:*": + version: 6.0.4 + resolution: "@types/d3-delaunay@npm:6.0.4" + checksum: 10/cb8d2c9ed0b39ade3107b9792544a745b2de3811a6bd054813e9dc708b1132fbacd796e54c0602c11b3a14458d14487c5276c1affb7c2b9f25fe55fff88d6d25 + languageName: node + linkType: hard + +"@types/d3-dispatch@npm:*": + version: 3.0.6 + resolution: "@types/d3-dispatch@npm:3.0.6" + checksum: 10/f82076c7d205885480d363c92c19b8e0d6b9e529a3a78ce772f96a7cc4cce01f7941141f148828337035fac9676b13e7440565530491d560fdf12e562cb56573 + languageName: node + linkType: hard + +"@types/d3-drag@npm:*": + version: 3.0.7 + resolution: "@types/d3-drag@npm:3.0.7" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10/93aba299c3a8d41ee326c5304ab694ceea135ed115c3b2ccab727a5d9bfc935f7f36d3fc416c013010eb755ac536c52adfcb15c195f241dc61f62650cc95088e + languageName: node + linkType: hard + +"@types/d3-dsv@npm:*": + version: 3.0.7 + resolution: "@types/d3-dsv@npm:3.0.7" + checksum: 10/8507f542135cae472781dff1c3b391eceedad0f2032d24ac4a0814e72e2f6877e4ddcb66f44627069977ee61029dc0a729edf659ed73cbf1040f55a7451f05ef + languageName: node + linkType: hard + +"@types/d3-ease@npm:*": + version: 3.0.2 + resolution: "@types/d3-ease@npm:3.0.2" + checksum: 10/d8f92a8a7a008da71f847a16227fdcb53a8938200ecdf8d831ab6b49aba91e8921769761d3bfa7e7191b28f62783bfd8b0937e66bae39d4dd7fb0b63b50d4a94 + languageName: node + linkType: hard + +"@types/d3-fetch@npm:*": + version: 3.0.7 + resolution: "@types/d3-fetch@npm:3.0.7" + dependencies: + "@types/d3-dsv": "npm:*" + checksum: 10/d496475cec7750f75740936e750a0150ca45e924a4f4697ad2c564f3a8f6c4ebc1b1edf8e081936e896532516731dbbaf2efd4890d53274a8eae13f51f821557 + languageName: node + linkType: hard + +"@types/d3-force@npm:*": + version: 3.0.10 + resolution: "@types/d3-force@npm:3.0.10" + checksum: 10/9c35abed2af91b94fc72d6b477188626e628ed89a01016437502c1deaf558da934b5d0cc808c2f2979ac853b6302b3d6ef763eddaff3a55552a55c0be710d5ca + languageName: node + linkType: hard + +"@types/d3-format@npm:*": + version: 3.0.4 + resolution: "@types/d3-format@npm:3.0.4" + checksum: 10/b937ecd2712d4aa38d5b4f5daab9cc8a576383868be1809e046aec99eeb1f1798c139f2e862dc400a82494c763be46087d154891773417f8eb53c73762ba3eb8 + languageName: node + linkType: hard + +"@types/d3-geo@npm:*": + version: 3.1.0 + resolution: "@types/d3-geo@npm:3.1.0" + dependencies: + "@types/geojson": "npm:*" + checksum: 10/e759d98470fe605ff0088247af81c3197cefce72b16eafe8acae606216c3e0a9f908df4e7cd5005ecfe13b8ac8396a51aaa0d282f3ca7d1c3850313a13fac905 + languageName: node + linkType: hard + +"@types/d3-hierarchy@npm:*": + version: 3.1.7 + resolution: "@types/d3-hierarchy@npm:3.1.7" + checksum: 10/9ff6cdedf5557ef9e1e7a65ca3c6846c895c84c1184e11ec6fa48565e96ebf5482d8be5cc791a8bc7f7debbd0e62604ee3da3ddca4f9d58bf6c8b4030567c6c6 + languageName: node + linkType: hard + +"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.0": version: 3.0.4 resolution: "@types/d3-interpolate@npm:3.0.4" dependencies: @@ -3551,7 +3647,42 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale@npm:^4.0.2": +"@types/d3-path@npm:*": + version: 3.1.0 + resolution: "@types/d3-path@npm:3.1.0" + checksum: 10/7348d65c9b37c7023590d4e5ef11e37f9eee62df9fa23e0758da1fbd66a1cbff40e37cbe0b85e9388ab900451e9c18a5a973469e9fd725c8c85c4a3f84647b9d + languageName: node + linkType: hard + +"@types/d3-polygon@npm:*": + version: 3.0.2 + resolution: "@types/d3-polygon@npm:3.0.2" + checksum: 10/7cf1eadb54f02dd3617512b558f4c0f3811f8a6a8c887d9886981c3cc251db28b68329b2b0707d9f517231a72060adbb08855227f89bef6ef30caedc0a67cab2 + languageName: node + linkType: hard + +"@types/d3-quadtree@npm:*": + version: 3.0.6 + resolution: "@types/d3-quadtree@npm:3.0.6" + checksum: 10/4c260c9857d496b7f112cf57680c411c1912cc72538a5846c401429e3ed89a097c66410cfd38b394bfb4733ec2cb47d345b4eb5e202cbfb8e78ab044b535be02 + languageName: node + linkType: hard + +"@types/d3-random@npm:*": + version: 3.0.3 + resolution: "@types/d3-random@npm:3.0.3" + checksum: 10/2c126dda6846f6c7e02c9123a30b4cdf27f3655d19b78456bbb330fbac27acceeeb987318055d3964dba8e6450377ff737db91d81f27c81ca6f4522c9b994ef2 + languageName: node + linkType: hard + +"@types/d3-scale-chromatic@npm:*": + version: 3.1.0 + resolution: "@types/d3-scale-chromatic@npm:3.1.0" + checksum: 10/6b04af931b7cd4aa09f21519970cab44aaae181faf076013ab93ccb0d550ec16f4c8d444c1e9dee1493be4261a8a8bb6f8e6356e6f4c6ba0650011b1e8a38aef + languageName: node + linkType: hard + +"@types/d3-scale@npm:*, @types/d3-scale@npm:^4.0.2": version: 4.0.8 resolution: "@types/d3-scale@npm:4.0.8" dependencies: @@ -3560,6 +3691,29 @@ __metadata: languageName: node linkType: hard +"@types/d3-selection@npm:*": + version: 3.0.11 + resolution: "@types/d3-selection@npm:3.0.11" + checksum: 10/2d2d993b9e9553d066566cb22916c632e5911090db99e247bd8c32855a344e6b7c25b674f3c27956c367a6b3b1214b09931ce854788c3be2072003e01f2c75d7 + languageName: node + linkType: hard + +"@types/d3-shape@npm:*": + version: 3.1.6 + resolution: "@types/d3-shape@npm:3.1.6" + dependencies: + "@types/d3-path": "npm:*" + checksum: 10/75abf403ec5b8c11e761256aa6b3546533d61e2e12f15c82bed6b606e963dcdfb9868a2038c46099173c8830423b35ddaf14d1162f96ad9da18a2e90b0fa7d25 + languageName: node + linkType: hard + +"@types/d3-time-format@npm:*": + version: 4.0.3 + resolution: "@types/d3-time-format@npm:4.0.3" + checksum: 10/9dfc1516502ac1c657d6024bdb88b6dc7e21dd7bff88f6187616cf9a0108250f63507a2004901ece4f97cc46602005a2ca2d05c6dbe53e8a0f6899bd60d4ff7a + languageName: node + linkType: hard + "@types/d3-time@npm:*": version: 3.0.3 resolution: "@types/d3-time@npm:3.0.3" @@ -3567,6 +3721,70 @@ __metadata: languageName: node linkType: hard +"@types/d3-timer@npm:*": + version: 3.0.2 + resolution: "@types/d3-timer@npm:3.0.2" + checksum: 10/1643eebfa5f4ae3eb00b556bbc509444d88078208ec2589ddd8e4a24f230dd4cf2301e9365947e70b1bee33f63aaefab84cd907822aae812b9bc4871b98ab0e1 + languageName: node + linkType: hard + +"@types/d3-transition@npm:*": + version: 3.0.9 + resolution: "@types/d3-transition@npm:3.0.9" + dependencies: + "@types/d3-selection": "npm:*" + checksum: 10/dad647c485440f176117e8a45f31aee9427d8d4dfa174eaa2f01e702641db53ad0f752a144b20987c7189723c4f0afe0bf0f16d95b2a91aa28937eee4339c161 + languageName: node + linkType: hard + +"@types/d3-zoom@npm:*": + version: 3.0.8 + resolution: "@types/d3-zoom@npm:3.0.8" + dependencies: + "@types/d3-interpolate": "npm:*" + "@types/d3-selection": "npm:*" + checksum: 10/cc6ba975cf4f55f94933413954d81b87feb1ee8b8cee8f2202cf526f218dcb3ba240cbeb04ed80522416201c4a7394b37de3eb695d840a36d190dfb2d3e62cb5 + languageName: node + linkType: hard + +"@types/d3@npm:^7": + version: 7.4.3 + resolution: "@types/d3@npm:7.4.3" + dependencies: + "@types/d3-array": "npm:*" + "@types/d3-axis": "npm:*" + "@types/d3-brush": "npm:*" + "@types/d3-chord": "npm:*" + "@types/d3-color": "npm:*" + "@types/d3-contour": "npm:*" + "@types/d3-delaunay": "npm:*" + "@types/d3-dispatch": "npm:*" + "@types/d3-drag": "npm:*" + "@types/d3-dsv": "npm:*" + "@types/d3-ease": "npm:*" + "@types/d3-fetch": "npm:*" + "@types/d3-force": "npm:*" + "@types/d3-format": "npm:*" + "@types/d3-geo": "npm:*" + "@types/d3-hierarchy": "npm:*" + "@types/d3-interpolate": "npm:*" + "@types/d3-path": "npm:*" + "@types/d3-polygon": "npm:*" + "@types/d3-quadtree": "npm:*" + "@types/d3-random": "npm:*" + "@types/d3-scale": "npm:*" + "@types/d3-scale-chromatic": "npm:*" + "@types/d3-selection": "npm:*" + "@types/d3-shape": "npm:*" + "@types/d3-time": "npm:*" + "@types/d3-time-format": "npm:*" + "@types/d3-timer": "npm:*" + "@types/d3-transition": "npm:*" + "@types/d3-zoom": "npm:*" + checksum: 10/12234aa093c8661546168becdd8956e892b276f525d96f65a7b32fed886fc6a569fe5a1171bff26fef2a5663960635f460c9504a6f2d242ba281a2b6c8c6465c + languageName: node + linkType: hard + "@types/eslint@npm:^8.56.10": version: 8.56.12 resolution: "@types/eslint@npm:8.56.12" @@ -3600,6 +3818,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:*": + version: 7946.0.15 + resolution: "@types/geojson@npm:7946.0.15" + checksum: 10/226d7ab59540632b19f7889c76c4c586a5104c18c43a81f32974aa035eafe557f86bd5a79ca5568bb63cbe5bfa9014c8e9a29cb0bb3d2f0bd71b0cc13ad8ccb3 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -3714,13 +3939,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.188": - version: 4.17.6 - resolution: "@types/lodash@npm:4.17.6" - checksum: 10/6d3a68b3e795381f4aaf946855134d24eeb348ad5d66e9a44461d30026da82b215d55b92b70486d811ca45d54d4ab956aa2dced37fd04e19d49afe160ae3da2e - languageName: node - linkType: hard - "@types/node@npm:*, @types/node@npm:>=13.7.0": version: 20.14.10 resolution: "@types/node@npm:20.14.10" @@ -3896,6 +4114,13 @@ __metadata: languageName: node linkType: hard +"@types/tinycolor2@npm:^1": + version: 1.4.6 + resolution: "@types/tinycolor2@npm:1.4.6" + checksum: 10/a662fd177135d27779b7ccba39881a85167f969787c71c7bf51903d288a519ad5b9526e0a4af7bde6444df6b7abe88bae893d569c6d804108c03dfec9509bdf6 + languageName: node + linkType: hard + "@types/tough-cookie@npm:*": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" @@ -6266,7 +6491,7 @@ __metadata: languageName: node linkType: hard -"d3@npm:7.9.0, d3@npm:^7.8.5": +"d3@npm:7.9.0, d3@npm:^7.9.0": version: 7.9.0 resolution: "d3@npm:7.9.0" dependencies: @@ -11455,13 +11680,13 @@ __metadata: "@grafana/e2e-selectors": "npm:^10.0.0" "@grafana/eslint-config": "npm:^8.0.0" "@grafana/faro-web-sdk": "npm:^1.10.0" - "@grafana/flamegraph": "npm:11.3.2" "@grafana/llm": "npm:^0.10.7" "@grafana/runtime": "npm:11.3.2" "@grafana/scenes": "npm:^4.22.0" "@grafana/schema": "npm:11.3.2" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.3.2" + "@leeoniya/ufuzzy": "npm:^1.0.17" "@playwright/test": "npm:^1.49.0" "@react-aria/utils": "npm:^3.25.3" "@stylistic/eslint-plugin-ts": "npm:^2.9.0" @@ -11475,18 +11700,19 @@ __metadata: "@testing-library/react-hooks": "npm:^8.0.1" "@trivago/prettier-plugin-sort-imports": "npm:^4.3.0" "@types/color": "npm:^3.0.2" + "@types/d3": "npm:^7" "@types/d3-scale": "npm:^4.0.2" "@types/file-saver": "npm:^2.0.7" "@types/flot": "npm:^0.0.32" "@types/jest": "npm:^29.5.0" "@types/jquery": "npm:^3.5.16" - "@types/lodash": "npm:^4.14.188" "@types/node": "npm:^22.7.4" "@types/prismjs": "npm:^1.26.0" "@types/react": "npm:18.2.37" "@types/react-dom": "npm:18.2.15" "@types/react-helmet": "npm:^6.1.5" "@types/testing-library__jest-dom": "npm:5.14.8" + "@types/tinycolor2": "npm:^1" "@typescript-eslint/eslint-plugin": "npm:6.18.1" "@typescript-eslint/parser": "npm:6.18.1" bundlewatch: "npm:^0.4.0" @@ -11494,6 +11720,7 @@ __metadata: compression-streams-polyfill: "npm:^0.1.7" copy-webpack-plugin: "npm:^11.0.0" css-loader: "npm:^6.7.3" + d3: "npm:^7.9.0" dotenv: "npm:^16.3.1" esbuild-loader: "npm:^4.0.2" eslint: "npm:8.52.0" @@ -11528,6 +11755,8 @@ __metadata: react-helmet: "npm:^6.1.0" react-inlinesvg: "npm:^4.1.3" react-router-dom: "npm:^6.22.0" + react-use: "npm:^17.6.0" + react-virtualized-auto-sizer: "npm:^1.0.25" replace-in-file-webpack-plugin: "npm:^1.0.6" rxjs: "npm:7.8.1" sass: "npm:1.63.2" @@ -11536,6 +11765,7 @@ __metadata: style-loader: "npm:3.3.3" swc-loader: "npm:^0.2.3" terser-webpack-plugin: "npm:^5.3.10" + tinycolor2: "npm:^1.6.0" ts-jest: "npm:^29.1.0" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:^4.2.0" @@ -12618,13 +12848,28 @@ __metadata: languageName: node linkType: hard -"react-virtualized-auto-sizer@npm:1.0.24": - version: 1.0.24 - resolution: "react-virtualized-auto-sizer@npm:1.0.24" +"react-use@npm:^17.6.0": + version: 17.6.0 + resolution: "react-use@npm:17.6.0" + dependencies: + "@types/js-cookie": "npm:^2.2.6" + "@xobotyi/scrollbar-width": "npm:^1.9.5" + copy-to-clipboard: "npm:^3.3.1" + fast-deep-equal: "npm:^3.1.3" + fast-shallow-equal: "npm:^1.0.0" + js-cookie: "npm:^2.2.1" + nano-css: "npm:^5.6.2" + react-universal-interface: "npm:^0.6.2" + resize-observer-polyfill: "npm:^1.5.1" + screenfull: "npm:^5.1.0" + set-harmonic-interval: "npm:^1.0.1" + throttle-debounce: "npm:^3.0.1" + ts-easing: "npm:^0.2.0" + tslib: "npm:^2.1.0" peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 - checksum: 10/02101a340bdbe3e40c49dbc52e524eb7ca18832690e91f045a25675600d7adc0a63e800a4ace6a014132adcdcce0e12a8137971de408427a5a3112d7c87c9f3e + react: "*" + react-dom: "*" + checksum: 10/a817b74e82b481a39d3539bfe8d3b535c08d59d44a75ea91f65e56a7ccaedb0de185159e50b44ea4a635dda0c1c7159f07530e81a1d64b57130e0a715a107795 languageName: node linkType: hard @@ -12638,6 +12883,16 @@ __metadata: languageName: node linkType: hard +"react-virtualized-auto-sizer@npm:^1.0.25": + version: 1.0.25 + resolution: "react-virtualized-auto-sizer@npm:1.0.25" + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/43678a904019f0413a3c649b5b64ea51263283120c991b285077b5075cf2ea564571f6d48b3a396b588d500d45820d1c98989cb7091e2a009e73e4faa7da9d20 + languageName: node + linkType: hard + "react-window@npm:1.8.10": version: 1.8.10 resolution: "react-window@npm:1.8.10" @@ -14141,7 +14396,7 @@ __metadata: languageName: node linkType: hard -"tinycolor2@npm:1.6.0": +"tinycolor2@npm:1.6.0, tinycolor2@npm:^1.6.0": version: 1.6.0 resolution: "tinycolor2@npm:1.6.0" checksum: 10/066c3acf4f82b81c58a0d3ab85f49407efe95ba87afc3c7a16b1d77625193dfbe10dd46c26d0a263c1137361dd5a6a68bff2fb71def5fb9b9aec940fb030bcd4 From 54fca79e37c3558943af22901af24b36a58d1e9a Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Thu, 19 Dec 2024 14:44:22 +0100 Subject: [PATCH 2/8] feat: Keep sandwhich --- src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx index 1039ba77..599c6189 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -144,8 +144,8 @@ const FlameGraphContainer = ({ useEffect(() => { if (!keepFocusOnDataChange) { resetFocus(); + resetSandwich(); } - resetSandwich(); }, [data, keepFocusOnDataChange, resetFocus]); const onSymbolClick = useCallback( From efa05d68b21067acaa16b9cfeef4b4942750bb70 Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Tue, 14 Jan 2025 21:16:38 +0100 Subject: [PATCH 3/8] fix: Keep item focused at all times + cosmetic UI changes --- .../src/FlameGraph/FlameGraphMetadata.tsx | 13 ++++--- .../src/FlameGraphContainer.tsx | 34 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx index e624a199..89065cfb 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx @@ -61,12 +61,15 @@ const FlameGraphMetadata = memo( } if (focusedItem) { - const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100; + const percentValue = totalTicks > 0 ? Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100 : 0; + const iconName = percentValue > 0 ? 'eye' : 'exclamation-circle'; + parts.push( - +
- {percentValue}% of total + +  {percentValue}% of total ({ margin: theme.spacing(0, 0.5), }), metadata: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', margin: '8px 0', - textAlign: 'center', }), metadataPillName: css({ label: 'metadataPillName', diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx index 599c6189..7d13913e 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -142,11 +142,37 @@ const FlameGraphContainer = ({ } useEffect(() => { - if (!keepFocusOnDataChange) { - resetFocus(); - resetSandwich(); + if (keepFocusOnDataChange && dataContainer && focusedItemData) { + const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; + + if (item) { + setFocusedItemData({ ...focusedItemData, item }); + + const levels = dataContainer.getLevels(); + const totalViewTicks = levels.length ? levels[0][0].value : 0; + setRangeMin(item.start / totalViewTicks); + setRangeMax((item.start + item.value) / totalViewTicks); + } else { + setFocusedItemData({ + ...focusedItemData, + item: { + start: 0, + value: 0, + itemIndexes: [], + children: [], + level: 0, + }, + }); + setRangeMin(0); + setRangeMax(1); + } + + return; } - }, [data, keepFocusOnDataChange, resetFocus]); + + resetFocus(); + resetSandwich(); + }, [dataContainer, focusedItemData, keepFocusOnDataChange, resetFocus]); const onSymbolClick = useCallback( (symbol: string) => { From 9078f02d1aa66639e1422ee19595d4a332c8f348 Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Tue, 14 Jan 2025 21:44:19 +0100 Subject: [PATCH 4/8] fix: Prevent too many renders --- .../src/FlameGraphContainer.tsx | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx index 7d13913e..ef22cc75 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -137,42 +137,42 @@ const FlameGraphContainer = ({ setRangeMax(1); }, [setFocusedItemData, setRangeMax, setRangeMin]); - function resetSandwich() { + const resetSandwich = useCallback(() => { setSandwichItem(undefined); - } + }, [setSandwichItem]); useEffect(() => { - if (keepFocusOnDataChange && dataContainer && focusedItemData) { - const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; + if (!keepFocusOnDataChange || !dataContainer || !focusedItemData) { + resetFocus(); + resetSandwich(); + return; + } - if (item) { - setFocusedItemData({ ...focusedItemData, item }); + const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; - const levels = dataContainer.getLevels(); - const totalViewTicks = levels.length ? levels[0][0].value : 0; - setRangeMin(item.start / totalViewTicks); - setRangeMax((item.start + item.value) / totalViewTicks); - } else { - setFocusedItemData({ - ...focusedItemData, - item: { - start: 0, - value: 0, - itemIndexes: [], - children: [], - level: 0, - }, - }); - setRangeMin(0); - setRangeMax(1); - } + if (item) { + setFocusedItemData({ ...focusedItemData, item }); - return; + const levels = dataContainer.getLevels(); + const totalViewTicks = levels.length ? levels[0][0].value : 0; + setRangeMin(item.start / totalViewTicks); + setRangeMax((item.start + item.value) / totalViewTicks); + } else { + setFocusedItemData({ + ...focusedItemData, + item: { + start: 0, + value: 0, + itemIndexes: [], + children: [], + level: 0, + }, + }); + + setRangeMin(0); + setRangeMax(1); } - - resetFocus(); - resetSandwich(); - }, [dataContainer, focusedItemData, keepFocusOnDataChange, resetFocus]); + }, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps const onSymbolClick = useCallback( (symbol: string) => { From b3bb9971d7699506581c36a4b35f20748fc038d6 Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Tue, 14 Jan 2025 21:44:36 +0100 Subject: [PATCH 5/8] feat: Add tooltip on focus pill --- .../src/FlameGraph/FlameGraphMetadata.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx index 89065cfb..712529be 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { getValueFormat, GrafanaTheme2 } from '@grafana/data'; -import { Icon, IconButton, useStyles2 } from '@grafana/ui'; +import { Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui'; import React, { memo, ReactNode } from 'react'; import { ClickedItemData } from '../types'; @@ -65,21 +65,23 @@ const FlameGraphMetadata = memo( const iconName = percentValue > 0 ? 'eye' : 'exclamation-circle'; parts.push( - - -
- -  {percentValue}% of total - + +
+ +
+ +  {percentValue}% of total + +
- +
); } From 29d36508e74fbb7e35fe5d904458a2726378c67f Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Wed, 15 Jan 2025 12:03:10 +0100 Subject: [PATCH 6/8] fix(FlameGraph): Fix fatal runtime error when in sandwich view --- src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index 3bcf79e2..3dd250de 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -130,7 +130,7 @@ const FlameGraph = ({ search, selectedView, }; - const canvas = levelsCallers ? ( + const canvas = levelsCallers?.length ? ( <>
From 2d4a91a5e5469214a05e616ef33965d6d1bdc430 Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Thu, 16 Jan 2025 17:56:39 +0100 Subject: [PATCH 7/8] fix: Keep sandwich view --- .../src/FlameGraphContainer.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx index ef22cc75..521af0c2 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -142,35 +142,37 @@ const FlameGraphContainer = ({ }, [setSandwichItem]); useEffect(() => { - if (!keepFocusOnDataChange || !dataContainer || !focusedItemData) { + if (!keepFocusOnDataChange) { resetFocus(); resetSandwich(); return; } - const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; + if (dataContainer && focusedItemData) { + const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; - if (item) { - setFocusedItemData({ ...focusedItemData, item }); + if (item) { + setFocusedItemData({ ...focusedItemData, item }); - const levels = dataContainer.getLevels(); - const totalViewTicks = levels.length ? levels[0][0].value : 0; - setRangeMin(item.start / totalViewTicks); - setRangeMax((item.start + item.value) / totalViewTicks); - } else { - setFocusedItemData({ - ...focusedItemData, - item: { - start: 0, - value: 0, - itemIndexes: [], - children: [], - level: 0, - }, - }); - - setRangeMin(0); - setRangeMax(1); + const levels = dataContainer.getLevels(); + const totalViewTicks = levels.length ? levels[0][0].value : 0; + setRangeMin(item.start / totalViewTicks); + setRangeMax((item.start + item.value) / totalViewTicks); + } else { + setFocusedItemData({ + ...focusedItemData, + item: { + start: 0, + value: 0, + itemIndexes: [], + children: [], + level: 0, + }, + }); + + setRangeMin(0); + setRangeMax(1); + } } }, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps From 56d07b64e97c0b6e6ebfacceda12c6e3ec7f389e Mon Sep 17 00:00:00 2001 From: Marc Mignonsin Date: Fri, 17 Jan 2025 14:01:47 +0100 Subject: [PATCH 8/8] fix: Edge case with sandwich view --- .../src/FlameGraph/FlameGraph.tsx | 68 ++++++++++--------- .../src/FlameGraph/FlameGraphMetadata.tsx | 34 +++++----- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index 3dd250de..99b998db 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -130,40 +130,46 @@ const FlameGraph = ({ search, selectedView, }; - const canvas = levelsCallers?.length ? ( - <> -
-
- Callers - + let canvas = null; + + if (levelsCallers?.length) { + canvas = ( + <> +
+
+ Callers + +
+
- -
-
-
- - Callees +
+
+ + Callees +
+
- -
- - ) : ( - - ); + + ); + } else if (levels?.length) { + canvas = ( + + ); + } return (
diff --git a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx index 712529be..4db955ba 100644 --- a/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx +++ b/src/tmp/grafana-flamegraph/src/FlameGraph/FlameGraphMetadata.tsx @@ -40,23 +40,25 @@ const FlameGraphMetadata = memo( if (sandwichedLabel) { parts.push( - - -
- {' '} - - {sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)} - - + +
+ +
+ {' '} + + {sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)} + + +
- +
); }