mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
FlameGraph: Add sandwich view (#70268)
This commit is contained in:
parent
db44ba305e
commit
5ca03a82f0
@ -4922,6 +4922,9 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/panel/flamegraph/components/FlameGraphTopWrapper.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/flamegraph/flamegraphV2/components/FlameGraphTopWrapper.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/gauge/GaugeMigrations.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
|
@ -115,6 +115,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `exploreScrollableLogsContainer` | Improves the scrolling behavior of logs in Explore |
|
||||
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries |
|
||||
| `alertingLokiRangeToInstant` | Rewrites eligible loki range queries to instant queries |
|
||||
| `flameGraphV2` | New version of flame graph with new features |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
@ -102,4 +102,5 @@ export interface FeatureToggles {
|
||||
exploreScrollableLogsContainer?: boolean;
|
||||
recordedQueriesMulti?: boolean;
|
||||
alertingLokiRangeToInstant?: boolean;
|
||||
flameGraphV2?: boolean;
|
||||
}
|
||||
|
@ -571,5 +571,12 @@ var (
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaAlertingSquad,
|
||||
},
|
||||
{
|
||||
Name: "flameGraphV2",
|
||||
Description: "New version of flame graph with new features",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityTracesAndProfilingSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -83,3 +83,4 @@ cloudWatchLogsMonacoEditor,experimental,@grafana/aws-plugins,false,false,false,t
|
||||
exploreScrollableLogsContainer,experimental,@grafana/observability-logs,false,false,false,true
|
||||
recordedQueriesMulti,experimental,@grafana/observability-metrics,false,false,false,false
|
||||
alertingLokiRangeToInstant,experimental,@grafana/alerting-squad,false,false,false,false
|
||||
flameGraphV2,experimental,@grafana/observability-traces-and-profiling,false,false,false,true
|
||||
|
|
@ -342,4 +342,8 @@ const (
|
||||
// FlagAlertingLokiRangeToInstant
|
||||
// Rewrites eligible loki range queries to instant queries
|
||||
FlagAlertingLokiRangeToInstant = "alertingLokiRangeToInstant"
|
||||
|
||||
// FlagFlameGraphV2
|
||||
// New version of flame graph with new features
|
||||
FlagFlameGraphV2 = "flameGraphV2"
|
||||
)
|
||||
|
@ -1,9 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp, PanelProps } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import FlameGraphContainer from './components/FlameGraphContainer';
|
||||
import FlameGraphContainerV2 from './flamegraphV2/components/FlameGraphContainer';
|
||||
|
||||
export const FlameGraphPanel = (props: PanelProps) => {
|
||||
return <FlameGraphContainer data={props.data.series[0]} app={CoreApp.Unknown} />;
|
||||
return config.featureToggles.flameGraphV2 ? (
|
||||
<FlameGraphContainerV2 data={props.data.series[0]} app={CoreApp.Unknown} />
|
||||
) : (
|
||||
<FlameGraphContainer data={props.data.series[0]} app={CoreApp.Unknown} />
|
||||
);
|
||||
};
|
||||
|
@ -5,6 +5,8 @@ import '@pyroscope/flamegraph/dist/index.css';
|
||||
import { CoreApp, DataFrame, DataFrameView } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import FlameGraphContainerV2 from '../flamegraphV2/components/FlameGraphContainer';
|
||||
|
||||
import FlameGraphContainer from './FlameGraphContainer';
|
||||
|
||||
type Props = {
|
||||
@ -22,7 +24,11 @@ export const FlameGraphTopWrapper = (props: Props) => {
|
||||
return <FlamegraphRenderer profile={profile} />;
|
||||
}
|
||||
|
||||
return <FlameGraphContainer data={props.data} app={props.app} />;
|
||||
return config.featureToggles.flameGraphV2 ? (
|
||||
<FlameGraphContainerV2 data={props.data} app={props.app} />
|
||||
) : (
|
||||
<FlameGraphContainer data={props.data} app={props.app} />
|
||||
);
|
||||
};
|
||||
|
||||
type Row = {
|
||||
|
@ -0,0 +1,95 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { createDataFrame } from '@grafana/data';
|
||||
|
||||
import FlameGraph from './FlameGraph';
|
||||
import { FlameGraphDataContainer } from './dataTransform';
|
||||
import { data } from './testData/dataNestedSet';
|
||||
|
||||
import 'jest-canvas-mock';
|
||||
|
||||
jest.mock('react-use', () => {
|
||||
const reactUse = jest.requireActual('react-use');
|
||||
return {
|
||||
...reactUse,
|
||||
useMeasure: () => {
|
||||
const ref = React.useRef();
|
||||
return [ref, { width: 1600 }];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('FlameGraph', () => {
|
||||
function setup() {
|
||||
const flameGraphData = createDataFrame(data);
|
||||
const container = new FlameGraphDataContainer(flameGraphData);
|
||||
|
||||
const setRangeMin = jest.fn();
|
||||
const setRangeMax = jest.fn();
|
||||
const onItemFocused = jest.fn();
|
||||
const onSandwich = jest.fn();
|
||||
const onFocusPillClick = jest.fn();
|
||||
const onSandwichPillClick = jest.fn();
|
||||
|
||||
const renderResult = render(
|
||||
<FlameGraph
|
||||
data={container}
|
||||
rangeMin={0}
|
||||
rangeMax={1}
|
||||
search={''}
|
||||
setRangeMin={setRangeMin}
|
||||
setRangeMax={setRangeMax}
|
||||
onItemFocused={onItemFocused}
|
||||
textAlign={'left'}
|
||||
onSandwich={onSandwich}
|
||||
onFocusPillClick={onFocusPillClick}
|
||||
onSandwichPillClick={onSandwichPillClick}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
renderResult,
|
||||
mocks: {
|
||||
setRangeMax,
|
||||
setRangeMin,
|
||||
onItemFocused,
|
||||
onSandwich,
|
||||
onFocusPillClick,
|
||||
onSandwichPillClick,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('should render without error', async () => {
|
||||
setup();
|
||||
});
|
||||
|
||||
it('should render correctly', async () => {
|
||||
setup();
|
||||
|
||||
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
|
||||
const ctx = canvas!.getContext('2d');
|
||||
const calls = ctx!.__getDrawCalls();
|
||||
expect(calls).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render metadata', async () => {
|
||||
setup();
|
||||
expect(screen.getByText('16.5 Bil | 16.5 Bil samples (Count)')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render context menu', async () => {
|
||||
const event = new MouseEvent('click', { bubbles: true });
|
||||
Object.defineProperty(event, 'offsetX', { get: () => 10 });
|
||||
Object.defineProperty(event, 'offsetY', { get: () => 10 });
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 });
|
||||
|
||||
setup();
|
||||
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
|
||||
expect(canvas).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent(canvas, event);
|
||||
expect(screen.getByTestId('contextMenu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,311 @@
|
||||
// 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 { css } from '@emotion/css';
|
||||
import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { PIXELS_PER_LEVEL } from '../../constants';
|
||||
import { ClickedItemData, TextAlign } from '../types';
|
||||
|
||||
import FlameGraphContextMenu from './FlameGraphContextMenu';
|
||||
import FlameGraphMetadata from './FlameGraphMetadata';
|
||||
import FlameGraphTooltip from './FlameGraphTooltip';
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { getBarX, useFlameRender } from './rendering';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
rangeMin: number;
|
||||
rangeMax: number;
|
||||
search: string;
|
||||
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;
|
||||
};
|
||||
|
||||
const FlameGraph = ({
|
||||
data,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
search,
|
||||
setRangeMin,
|
||||
setRangeMax,
|
||||
onItemFocused,
|
||||
focusedItemData,
|
||||
textAlign,
|
||||
onSandwich,
|
||||
sandwichItem,
|
||||
onFocusPillClick,
|
||||
onSandwichPillClick,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [levels, totalTicks, callersCount] = useMemo(() => {
|
||||
let levels = data.getLevels();
|
||||
let totalTicks = levels.length ? levels[0][0].value : 0;
|
||||
let callersCount = 0;
|
||||
|
||||
if (sandwichItem) {
|
||||
const [callers, callees] = data.getSandwichLevels(sandwichItem);
|
||||
levels = [...callers, [], ...callees];
|
||||
totalTicks = callees.length ? callees[0][0].value : 0;
|
||||
callersCount = callers.length;
|
||||
}
|
||||
return [levels, totalTicks, callersCount];
|
||||
}, [data, sandwichItem]);
|
||||
|
||||
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
|
||||
const graphRef = useRef<HTMLCanvasElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltipItem, setTooltipItem] = useState<LevelItem>();
|
||||
|
||||
const [clickedItemData, setClickedItemData] = useState<ClickedItemData>();
|
||||
|
||||
useFlameRender(
|
||||
graphRef,
|
||||
data,
|
||||
levels,
|
||||
wrapperWidth,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
search,
|
||||
textAlign,
|
||||
totalTicks,
|
||||
focusedItemData
|
||||
);
|
||||
|
||||
const onGraphClick = useCallback(
|
||||
(e: ReactMouseEvent<HTMLCanvasElement>) => {
|
||||
setTooltipItem(undefined);
|
||||
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
|
||||
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
|
||||
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
|
||||
levels,
|
||||
pixelsPerTick,
|
||||
totalTicks,
|
||||
rangeMin
|
||||
);
|
||||
|
||||
// if clicking on a block in the canvas
|
||||
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
|
||||
const item = levels[levelIndex][barIndex];
|
||||
setClickedItemData({
|
||||
posY: e.clientY,
|
||||
posX: e.clientX,
|
||||
item,
|
||||
level: levelIndex,
|
||||
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, totalTicks, levels]
|
||||
);
|
||||
|
||||
const onGraphMouseMove = useCallback(
|
||||
(e: ReactMouseEvent<HTMLCanvasElement>) => {
|
||||
if (tooltipRef.current && clickedItemData === undefined) {
|
||||
setTooltipItem(undefined);
|
||||
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
|
||||
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
|
||||
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
|
||||
levels,
|
||||
pixelsPerTick,
|
||||
totalTicks,
|
||||
rangeMin
|
||||
);
|
||||
|
||||
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
|
||||
tooltipRef.current.style.top = e.clientY + 'px';
|
||||
if (document.documentElement.clientWidth - e.clientX < 400) {
|
||||
tooltipRef.current.style.right = document.documentElement.clientWidth - e.clientX + 15 + 'px';
|
||||
tooltipRef.current.style.left = 'auto';
|
||||
} else {
|
||||
tooltipRef.current.style.left = e.clientX + 15 + 'px';
|
||||
tooltipRef.current.style.right = 'auto';
|
||||
}
|
||||
|
||||
setTooltipItem(levels[levelIndex][barIndex]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[rangeMin, rangeMax, totalTicks, clickedItemData, levels]
|
||||
);
|
||||
|
||||
const onGraphMouseLeave = useCallback(() => {
|
||||
setTooltipItem(undefined);
|
||||
}, []);
|
||||
|
||||
// hide context menu if outside the flame graph canvas is clicked
|
||||
useEffect(() => {
|
||||
const handleOnClick = (e: MouseEvent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
if ((e.target as HTMLElement).parentElement?.id !== 'flameGraphCanvasContainer_clickOutsideCheck') {
|
||||
setClickedItemData(undefined);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleOnClick);
|
||||
return () => window.removeEventListener('click', handleOnClick);
|
||||
}, [setClickedItemData]);
|
||||
|
||||
return (
|
||||
<div className={styles.graph}>
|
||||
<FlameGraphMetadata
|
||||
data={data}
|
||||
focusedItem={focusedItemData}
|
||||
sandwichedLabel={sandwichItem}
|
||||
totalTicks={totalTicks}
|
||||
onFocusPillClick={onFocusPillClick}
|
||||
onSandwichPillClick={onSandwichPillClick}
|
||||
/>
|
||||
<div className={styles.canvasContainer}>
|
||||
{sandwichItem && (
|
||||
<div>
|
||||
<div
|
||||
className={styles.sandwichMarker}
|
||||
style={{ height: (callersCount * PIXELS_PER_LEVEL) / window.devicePixelRatio }}
|
||||
>
|
||||
Callers
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
|
||||
</div>
|
||||
<div className={styles.sandwichMarker} style={{ marginTop: PIXELS_PER_LEVEL / window.devicePixelRatio }}>
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
|
||||
Callees
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.canvasWrapper} id="flameGraphCanvasContainer_clickOutsideCheck" ref={sizeRef}>
|
||||
<canvas
|
||||
ref={graphRef}
|
||||
data-testid="flameGraph"
|
||||
onClick={onGraphClick}
|
||||
onMouseMove={onGraphMouseMove}
|
||||
onMouseLeave={onGraphMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FlameGraphTooltip tooltipRef={tooltipRef} item={tooltipItem} data={data} totalTicks={totalTicks} />
|
||||
{clickedItemData && (
|
||||
<FlameGraphContextMenu
|
||||
itemData={clickedItemData}
|
||||
onMenuItemClick={() => {
|
||||
setClickedItemData(undefined);
|
||||
}}
|
||||
onItemFocus={() => {
|
||||
setRangeMin(clickedItemData.item.start / totalTicks);
|
||||
setRangeMax((clickedItemData.item.start + clickedItemData.item.value) / totalTicks);
|
||||
onItemFocused(clickedItemData);
|
||||
}}
|
||||
onSandwich={() => {
|
||||
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
graph: css`
|
||||
overflow: scroll;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
flex-basis: 50%;
|
||||
`,
|
||||
canvasContainer: css`
|
||||
label: canvasContainer;
|
||||
display: flex;
|
||||
`,
|
||||
canvasWrapper: css`
|
||||
label: canvasWrapper;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`,
|
||||
sandwichMarker: css`
|
||||
writing-mode: vertical-lr;
|
||||
transform: rotate(180deg);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
sandwichMarkerIcon: css`
|
||||
vertical-align: baseline;
|
||||
`,
|
||||
});
|
||||
|
||||
// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to
|
||||
// the canvas.
|
||||
const convertPixelCoordinatesToBarCoordinates = (
|
||||
// position relative to the start of the graph
|
||||
pos: { x: number; y: number },
|
||||
levels: LevelItem[][],
|
||||
pixelsPerTick: number,
|
||||
totalTicks: number,
|
||||
rangeMin: number
|
||||
) => {
|
||||
const levelIndex = Math.floor(pos.y / (PIXELS_PER_LEVEL / window.devicePixelRatio));
|
||||
const barIndex = getBarIndex(pos.x, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin);
|
||||
return { levelIndex, barIndex };
|
||||
};
|
||||
|
||||
/**
|
||||
* Binary search for a bar in a level, based on the X pixel coordinate. Useful for detecting which bar did user click
|
||||
* on.
|
||||
*/
|
||||
const getBarIndex = (x: number, level: LevelItem[], pixelsPerTick: number, totalTicks: number, rangeMin: number) => {
|
||||
if (level) {
|
||||
let start = 0;
|
||||
let end = level.length - 1;
|
||||
|
||||
while (start <= end) {
|
||||
const midIndex = (start + end) >> 1;
|
||||
const startOfBar = getBarX(level[midIndex].start, totalTicks, rangeMin, pixelsPerTick);
|
||||
const startOfNextBar = getBarX(
|
||||
level[midIndex].start + level[midIndex].value,
|
||||
totalTicks,
|
||||
rangeMin,
|
||||
pixelsPerTick
|
||||
);
|
||||
|
||||
if (startOfBar <= x && startOfNextBar >= x) {
|
||||
return midIndex;
|
||||
}
|
||||
|
||||
if (startOfBar > x) {
|
||||
end = midIndex - 1;
|
||||
} else {
|
||||
start = midIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
export default FlameGraph;
|
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MenuItem, ContextMenu } from '@grafana/ui';
|
||||
|
||||
import { ClickedItemData } from '../types';
|
||||
|
||||
type Props = {
|
||||
itemData: ClickedItemData;
|
||||
onMenuItemClick: () => void;
|
||||
onItemFocus: () => void;
|
||||
onSandwich: () => void;
|
||||
};
|
||||
|
||||
const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandwich }: Props) => {
|
||||
function renderItems() {
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
label="Focus block"
|
||||
icon={'eye'}
|
||||
onClick={() => {
|
||||
onItemFocus();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Copy function name"
|
||||
icon={'copy'}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(itemData.label).then(() => {
|
||||
onMenuItemClick();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Sandwich view"
|
||||
icon={'gf-show-context'}
|
||||
onClick={() => {
|
||||
onSandwich();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="contextMenu">
|
||||
<ContextMenu
|
||||
renderMenuItems={renderItems}
|
||||
x={itemData.posX + 10}
|
||||
y={itemData.posY}
|
||||
focusOnOpen={false}
|
||||
></ContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlameGraphContextMenu;
|
@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import FlameGraphMetadata from './FlameGraphMetadata';
|
||||
import { textToDataContainer } from './testHelpers';
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof FlameGraphMetadata>> = {}) {
|
||||
const container = textToDataContainer(`
|
||||
[1//////////////]
|
||||
[2][4//][7///]
|
||||
[3][5]
|
||||
[6]
|
||||
`)!;
|
||||
|
||||
const onFocusPillClick = jest.fn();
|
||||
const onSandwichPillClick = jest.fn();
|
||||
const renderResult = render(
|
||||
<FlameGraphMetadata
|
||||
data={container}
|
||||
totalTicks={17}
|
||||
onFocusPillClick={onFocusPillClick}
|
||||
onSandwichPillClick={onSandwichPillClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return { renderResult, mocks: { onSandwichPillClick, onFocusPillClick } };
|
||||
}
|
||||
|
||||
describe('FlameGraphMetadata', () => {
|
||||
it('shows only default pill if not focus or sandwich', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Remove focus/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/Remove sandwich/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows focus pill', async () => {
|
||||
const { mocks } = setup({
|
||||
focusedItem: {
|
||||
label: '4',
|
||||
item: {
|
||||
value: 5,
|
||||
children: [],
|
||||
itemIndexes: [3],
|
||||
start: 3,
|
||||
},
|
||||
level: 0,
|
||||
posX: 0,
|
||||
posY: 0,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/29.41% of total/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Remove sandwich/)).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByLabelText(/Remove focus/));
|
||||
expect(mocks.onFocusPillClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows sandwich state', async () => {
|
||||
const { mocks } = setup({
|
||||
sandwichedLabel: 'some/random/func.go',
|
||||
});
|
||||
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Remove focus/)).toBeNull();
|
||||
expect(screen.getByText(/func.go/)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText(/Remove sandwich/));
|
||||
expect(mocks.onSandwichPillClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
@ -0,0 +1,113 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { getValueFormat, GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ClickedItemData } from '../types';
|
||||
|
||||
import { FlameGraphDataContainer } from './dataTransform';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
totalTicks: number;
|
||||
onFocusPillClick: () => void;
|
||||
onSandwichPillClick: () => void;
|
||||
focusedItem?: ClickedItemData;
|
||||
sandwichedLabel?: string;
|
||||
};
|
||||
|
||||
const FlameGraphMetadata = React.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(
|
||||
<div className={styles.metadataPill} key={'default'}>
|
||||
{unitValue} | {ticksVal.text}
|
||||
{ticksVal.suffix} samples ({unitTitle})
|
||||
</div>
|
||||
);
|
||||
|
||||
if (sandwichedLabel) {
|
||||
parts.push(
|
||||
<span key={'sandwich'}>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={'gf-show-context'} />{' '}
|
||||
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onSandwichPillClick}
|
||||
tooltip={'Remove sandwich view'}
|
||||
aria-label={'Remove sandwich view'}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedItem) {
|
||||
const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100;
|
||||
parts.push(
|
||||
<span key={'focus'}>
|
||||
<Icon size={'sm'} name={'angle-right'} />
|
||||
<div className={styles.metadataPill}>
|
||||
<Icon size={'sm'} name={'eye'} /> {percentValue}% of total
|
||||
<IconButton
|
||||
className={styles.pillCloseButton}
|
||||
name={'times'}
|
||||
size={'sm'}
|
||||
onClick={onFocusPillClick}
|
||||
tooltip={'Remove focus'}
|
||||
aria-label={'Remove focus'}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{<div className={styles.metadata}>{parts}</div>}</>;
|
||||
}
|
||||
);
|
||||
|
||||
FlameGraphMetadata.displayName = 'FlameGraphMetadata';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
metadataPill: css`
|
||||
label: metadataPill;
|
||||
display: inline-block;
|
||||
background: ${theme.colors.background.secondary};
|
||||
border-radius: ${theme.shape.borderRadius(8)};
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
font-weight: ${theme.typography.fontWeightMedium};
|
||||
line-height: ${theme.typography.bodySmall.lineHeight};
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
|
||||
pillCloseButton: css`
|
||||
label: pillCloseButton;
|
||||
vertical-align: text-bottom;
|
||||
margin: ${theme.spacing(0, 0.5)};
|
||||
`,
|
||||
metadata: css`
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
`,
|
||||
});
|
||||
|
||||
export default FlameGraphMetadata;
|
@ -0,0 +1,114 @@
|
||||
import { Field, FieldType, createDataFrame } from '@grafana/data';
|
||||
|
||||
import { getTooltipData } from './FlameGraphTooltip';
|
||||
import { FlameGraphDataContainer } from './dataTransform';
|
||||
|
||||
function setupData(unit?: string) {
|
||||
const flameGraphData = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'level', values: [0] },
|
||||
unit ? makeField('value', unit, [8_624_078_250]) : { name: 'value', values: [8_624_078_250] },
|
||||
{ name: 'self', values: [978_250] },
|
||||
{ name: 'label', values: ['total'] },
|
||||
],
|
||||
});
|
||||
return new FlameGraphDataContainer(flameGraphData);
|
||||
}
|
||||
|
||||
describe('FlameGraphTooltip', () => {
|
||||
it('for bytes', () => {
|
||||
const tooltipData = getTooltipData(
|
||||
setupData('bytes'),
|
||||
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
|
||||
8_624_078_250
|
||||
);
|
||||
expect(tooltipData).toEqual({
|
||||
name: 'total',
|
||||
percentSelf: 0.01,
|
||||
percentValue: 100,
|
||||
unitTitle: 'RAM',
|
||||
unitSelf: '955 KiB',
|
||||
unitValue: '8.03 GiB',
|
||||
samples: '8,624,078,250',
|
||||
});
|
||||
});
|
||||
|
||||
it('with default unit', () => {
|
||||
const tooltipData = getTooltipData(
|
||||
setupData('none'),
|
||||
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
|
||||
8_624_078_250
|
||||
);
|
||||
expect(tooltipData).toEqual({
|
||||
name: 'total',
|
||||
percentSelf: 0.01,
|
||||
percentValue: 100,
|
||||
unitSelf: '978250',
|
||||
unitTitle: 'Count',
|
||||
unitValue: '8624078250',
|
||||
samples: '8,624,078,250',
|
||||
});
|
||||
});
|
||||
|
||||
it('without unit', () => {
|
||||
const tooltipData = getTooltipData(
|
||||
setupData('none'),
|
||||
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
|
||||
8_624_078_250
|
||||
);
|
||||
expect(tooltipData).toEqual({
|
||||
name: 'total',
|
||||
percentSelf: 0.01,
|
||||
percentValue: 100,
|
||||
unitTitle: 'Count',
|
||||
unitSelf: '978250',
|
||||
unitValue: '8624078250',
|
||||
samples: '8,624,078,250',
|
||||
});
|
||||
});
|
||||
|
||||
it('for objects', () => {
|
||||
const tooltipData = getTooltipData(
|
||||
setupData('short'),
|
||||
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
|
||||
8_624_078_250
|
||||
);
|
||||
expect(tooltipData).toEqual({
|
||||
name: 'total',
|
||||
percentSelf: 0.01,
|
||||
percentValue: 100,
|
||||
unitTitle: 'Count',
|
||||
unitSelf: '978 K',
|
||||
unitValue: '8.62 Bil',
|
||||
samples: '8,624,078,250',
|
||||
});
|
||||
});
|
||||
|
||||
it('for nanoseconds', () => {
|
||||
const tooltipData = getTooltipData(
|
||||
setupData('ns'),
|
||||
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
|
||||
8_624_078_250
|
||||
);
|
||||
expect(tooltipData).toEqual({
|
||||
name: 'total',
|
||||
percentSelf: 0.01,
|
||||
percentValue: 100,
|
||||
unitTitle: 'Time',
|
||||
unitSelf: '978 µs',
|
||||
unitValue: '8.62 s',
|
||||
samples: '8,624,078,250',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeField(name: string, unit: string, values: number[]): Field {
|
||||
return {
|
||||
name,
|
||||
type: FieldType.number,
|
||||
config: {
|
||||
unit,
|
||||
},
|
||||
values: values,
|
||||
};
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { LegacyRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
totalTicks: number;
|
||||
item?: LevelItem;
|
||||
tooltipRef?: LegacyRef<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const FlameGraphTooltip = ({ data, tooltipRef, item, totalTicks }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
let content = null;
|
||||
if (item) {
|
||||
const tooltipData = getTooltipData(data, item, totalTicks);
|
||||
content = (
|
||||
<div className={styles.tooltipContent}>
|
||||
<p>{data.getLabel(item.itemIndexes[0])}</p>
|
||||
<p className={styles.lastParagraph}>
|
||||
{tooltipData.unitTitle}
|
||||
<br />
|
||||
Total: <b>{tooltipData.unitValue}</b> ({tooltipData.percentValue}%)
|
||||
<br />
|
||||
Self: <b>{tooltipData.unitSelf}</b> ({tooltipData.percentSelf}%)
|
||||
<br />
|
||||
Samples: <b>{tooltipData.samples}</b>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Even if we don't show tooltip we need this div so the ref is consistently attached. Would need some refactor in
|
||||
// FlameGraph.tsx to make it work without it.
|
||||
return (
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TooltipData = {
|
||||
name: string;
|
||||
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 {
|
||||
name: data.getLabel(item.itemIndexes[0]),
|
||||
percentValue,
|
||||
percentSelf,
|
||||
unitTitle,
|
||||
unitValue,
|
||||
unitSelf,
|
||||
samples: displayValue.numeric.toLocaleString(),
|
||||
};
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tooltip: css`
|
||||
title: tooltip;
|
||||
position: fixed;
|
||||
`,
|
||||
tooltipContent: css`
|
||||
title: tooltipContent;
|
||||
background-color: ${theme.components.tooltip.background};
|
||||
border-radius: ${theme.shape.radius.default};
|
||||
border: 1px solid ${theme.components.tooltip.background};
|
||||
box-shadow: ${theme.shadows.z2};
|
||||
color: ${theme.components.tooltip.text};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
padding: ${theme.spacing(0.5, 1)};
|
||||
transition: opacity 0.3s;
|
||||
z-index: ${theme.zIndex.tooltip};
|
||||
max-width: 400px;
|
||||
overflow-wrap: break-word;
|
||||
`,
|
||||
lastParagraph: css`
|
||||
title: lastParagraph;
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
name: css`
|
||||
title: name;
|
||||
margin-bottom: 10px;
|
||||
`,
|
||||
});
|
||||
|
||||
export default FlameGraphTooltip;
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
import { createDataFrame } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform';
|
||||
|
||||
describe('nestedSetToLevels', () => {
|
||||
it('converts nested set data frame to levels', () => {
|
||||
// [1------]
|
||||
// [2---][6]
|
||||
// [3][5][7]
|
||||
// [4] [8]
|
||||
// [9]
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'level', values: [0, 1, 2, 3, 2, 1, 2, 3, 4] },
|
||||
{ name: 'value', values: [10, 5, 3, 1, 1, 4, 3, 2, 1] },
|
||||
{ name: 'label', values: ['1', '2', '3', '4', '5', '6', '7', '8', '9'] },
|
||||
{ name: 'self', values: [0, 0, 0, 0, 0, 0, 0, 0, 0] },
|
||||
],
|
||||
});
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
|
||||
|
||||
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1 };
|
||||
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2 };
|
||||
const n7: LevelItem = { itemIndexes: [6], start: 5, children: [n8], value: 3 };
|
||||
const n6: LevelItem = { itemIndexes: [5], start: 5, children: [n7], value: 4 };
|
||||
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [], value: 1 };
|
||||
const n4: LevelItem = { itemIndexes: [3], start: 0, children: [], value: 1 };
|
||||
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [n4], value: 3 };
|
||||
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3, n5], value: 5 };
|
||||
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n6], value: 10 };
|
||||
|
||||
n2.parents = [n1];
|
||||
n6.parents = [n1];
|
||||
n3.parents = [n2];
|
||||
n5.parents = [n2];
|
||||
n4.parents = [n3];
|
||||
n7.parents = [n6];
|
||||
n8.parents = [n7];
|
||||
n9.parents = [n8];
|
||||
|
||||
expect(levels[0]).toEqual([n1]);
|
||||
expect(levels[1]).toEqual([n2, n6]);
|
||||
expect(levels[2]).toEqual([n3, n5, n7]);
|
||||
expect(levels[3]).toEqual([n4, n8]);
|
||||
expect(levels[4]).toEqual([n9]);
|
||||
});
|
||||
|
||||
it('converts nested set data if multiple same level items', () => {
|
||||
const frame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'level', values: [0, 1, 1, 1] },
|
||||
{ name: 'value', values: [10, 5, 3, 1] },
|
||||
{ name: 'label', values: ['1', '2', '3', '4'] },
|
||||
{ name: 'self', values: [10, 5, 3, 1] },
|
||||
],
|
||||
});
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
|
||||
|
||||
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1 };
|
||||
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3 };
|
||||
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [], value: 5 };
|
||||
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n3, n4], value: 10 };
|
||||
|
||||
n2.parents = [n1];
|
||||
n3.parents = [n1];
|
||||
n4.parents = [n1];
|
||||
|
||||
expect(levels[0]).toEqual([n1]);
|
||||
expect(levels[1]).toEqual([n2, n3, n4]);
|
||||
});
|
||||
});
|
@ -0,0 +1,201 @@
|
||||
import {
|
||||
createTheme,
|
||||
DataFrame,
|
||||
DisplayProcessor,
|
||||
Field,
|
||||
getDisplayProcessor,
|
||||
getEnumDisplayProcessor,
|
||||
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.
|
||||
value: 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[];
|
||||
parents?: LevelItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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): [LevelItem[][], Record<string, LevelItem[]>] {
|
||||
const levels: LevelItem[][] = [];
|
||||
let offset = 0;
|
||||
|
||||
let parent: LevelItem | undefined = undefined;
|
||||
const uniqueLabels: Record<string, LevelItem[]> = {};
|
||||
|
||||
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]);
|
||||
// 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),
|
||||
start: offset,
|
||||
parents: parent && [parent],
|
||||
children: [],
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return [levels, uniqueLabels];
|
||||
}
|
||||
|
||||
export class FlameGraphDataContainer {
|
||||
data: DataFrame;
|
||||
labelField: Field;
|
||||
levelField: Field;
|
||||
valueField: Field;
|
||||
selfField: Field;
|
||||
|
||||
labelDisplayProcessor: DisplayProcessor;
|
||||
valueDisplayProcessor: DisplayProcessor;
|
||||
uniqueLabels: string[];
|
||||
|
||||
private levels: LevelItem[][] | undefined;
|
||||
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
|
||||
|
||||
constructor(data: DataFrame, theme: GrafanaTheme2 = createTheme()) {
|
||||
this.data = data;
|
||||
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')!;
|
||||
|
||||
if (!(this.labelField && this.levelField && this.valueField && this.selfField)) {
|
||||
throw new Error('Malformed dataFrame: value, level and label and self fields are required.');
|
||||
}
|
||||
|
||||
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 = getEnumDisplayProcessor(theme, enumConfig);
|
||||
this.uniqueLabels = enumConfig.text || [];
|
||||
} else {
|
||||
this.labelDisplayProcessor = (value) => ({
|
||||
text: value + '',
|
||||
numeric: 0,
|
||||
});
|
||||
this.uniqueLabels = [...new Set<string>(this.labelField.values)];
|
||||
}
|
||||
|
||||
this.valueDisplayProcessor = getDisplayProcessor({
|
||||
field: this.valueField,
|
||||
theme,
|
||||
});
|
||||
}
|
||||
|
||||
getLabel(index: number) {
|
||||
return this.labelDisplayProcessor(this.labelField.values[index]).text;
|
||||
}
|
||||
|
||||
getLevel(index: number) {
|
||||
return this.levelField.values[index];
|
||||
}
|
||||
|
||||
getValue(index: number | number[]) {
|
||||
let indexArray: number[] = typeof index === 'number' ? [index] : index;
|
||||
return indexArray.reduce((acc, index) => {
|
||||
return acc + this.valueField.values[index];
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getValueDisplay(index: number | number[]) {
|
||||
return this.valueDisplayProcessor(this.getValue(index));
|
||||
}
|
||||
|
||||
getSelf(index: number | number[]) {
|
||||
let indexArray: number[] = typeof index === 'number' ? [index] : index;
|
||||
return indexArray.reduce((acc, index) => {
|
||||
return acc + this.selfField.values[index];
|
||||
}, 0);
|
||||
}
|
||||
|
||||
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) {
|
||||
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];
|
||||
}
|
||||
|
||||
private initLevels() {
|
||||
if (!this.levels) {
|
||||
const [levels, uniqueLabelsMap] = nestedSetToLevels(this);
|
||||
this.levels = levels;
|
||||
this.uniqueLabelsMap = uniqueLabelsMap;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import { createDataFrame } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { getRectDimensionsForLevel } from './rendering';
|
||||
|
||||
function makeDataFrame(fields: Record<string, Array<number | string>>) {
|
||||
return createDataFrame({
|
||||
fields: Object.keys(fields).map((key) => ({
|
||||
name: key,
|
||||
values: fields[key],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe('getRectDimensionsForLevel', () => {
|
||||
it('should render a single item', () => {
|
||||
const level: LevelItem[] = [{ start: 0, itemIndexes: [0], children: [], value: 100 }];
|
||||
const container = new FlameGraphDataContainer(makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }));
|
||||
const result = getRectDimensionsForLevel(container, level, 1, 100, 0, 10);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
width: 999,
|
||||
height: 22,
|
||||
itemIndex: 0,
|
||||
x: 0,
|
||||
y: 22,
|
||||
collapsed: false,
|
||||
ticks: 100,
|
||||
label: '1',
|
||||
unitLabel: '100',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a multiple items', () => {
|
||||
const level: LevelItem[] = [
|
||||
{ start: 0, itemIndexes: [0], children: [], value: 100 },
|
||||
{ start: 100, itemIndexes: [1], children: [], value: 50 },
|
||||
{ start: 150, itemIndexes: [2], children: [], value: 50 },
|
||||
];
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100, 50, 50], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
|
||||
);
|
||||
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 10);
|
||||
expect(result).toEqual([
|
||||
{ width: 999, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
|
||||
{
|
||||
width: 499,
|
||||
height: 22,
|
||||
x: 1000,
|
||||
y: 44,
|
||||
collapsed: false,
|
||||
ticks: 50,
|
||||
label: '2',
|
||||
unitLabel: '50',
|
||||
itemIndex: 1,
|
||||
},
|
||||
{
|
||||
width: 499,
|
||||
height: 22,
|
||||
x: 1500,
|
||||
y: 44,
|
||||
collapsed: false,
|
||||
ticks: 50,
|
||||
label: '3',
|
||||
unitLabel: '50',
|
||||
itemIndex: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a collapsed items', () => {
|
||||
const level: LevelItem[] = [
|
||||
{ start: 0, itemIndexes: [0], children: [], value: 100 },
|
||||
{ start: 100, itemIndexes: [1], children: [], value: 2 },
|
||||
{ start: 102, itemIndexes: [2], children: [], value: 1 },
|
||||
];
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100, 2, 1], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
|
||||
);
|
||||
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 1);
|
||||
expect(result).toEqual([
|
||||
{ width: 99, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
|
||||
{ width: 3, height: 22, x: 100, y: 44, collapsed: true, ticks: 3, label: '2', unitLabel: '2', itemIndex: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,256 @@
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { RefObject, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { colors } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
BAR_BORDER_WIDTH,
|
||||
BAR_TEXT_PADDING_LEFT,
|
||||
COLLAPSE_THRESHOLD,
|
||||
HIDE_THRESHOLD,
|
||||
LABEL_THRESHOLD,
|
||||
PIXELS_PER_LEVEL,
|
||||
} from '../../constants';
|
||||
import { ClickedItemData, TextAlign } from '../types';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
const ufuzzy = new uFuzzy();
|
||||
|
||||
export function useFlameRender(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
data: FlameGraphDataContainer,
|
||||
levels: LevelItem[][],
|
||||
wrapperWidth: number,
|
||||
rangeMin: number,
|
||||
rangeMax: number,
|
||||
search: string,
|
||||
textAlign: TextAlign,
|
||||
totalTicks: number,
|
||||
focusedItemData?: ClickedItemData
|
||||
) {
|
||||
const foundLabels = useMemo(() => {
|
||||
if (search) {
|
||||
const foundLabels = new Set<string>();
|
||||
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]);
|
||||
|
||||
const ctx = useSetupCanvas(canvasRef, wrapperWidth, levels.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin);
|
||||
|
||||
for (let levelIndex = 0; levelIndex < levels.length; levelIndex++) {
|
||||
const level = levels[levelIndex];
|
||||
// Get all the dimensions of the rectangles for the level. We do this by level instead of per rectangle, because
|
||||
// sometimes we collapse multiple bars into single rect.
|
||||
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
|
||||
for (const rect of dimensions) {
|
||||
const focusedLevel = focusedItemData ? focusedItemData.level : 0;
|
||||
// Render each rectangle based on the computed dimensions
|
||||
renderRect(ctx, rect, totalTicks, rangeMin, rangeMax, levelIndex, focusedLevel, foundLabels, textAlign);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
ctx,
|
||||
data,
|
||||
levels,
|
||||
wrapperWidth,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
search,
|
||||
focusedItemData,
|
||||
foundLabels,
|
||||
textAlign,
|
||||
totalTicks,
|
||||
]);
|
||||
}
|
||||
|
||||
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
|
||||
const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type RectData = {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
ticks: number;
|
||||
label: string;
|
||||
unitLabel: string;
|
||||
itemIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the pixel coordinates for each bar in a level. We need full level of bars so that we can collapse small bars
|
||||
* into bigger rects.
|
||||
*/
|
||||
export function getRectDimensionsForLevel(
|
||||
data: FlameGraphDataContainer,
|
||||
level: LevelItem[],
|
||||
levelIndex: number,
|
||||
totalTicks: number,
|
||||
rangeMin: number,
|
||||
pixelsPerTick: number
|
||||
): RectData[] {
|
||||
const coordinatesLevel = [];
|
||||
for (let barIndex = 0; barIndex < level.length; barIndex += 1) {
|
||||
const item = level[barIndex];
|
||||
const barX = getBarX(item.start, totalTicks, rangeMin, pixelsPerTick);
|
||||
let curBarTicks = item.value;
|
||||
|
||||
// merge very small blocks into big "collapsed" ones for performance
|
||||
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
|
||||
if (collapsed) {
|
||||
while (
|
||||
barIndex < level.length - 1 &&
|
||||
item.start + curBarTicks === level[barIndex + 1].start &&
|
||||
level[barIndex + 1].value * pixelsPerTick <= COLLAPSE_THRESHOLD
|
||||
) {
|
||||
barIndex += 1;
|
||||
curBarTicks += level[barIndex].value;
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = data.valueDisplayProcessor(item.value);
|
||||
let unit = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
|
||||
|
||||
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
|
||||
coordinatesLevel.push({
|
||||
width,
|
||||
height: PIXELS_PER_LEVEL,
|
||||
x: barX,
|
||||
y: levelIndex * PIXELS_PER_LEVEL,
|
||||
collapsed,
|
||||
ticks: curBarTicks,
|
||||
label: data.getLabel(item.itemIndexes[0]),
|
||||
unitLabel: unit,
|
||||
itemIndex: item.itemIndexes[0],
|
||||
});
|
||||
}
|
||||
return coordinatesLevel;
|
||||
}
|
||||
|
||||
export function renderRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rect: RectData,
|
||||
totalTicks: number,
|
||||
rangeMin: number,
|
||||
rangeMax: number,
|
||||
levelIndex: number,
|
||||
topLevelIndex: number,
|
||||
foundNames: Set<string> | undefined,
|
||||
textAlign: TextAlign
|
||||
) {
|
||||
if (rect.width < HIDE_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(rect.x + (rect.collapsed ? 0 : BAR_BORDER_WIDTH), rect.y, rect.width, rect.height);
|
||||
|
||||
// / (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, rect.ticks / totalTicks / (rangeMax - rangeMin));
|
||||
const h = 50 - 50 * intensity;
|
||||
const l = 65 + 7 * intensity;
|
||||
|
||||
const name = rect.label;
|
||||
|
||||
if (!rect.collapsed) {
|
||||
ctx.stroke();
|
||||
|
||||
if (foundNames) {
|
||||
ctx.fillStyle = foundNames.has(name) ? getBarColor(h, l) : colors[55];
|
||||
} else {
|
||||
ctx.fillStyle = levelIndex > topLevelIndex - 1 ? getBarColor(h, l) : getBarColor(h, l + 15);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = foundNames && foundNames.has(name) ? getBarColor(h, l) : colors[55];
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
if (!rect.collapsed && rect.width >= LABEL_THRESHOLD) {
|
||||
renderLabel(ctx, name, rect, textAlign);
|
||||
}
|
||||
}
|
||||
|
||||
// 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, name: string, rect: RectData, textAlign: TextAlign) {
|
||||
ctx.save();
|
||||
ctx.clip(); // so text does not overflow
|
||||
ctx.fillStyle = '#222';
|
||||
|
||||
// We only measure name here instead of full label because of how we deal with the units and aligning later.
|
||||
const measure = ctx.measureText(name);
|
||||
const spaceForTextInRect = rect.width - BAR_TEXT_PADDING_LEFT;
|
||||
|
||||
let label = `${name} (${rect.unitLabel})`;
|
||||
let labelX = Math.max(rect.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') {
|
||||
label = name;
|
||||
labelX = rect.x + rect.width - BAR_TEXT_PADDING_LEFT;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(label, labelX, rect.y + PIXELS_PER_LEVEL / 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;
|
||||
}
|
||||
|
||||
function getBarColor(h: number, l: number) {
|
||||
return `hsl(${h}, 100%, ${l}%)`;
|
||||
}
|
@ -0,0 +1,572 @@
|
||||
export const data = {
|
||||
version: 1,
|
||||
flamebearer: {
|
||||
names: [
|
||||
'total',
|
||||
'runtime.mcall',
|
||||
'runtime.park_m',
|
||||
'runtime.schedule',
|
||||
'runtime.resetspinning',
|
||||
'runtime.wakep',
|
||||
'runtime.startm',
|
||||
'runtime.newm',
|
||||
'runtime.allocm',
|
||||
'github.com/bufbuild/connect-go.(*duplexHTTPCall).makeRequest',
|
||||
'net/http.(*Client).Do',
|
||||
'net/http.(*Client).do',
|
||||
'net/http.(*Client).send',
|
||||
'net/http.send',
|
||||
'test/pkg/util.RoundTripperFunc.RoundTrip',
|
||||
'test/pkg/util.WrapWithInstrumentedHTTPTransport.func1',
|
||||
'github.com/opentracing-contrib/go-stdlib/nethttp.(*Transport).RoundTrip',
|
||||
'github.com/opentracing-contrib/go-stdlib/nethttp.(*Tracer).start',
|
||||
'github.com/uber/jaeger-client-go.(*Tracer).StartSpan',
|
||||
'github.com/uber/jaeger-client-go.(*Tracer).startSpanWithOptions',
|
||||
'github.com/uber/jaeger-client-go.(*Tracer).randomID',
|
||||
'github.com/uber/jaeger-client-go.NewTracer.func2',
|
||||
'sync.(*Pool).Get',
|
||||
'sync.(*Pool).pin',
|
||||
'sync.(*Pool).pinSlow',
|
||||
'runtime.mstart',
|
||||
'runtime.mstart0',
|
||||
'runtime.mstart1',
|
||||
'golang.org/x/net/http2.(*serverConn).writeFrameAsync',
|
||||
'golang.org/x/net/http2.(*writeResHeaders).writeFrame',
|
||||
'golang.org/x/net/http2.splitHeaderBlock',
|
||||
'golang.org/x/net/http2.(*writeResHeaders).writeHeaderBlock',
|
||||
'golang.org/x/net/http2.(*Framer).WriteHeaders',
|
||||
'golang.org/x/net/http2.(*Framer).endWrite',
|
||||
'golang.org/x/net/http2.(*bufferedWriter).Write',
|
||||
'golang.org/x/net/http2.glob..func8',
|
||||
'bufio.NewWriterSize',
|
||||
'regexp/syntax.(*compiler).compile',
|
||||
'regexp/syntax.(*compiler).rune',
|
||||
'regexp/syntax.(*compiler).inst',
|
||||
'runtime.systemstack',
|
||||
'runtime.newproc.func1',
|
||||
'runtime.newproc1',
|
||||
'runtime.malg',
|
||||
'google.golang.org/grpc/internal/transport.newHTTP2Client.func3',
|
||||
'google.golang.org/grpc/internal/transport.(*loopyWriter).run',
|
||||
'google.golang.org/grpc/internal/transport.(*loopyWriter).handle',
|
||||
'google.golang.org/grpc/internal/transport.(*loopyWriter).headerHandler',
|
||||
'google.golang.org/grpc/internal/transport.(*loopyWriter).originateStream',
|
||||
'google.golang.org/grpc/internal/transport.(*loopyWriter).writeHeader',
|
||||
'golang.org/x/net/http2/hpack.(*Encoder).WriteField',
|
||||
'golang.org/x/net/http2/hpack.(*dynamicTable).add',
|
||||
'golang.org/x/net/http2/hpack.(*headerFieldTable).addEntry',
|
||||
'net/http.(*persistConn).readLoop',
|
||||
'net/http.(*persistConn).readResponse',
|
||||
'net/http.ReadResponse',
|
||||
'net/http.serverHandler.ServeHTTP',
|
||||
'net/http.HandlerFunc.ServeHTTP',
|
||||
'test/pkg/util.glob..func1.1',
|
||||
'golang.org/x/net/http2/h2c.h2cHandler.ServeHTTP',
|
||||
'test/pkg/create.(*create).initServer.func2.1',
|
||||
'github.com/opentracing-contrib/go-stdlib/nethttp.MiddlewareFunc.func5',
|
||||
'github.com/weaveworks/common/middleware.Log.Wrap.func1',
|
||||
'github.com/weaveworks/common/middleware.Instrument.Wrap.func1',
|
||||
'github.com/felixge/httpsnoop.CaptureMetricsFn',
|
||||
'github.com/felixge/httpsnoop.(*Metrics).CaptureMetrics',
|
||||
'github.com/weaveworks/common/middleware.Instrument.Wrap.func1.2',
|
||||
'github.com/gorilla/mux.(*Router).ServeHTTP',
|
||||
'net/http.(*ServeMux).ServeHTTP',
|
||||
'net/http/pprof.Index',
|
||||
'net/http/pprof.handler.ServeHTTP',
|
||||
'runtime/pprof.(*Profile).WriteTo',
|
||||
'runtime/pprof.writeGoroutine',
|
||||
'runtime/pprof.writeRuntimeProfile',
|
||||
'runtime/pprof.printCountProfile',
|
||||
'runtime/pprof.(*profileBuilder).appendLocsForStack',
|
||||
'runtime/pprof.(*profileBuilder).emitLocation',
|
||||
'runtime/pprof.(*profileBuilder).flush',
|
||||
'compress/gzip.(*Writer).Write',
|
||||
'compress/flate.NewWriter',
|
||||
'compress/flate.(*compressor).init',
|
||||
'compress/flate.newHuffmanBitWriter',
|
||||
'compress/flate.newHuffmanEncoder',
|
||||
'test/pkg/create.(*create).Run.func3',
|
||||
'github.com/weaveworks/common/signals.(*Handler).Loop',
|
||||
'runtime.gcBgMarkWorker',
|
||||
'runtime.gcMarkDone',
|
||||
'runtime.semacquire',
|
||||
'runtime.semacquire1',
|
||||
'runtime.acquireSudog',
|
||||
'test/dskit/services.(*BasicService).main',
|
||||
'test/dskit/ring.(*Ring).loop',
|
||||
'test/dskit/kv.metrics.WatchKey',
|
||||
'github.com/weaveworks/common/instrument.CollectedRequest',
|
||||
'test/dskit/kv.metrics.WatchKey.func1',
|
||||
'test/dskit/kv.(*prefixedKVClient).WatchKey',
|
||||
'test/dskit/kv/memberlist.(*Client).WatchKey',
|
||||
'test/dskit/kv/memberlist.(*KV).WatchKey',
|
||||
'test/dskit/kv/memberlist.(*KV).get',
|
||||
'test/dskit/kv/memberlist.ValueDesc.Clone',
|
||||
'test/dskit/ring.(*Desc).Clone',
|
||||
'github.com/gogo/protobuf/proto.Clone',
|
||||
'github.com/gogo/protobuf/proto.Merge',
|
||||
'test/dskit/ring.(*Desc).XXX_Merge',
|
||||
'github.com/gogo/protobuf/proto.(*InternalMessageInfo).Merge',
|
||||
'github.com/gogo/protobuf/proto.(*mergeInfo).merge',
|
||||
'github.com/gogo/protobuf/proto.(*mergeInfo).computeMergeInfo.func31',
|
||||
'reflect.Value.SetMapIndex',
|
||||
'reflect.mapassign_faststr',
|
||||
'runtime/pprof.profileWriter',
|
||||
'runtime/pprof.(*profileBuilder).addCPUData',
|
||||
'runtime/pprof.(*profMap).lookup',
|
||||
'runtime/pprof.newProfileBuilder',
|
||||
'compress/gzip.NewWriterLevel',
|
||||
'runtime/pprof.(*profileBuilder).build',
|
||||
'compress/flate.newDeflateFast',
|
||||
'github.com/hashicorp/memberlist.(*Memberlist).triggerFunc',
|
||||
'github.com/hashicorp/memberlist.(*Memberlist).gossip',
|
||||
'github.com/armon/go-metrics.MeasureSince',
|
||||
'github.com/armon/go-metrics.(*Metrics).MeasureSince',
|
||||
'github.com/armon/go-metrics.(*Metrics).MeasureSinceWithLabels',
|
||||
'github.com/armon/go-metrics/prometheus.(*PrometheusSink).AddSampleWithLabels',
|
||||
'github.com/armon/go-metrics/prometheus.flattenKey',
|
||||
'regexp.(*Regexp).ReplaceAllString',
|
||||
'regexp.(*Regexp).replaceAll',
|
||||
'regexp.(*Regexp).doExecute',
|
||||
'regexp.(*Regexp).backtrack',
|
||||
'regexp.(*bitState).reset',
|
||||
'runtime.main',
|
||||
'main.main',
|
||||
'test/pkg/create.New',
|
||||
'github.com/prometheus/common/config.NewClientFromConfig',
|
||||
'github.com/prometheus/common/config.NewRoundTripperFromConfig',
|
||||
'github.com/mwitkow/go-conntrack.NewDialContextFunc',
|
||||
'github.com/mwitkow/go-conntrack.PreRegisterDialerMetrics',
|
||||
'github.com/prometheus/client_golang/prometheus.(*CounterVec).WithLabelValues',
|
||||
'github.com/prometheus/client_golang/prometheus.(*CounterVec).GetMetricWithLabelValues',
|
||||
'github.com/prometheus/client_golang/prometheus.(*MetricVec).GetMetricWithLabelValues',
|
||||
'github.com/prometheus/client_golang/prometheus.(*metricMap).getOrCreateMetricWithLabelValues',
|
||||
'test/pkg/create.(*create).Run',
|
||||
'test/dskit/modules.(*Manager).InitModuleServices',
|
||||
'test/dskit/modules.(*Manager).initModule',
|
||||
'test/pkg/create.(*create).initcreate',
|
||||
'test/pkg/create.New',
|
||||
'test/pkg/create.(*create).initHead',
|
||||
'test/pkg/create.NewHead',
|
||||
'test/pkg/create.(*deduplicatingSlice[...]).Init',
|
||||
'github.com/segmentio/parquet-go.NewWriter',
|
||||
'github.com/segmentio/parquet-go.(*Writer).configure',
|
||||
'github.com/segmentio/parquet-go.newWriter',
|
||||
'runtime.doInit',
|
||||
'test/dskit/ring.init',
|
||||
'html/template.(*Template).Parse',
|
||||
'text/template.(*Template).Parse',
|
||||
'text/template/parse.Parse',
|
||||
'text/template/parse.(*Tree).Parse',
|
||||
'text/template/parse.(*Tree).parse',
|
||||
'text/template/parse.(*Tree).textOrAction',
|
||||
'text/template/parse.(*Tree).action',
|
||||
'text/template/parse.(*Tree).rangeControl',
|
||||
'text/template/parse.(*Tree).parseControl',
|
||||
'text/template/parse.(*Tree).itemList',
|
||||
'text/template/parse.(*Tree).pipeline',
|
||||
'text/template/parse.(*PipeNode).append',
|
||||
'text/template/parse.(*Tree).newPipeline',
|
||||
'google.golang.org/protobuf/types/known/structpb.init',
|
||||
'github.com/prometheus/prometheus/scrape.init',
|
||||
'fmt.Errorf',
|
||||
'github.com/prometheus/prometheus/discovery/consul.init',
|
||||
'github.com/prometheus/client_golang/prometheus.(*SummaryVec).WithLabelValues',
|
||||
'github.com/prometheus/client_golang/prometheus.(*SummaryVec).GetMetricWithLabelValues',
|
||||
'github.com/prometheus/client_golang/prometheus.NewSummaryVec.func1',
|
||||
'github.com/prometheus/client_golang/prometheus.newSummary',
|
||||
'github.com/prometheus/client_golang/prometheus.(*summary).newStream',
|
||||
'github.com/beorn7/perks/quantile.NewTargeted',
|
||||
'github.com/beorn7/perks/quantile.newStream',
|
||||
'encoding/gob.init',
|
||||
'encoding/gob.mustGetTypeInfo',
|
||||
'encoding/gob.getTypeInfo',
|
||||
'encoding/gob.buildTypeInfo',
|
||||
'encoding/gob.getBaseType',
|
||||
'encoding/gob.getType',
|
||||
'encoding/gob.newTypeObject',
|
||||
'encoding/gob.userType',
|
||||
'encoding/gob.validUserType',
|
||||
'sync.(*Map).LoadOrStore',
|
||||
'sync.(*Map).dirtyLocked',
|
||||
'go.opentelemetry.io/otel/trace.init',
|
||||
'regexp.MustCompile',
|
||||
'regexp.Compile',
|
||||
'regexp.compile',
|
||||
'regexp.compileOnePass',
|
||||
'regexp.onePassCopy',
|
||||
'cloud.google.com/go/storage.init',
|
||||
'regexp/syntax.Compile',
|
||||
'github.com/aws/aws-sdk-go/aws/endpoints.init',
|
||||
'github.com/asaskevich/govalidator.init',
|
||||
'regexp/syntax.(*Regexp).CapNames',
|
||||
'github.com/goccy/go-json/internal/decoder.init.0',
|
||||
'k8s.io/api/flowcontrol/v1beta2.init',
|
||||
'k8s.io/kube-openapi/pkg/handler3.init.0',
|
||||
'mime.AddExtensionType',
|
||||
'sync.(*Once).Do',
|
||||
'sync.(*Once).doSlow',
|
||||
'mime.initMime',
|
||||
'mime.initMimeUnix',
|
||||
'mime.loadMimeFile',
|
||||
'mime.setExtensionType',
|
||||
'sync.(*Map).Store',
|
||||
'google.golang.org/genproto/googleapis/rpc/errdetails.init.0',
|
||||
'google.golang.org/genproto/googleapis/rpc/errdetails.file_google_rpc_error_details_proto_init',
|
||||
'google.golang.org/protobuf/internal/filetype.Builder.Build',
|
||||
'google.golang.org/protobuf/internal/filedesc.Builder.Build',
|
||||
'google.golang.org/protobuf/internal/filedesc.newRawFile',
|
||||
'google.golang.org/protobuf/internal/filedesc.(*File).unmarshalSeed',
|
||||
'google.golang.org/protobuf/internal/filedesc.(*Message).unmarshalSeed',
|
||||
'google.golang.org/protobuf/internal/filedesc.appendFullName',
|
||||
'google.golang.org/protobuf/internal/strs.(*Builder).AppendFullName',
|
||||
'google.golang.org/genproto/googleapis/type/color.init.0',
|
||||
'google.golang.org/genproto/googleapis/type/color.file_google_type_color_proto_init',
|
||||
'google.golang.org/protobuf/reflect/protoregistry.(*Files).RegisterFile',
|
||||
'google.golang.org/protobuf/reflect/protoregistry.rangeTopLevelDescriptors',
|
||||
'google.golang.org/protobuf/reflect/protoregistry.(*Files).RegisterFile.func2',
|
||||
'github.com/goccy/go-json/internal/encoder.init.0',
|
||||
'google.golang.org/protobuf/types/descriptorpb.init.0',
|
||||
'google.golang.org/protobuf/types/descriptorpb.file_google_protobuf_descriptor_proto_init',
|
||||
'google.golang.org/protobuf/internal/filedesc.(*File).initDecls',
|
||||
'golang.org/x/net/http2.(*serverConn).runHandler',
|
||||
'github.com/weaveworks/common/middleware.Tracer.Wrap.func1',
|
||||
'github.com/weaveworks/common/middleware.getRouteName',
|
||||
'github.com/gorilla/mux.(*Router).Match',
|
||||
'github.com/gorilla/mux.(*Route).Match',
|
||||
'github.com/gorilla/mux.(*routeRegexp).Match',
|
||||
'regexp.(*Regexp).MatchString',
|
||||
'regexp.(*Regexp).doMatch',
|
||||
'test/pkg/agent.(*Target).start.func1',
|
||||
'test/pkg/agent.(*Target).scrape',
|
||||
'github.com/prometheus/prometheus/util/pool.(*Pool).Get',
|
||||
'test/pkg/agent.glob..func1',
|
||||
'test/pkg/agent.(*Target).fetchProfile',
|
||||
'io/ioutil.ReadAll',
|
||||
'io.ReadAll',
|
||||
'test/pkg/distributor.(*Distributor).Push',
|
||||
'compress/flate.(*compressor).initDeflate',
|
||||
'compress/gzip.(*Reader).Read',
|
||||
'compress/flate.(*decompressor).Read',
|
||||
'compress/flate.(*decompressor).nextBlock',
|
||||
'compress/flate.(*decompressor).readHuffman',
|
||||
'compress/flate.(*huffmanDecoder).init',
|
||||
'compress/gzip.NewReader',
|
||||
'compress/gzip.(*Reader).Reset',
|
||||
'compress/gzip.(*Reader).readHeader',
|
||||
'compress/flate.NewReader',
|
||||
'compress/flate.(*dictDecoder).init',
|
||||
'test/pkg/gen/google/v1.(*Profile).UnmarshalVT',
|
||||
'test/pkg/gen/google/v1.(*Location).UnmarshalVT',
|
||||
'test/pkg/gen/google/v1.(*Sample).UnmarshalVT',
|
||||
'test/pkg/distributor.sanitizeProfile',
|
||||
'github.com/samber/lo.Reject[...]',
|
||||
'net/http.(*conn).serve',
|
||||
'net/http.(*response).finishRequest',
|
||||
'net/http.putBufioWriter',
|
||||
'sync.(*Pool).Put',
|
||||
'net/http.(*conn).readRequest',
|
||||
'net/http.newBufioWriterSize',
|
||||
'net/http.readRequest',
|
||||
'net/textproto.(*Reader).ReadMIMEHeader',
|
||||
'net/http.newTextprotoReader',
|
||||
'github.com/uber/jaeger-client-go.NewTracer.func1',
|
||||
'math/rand.NewSource',
|
||||
'fmt.Sprintf',
|
||||
'fmt.newPrinter',
|
||||
'fmt.glob..func1',
|
||||
'regexp.newBitState',
|
||||
'github.com/felixge/httpsnoop.Wrap',
|
||||
'github.com/bufbuild/connect-go.(*Handler).ServeHTTP',
|
||||
'net/http.(*Request).WithContext',
|
||||
'github.com/bufbuild/connect-go.NewUnaryHandler[...].func1',
|
||||
'github.com/bufbuild/connect-go.(*errorTranslatingSender).Send',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryHandlerSender).Send',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryMarshaler).Marshal',
|
||||
'github.com/bufbuild/connect-go.(*bufferPool).Put',
|
||||
'sync.(*poolChain).pushHead',
|
||||
'github.com/bufbuild/connect-go.(*compressionPool).Compress',
|
||||
'github.com/bufbuild/connect-go.(*compressionPool).putCompressor',
|
||||
'compress/gzip.(*Writer).Close',
|
||||
'io.Copy',
|
||||
'io.copyBuffer',
|
||||
'bytes.(*Buffer).WriteTo',
|
||||
'github.com/bufbuild/connect-go.(*protoBinaryCodec).Marshal',
|
||||
'google.golang.org/protobuf/proto.Marshal',
|
||||
'google.golang.org/protobuf/proto.MarshalOptions.marshal',
|
||||
'github.com/bufbuild/connect-go.NewUnaryHandler[...].func1.1',
|
||||
'test/pkg/ingester.(*Ingester).Push',
|
||||
'github.com/klauspost/compress/gzip.NewReader',
|
||||
'github.com/klauspost/compress/gzip.(*Reader).Reset',
|
||||
'github.com/klauspost/compress/gzip.(*Reader).readHeader',
|
||||
'github.com/klauspost/compress/flate.NewReader',
|
||||
'github.com/klauspost/compress/flate.(*dictDecoder).init',
|
||||
'test/pkg/create.(*Head).Ingest',
|
||||
'test/pkg/create.(*deduplicatingSlice[...]).ingest',
|
||||
'test/pkg/model.(*LabelsBuilder).Set',
|
||||
'test/pkg/create.(*Head).convertSamples',
|
||||
'github.com/bufbuild/connect-go.receiveUnaryRequest[...]',
|
||||
'github.com/bufbuild/connect-go.(*errorTranslatingReceiver).Receive',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryHandlerReceiver).Receive',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryUnmarshaler).Unmarshal',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryUnmarshaler).UnmarshalFunc',
|
||||
'bytes.(*Buffer).ReadFrom',
|
||||
'bytes.(*Buffer).grow',
|
||||
'bytes.makeSlice',
|
||||
'github.com/bufbuild/connect-go.(*bufferPool).Get',
|
||||
'net/http/pprof.Profile',
|
||||
'runtime/pprof.StartCPUProfile',
|
||||
'runtime/pprof.writeMutex',
|
||||
'runtime/pprof.writeProfileInternal',
|
||||
'runtime/pprof.printCountCycleProfile',
|
||||
'runtime/pprof.writeBlock',
|
||||
'runtime/pprof.writeAlloc',
|
||||
'runtime/pprof.writeHeapInternal',
|
||||
'runtime/pprof.writeHeapProto',
|
||||
'runtime/pprof.(*protobuf).strings',
|
||||
'runtime/pprof.(*protobuf).string',
|
||||
'runtime/pprof.(*profileBuilder).stringIndex',
|
||||
'runtime/pprof.(*protobuf).uint64Opt',
|
||||
'runtime/pprof.(*protobuf).uint64',
|
||||
'runtime/pprof.(*protobuf).varint',
|
||||
'runtime/pprof.allFrames',
|
||||
'runtime/pprof.(*profileBuilder).pbSample',
|
||||
'runtime/pprof.printCountProfile.func1',
|
||||
'bytes.(*Buffer).String',
|
||||
'net/http.(*persistConn).writeLoop',
|
||||
'net/http.(*Request).write',
|
||||
'net/http.(*transferWriter).writeBody',
|
||||
'net/http.(*transferWriter).doBodyCopy',
|
||||
'test/pkg/distributor.(*Distributor).Push.func1',
|
||||
'test/pkg/distributor.(*Distributor).sendProfiles',
|
||||
'test/pkg/distributor.(*Distributor).sendProfilesErr',
|
||||
'test/pkg/gen/ingester/v1/ingesterv1connect.(*ingesterServiceClient).Push',
|
||||
'github.com/bufbuild/connect-go.(*Client[...]).CallUnary',
|
||||
'github.com/bufbuild/connect-go.NewClient[...].func2',
|
||||
'github.com/bufbuild/connect-go.NewClient[...].func1',
|
||||
'github.com/bufbuild/connect-go.(*connectClientSender).Send',
|
||||
'github.com/bufbuild/connect-go.(*errorTranslatingReceiver).Close',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryClientReceiver).Close',
|
||||
'github.com/bufbuild/connect-go.(*duplexHTTPCall).CloseRead',
|
||||
'github.com/bufbuild/connect-go.discard',
|
||||
'io.discard.ReadFrom',
|
||||
'io.glob..func1',
|
||||
'github.com/bufbuild/connect-go.receiveUnaryResponse[...]',
|
||||
'github.com/bufbuild/connect-go.(*connectUnaryClientReceiver).Receive',
|
||||
'github.com/bufbuild/connect-go.(*compressionPool).Decompress',
|
||||
'github.com/bufbuild/connect-go.(*compressionPool).getDecompressor',
|
||||
],
|
||||
levels: [
|
||||
[0, 8624078250, 0, 0],
|
||||
[
|
||||
0, 60011939, 0, 335, 0, 1081684, 0, 331, 0, 2765065247, 0, 259, 0, 144858662, 0, 235, 0, 1081684, 0, 227, 0,
|
||||
4523250662, 0, 128, 0, 9691644, 0, 116, 0, 8663322, 0, 109, 0, 1574208, 0, 90, 0, 132657008, 0, 85, 0,
|
||||
304386696, 0, 83, 0, 1049728, 0, 56, 0, 524360, 0, 53, 0, 2624640, 0, 44, 0, 132697488, 0, 40, 0, 545034, 0, 37,
|
||||
0, 1052676, 0, 28, 0, 398371776, 0, 25, 0, 2099200, 0, 9, 0, 132790592, 0, 1,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 336, 0, 1081684, 0, 332, 0, 2756669265, 0, 56, 0, 6821582, 0, 263, 0, 1574400, 0, 260, 0,
|
||||
144858662, 0, 236, 0, 1081684, 0, 57, 0, 4255866888, 0, 150, 0, 267383774, 0, 129, 0, 9691644, 0, 117, 0,
|
||||
4444206, 0, 114, 0, 1048752, 0, 112, 0, 3170364, 0, 110, 0, 1574208, 0, 91, 0, 132657008, 0, 86, 0, 304386696,
|
||||
304386696, 84, 0, 1049728, 0, 57, 0, 524360, 0, 54, 0, 2624640, 0, 45, 0, 132697488, 0, 41, 0, 545034, 0, 37, 0,
|
||||
1052676, 0, 29, 0, 398371776, 0, 26, 0, 2099200, 0, 10, 0, 132790592, 0, 2,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 337, 0, 1081684, 0, 333, 0, 2756669265, 0, 57, 0, 6296270, 0, 265, 0, 525312, 0, 264, 0,
|
||||
1574400, 0, 261, 0, 135394175, 0, 242, 0, 526980, 0, 239, 0, 8937507, 0, 237, 0, 1081684, 0, 60, 0, 4255866888,
|
||||
0, 150, 0, 135751342, 0, 139, 0, 131632432, 0, 130, 0, 9691644, 0, 118, 0, 4444206, 0, 78, 0, 1048752, 1048752,
|
||||
113, 0, 3170364, 3170364, 111, 0, 1574208, 0, 92, 0, 132657008, 0, 87, 304386696, 1049728, 0, 58, 0, 524360,
|
||||
524360, 55, 0, 2624640, 0, 46, 0, 132697488, 0, 42, 0, 545034, 0, 37, 0, 1052676, 0, 30, 0, 398371776, 0, 27, 0,
|
||||
2099200, 0, 11, 0, 132790592, 0, 3,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 338, 0, 1081684, 0, 334, 0, 2756669265, 0, 58, 0, 1049600, 0, 267, 0, 5246670, 5246670, 266, 0,
|
||||
525312, 525312, 36, 0, 1574400, 0, 262, 0, 4248808, 0, 257, 0, 35145141, 29377349, 254, 0, 5380182, 0, 249, 0,
|
||||
5283874, 0, 240, 0, 85336170, 0, 78, 0, 526980, 0, 240, 0, 8937507, 8937507, 238, 0, 1081684, 0, 57, 0,
|
||||
4255866888, 0, 150, 0, 135751342, 0, 140, 0, 131632432, 0, 131, 0, 9691644, 0, 119, 0, 4444206, 1848496, 79,
|
||||
4219116, 1574208, 0, 93, 0, 132657008, 0, 88, 304386696, 1049728, 0, 59, 524360, 2624640, 0, 47, 0, 132697488,
|
||||
132697488, 43, 0, 545034, 0, 37, 0, 1052676, 0, 31, 0, 398371776, 0, 3, 0, 2099200, 0, 12, 0, 132790592, 0, 4,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 339, 0, 1081684, 0, 286, 0, 2756669265, 0, 59, 0, 1049600, 0, 22, 5771982, 1574400, 0, 23, 0,
|
||||
4248808, 4248808, 258, 29377349, 4194832, 4194832, 256, 0, 1572960, 1572960, 255, 0, 5380182, 0, 250, 0,
|
||||
5283874, 2137058, 241, 0, 85336170, 67470104, 79, 0, 526980, 526980, 241, 8937507, 1081684, 0, 61, 0,
|
||||
3990564004, 0, 150, 0, 265302884, 0, 151, 0, 135751342, 0, 141, 0, 131632432, 0, 132, 0, 9691644, 0, 120,
|
||||
1848496, 2595710, 1998711, 80, 4219116, 1574208, 0, 94, 0, 132657008, 132657008, 89, 304386696, 1049728, 0, 57,
|
||||
524360, 2624640, 0, 48, 132697488, 545034, 0, 37, 0, 1052676, 0, 32, 0, 398371776, 0, 4, 0, 2099200, 0, 13, 0,
|
||||
132790592, 0, 5,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 340, 0, 1081684, 1081684, 287, 0, 2756669265, 0, 57, 0, 1049600, 0, 23, 5771982, 1574400,
|
||||
1574400, 24, 39393949, 5380182, 0, 251, 2137058, 3146816, 0, 244, 67470104, 17866066, 0, 80, 9464487, 1081684,
|
||||
0, 228, 0, 3725614404, 0, 150, 0, 132126624, 0, 166, 0, 132822976, 132822976, 165, 0, 265302884, 0, 152, 0,
|
||||
135751342, 0, 142, 0, 131632432, 0, 133, 0, 9691644, 0, 121, 3847207, 596999, 596999, 115, 4219116, 1574208, 0,
|
||||
95, 437043704, 1049728, 0, 60, 524360, 2624640, 0, 49, 132697488, 545034, 0, 37, 0, 1052676, 0, 33, 0,
|
||||
398371776, 0, 5, 0, 2099200, 0, 14, 0, 132790592, 0, 6,
|
||||
],
|
||||
[
|
||||
0, 60011939, 0, 341, 1081684, 2756669265, 0, 60, 0, 1049600, 1049600, 24, 46740331, 5380182, 1053446, 252,
|
||||
2137058, 3146816, 0, 245, 67470104, 524864, 524864, 81, 0, 17341202, 17341202, 243, 9464487, 1081684, 0, 229, 0,
|
||||
3725614404, 0, 150, 0, 132126624, 132126624, 167, 132822976, 265302884, 0, 153, 0, 135751342, 0, 143, 0,
|
||||
131632432, 0, 134, 0, 9691644, 0, 122, 8663322, 1574208, 0, 96, 437043704, 1049728, 0, 57, 524360, 2624640, 0,
|
||||
50, 132697488, 545034, 0, 37, 0, 1052676, 0, 34, 0, 398371776, 0, 6, 0, 2099200, 0, 15, 0, 132790592, 0, 7,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 349, 0, 7925912, 0, 343, 0, 16570269, 0, 278, 1081684, 2756669265, 0, 57, 48843377, 4326736,
|
||||
4326736, 253, 2137058, 3146816, 0, 246, 94800657, 1081684, 0, 230, 0, 3457203921, 0, 150, 0, 268410483, 0, 168,
|
||||
264949600, 265302884, 0, 154, 0, 135751342, 0, 144, 0, 131632432, 0, 135, 0, 9691644, 0, 123, 8663322, 1574208,
|
||||
0, 97, 437043704, 1049728, 0, 61, 524360, 2624640, 0, 51, 132697488, 545034, 0, 37, 0, 1052676, 0, 22, 0,
|
||||
398371776, 0, 7, 0, 2099200, 0, 16, 0, 132790592, 132790592, 8,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 304, 0, 7925912, 0, 344, 0, 16570269, 0, 342, 1081684, 2756669265, 0, 61, 55307171, 3146816, 0,
|
||||
247, 94800657, 1081684, 0, 231, 0, 3324445713, 0, 150, 0, 132758208, 0, 176, 0, 268410483, 0, 169, 264949600,
|
||||
265302884, 0, 155, 0, 135751342, 0, 145, 0, 131632432, 0, 136, 0, 9691644, 0, 124, 8663322, 1574208, 0, 98,
|
||||
437043704, 1049728, 0, 57, 524360, 2624640, 2624640, 52, 132697488, 545034, 0, 37, 0, 1052676, 0, 35, 0,
|
||||
398371776, 398371776, 8, 0, 2099200, 0, 17,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 350, 0, 7925912, 0, 345, 0, 16570269, 0, 280, 1081684, 2740098251, 0, 57, 0, 13936114, 0, 228,
|
||||
0, 2634900, 0, 18, 55307171, 3146816, 3146816, 248, 94800657, 1081684, 0, 232, 0, 133423345, 0, 224, 0,
|
||||
2527422102, 0, 150, 0, 264582108, 0, 196, 0, 265453672, 265453672, 195, 0, 132985149, 0, 193, 0, 579337, 0, 187,
|
||||
0, 132758208, 0, 177, 0, 268410483, 0, 170, 264949600, 265302884, 0, 156, 0, 135751342, 0, 146, 0, 131632432, 0,
|
||||
137, 0, 9691644, 0, 125, 8663322, 1574208, 0, 99, 437043704, 1049728, 0, 62, 135846488, 545034, 0, 37, 0,
|
||||
1052676, 1052676, 36, 398371776, 2099200, 0, 18,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 306, 0, 7925912, 0, 346, 0, 16045469, 0, 289, 0, 524800, 0, 281, 1081684, 2740098251, 0, 62, 0,
|
||||
11838546, 0, 229, 0, 2097568, 0, 270, 0, 2634900, 0, 19, 153254644, 1081684, 0, 233, 0, 133423345, 0, 225, 0,
|
||||
663692876, 663692876, 223, 0, 550717750, 0, 150, 0, 1313011476, 1313011476, 198, 0, 264582108, 0, 188,
|
||||
265453672, 132985149, 0, 188, 0, 579337, 0, 188, 0, 132758208, 0, 178, 0, 268410483, 0, 137, 264949600,
|
||||
265302884, 0, 157, 0, 135751342, 0, 147, 0, 131632432, 131632432, 138, 0, 9691644, 0, 126, 8663322, 1574208, 0,
|
||||
100, 437043704, 1049728, 0, 57, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 19,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 307, 0, 7925912, 0, 286, 0, 16045469, 0, 290, 0, 524800, 0, 262, 1081684, 2740098251, 0, 57, 0,
|
||||
11838546, 0, 230, 0, 2097568, 0, 271, 0, 2634900, 0, 20, 153254644, 1081684, 0, 234, 0, 133423345, 0, 211,
|
||||
663692876, 143277442, 0, 218, 0, 134728066, 0, 150, 0, 140030946, 0, 200, 0, 132681296, 132681296, 199,
|
||||
1313011476, 264582108, 0, 189, 265453672, 132985149, 0, 189, 0, 579337, 0, 189, 0, 132758208, 0, 179, 0,
|
||||
268410483, 0, 138, 264949600, 265302884, 0, 158, 0, 135751342, 0, 148, 131632432, 9691644, 9691644, 127,
|
||||
8663322, 1574208, 0, 101, 437043704, 1049728, 0, 63, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 20,
|
||||
],
|
||||
[
|
||||
0, 35515758, 0, 351, 0, 7925912, 0, 287, 0, 16045469, 16045469, 291, 0, 524800, 0, 23, 1081684, 2740098251, 0,
|
||||
63, 0, 11838546, 0, 231, 0, 2097568, 0, 22, 0, 2634900, 0, 21, 153254644, 1081684, 0, 125, 0, 133423345, 0, 212,
|
||||
663692876, 143277442, 0, 219, 0, 134728066, 0, 209, 0, 140030946, 0, 201, 1445692772, 264582108, 0, 190,
|
||||
265453672, 132985149, 0, 190, 0, 579337, 0, 190, 0, 132758208, 0, 180, 0, 268410483, 0, 171, 264949600,
|
||||
265302884, 0, 159, 0, 135751342, 0, 149, 149987398, 1574208, 0, 102, 437043704, 1049728, 0, 64, 135846488,
|
||||
545034, 0, 37, 399424452, 2099200, 0, 21,
|
||||
],
|
||||
[
|
||||
0, 34466158, 0, 352, 0, 1049600, 0, 308, 0, 7925912, 0, 347, 16045469, 524800, 524800, 24, 1081684, 2740098251,
|
||||
0, 64, 0, 11838546, 0, 232, 0, 2097568, 2097568, 272, 0, 2634900, 0, 22, 153254644, 1081684, 0, 126, 0,
|
||||
133423345, 0, 213, 663692876, 143277442, 0, 211, 0, 134728066, 0, 210, 0, 140030946, 0, 202, 1445692772,
|
||||
132122592, 132122592, 197, 0, 132459516, 0, 194, 265453672, 132985149, 0, 194, 0, 579337, 0, 191, 0, 132758208,
|
||||
0, 181, 0, 268410483, 0, 172, 264949600, 265302884, 0, 160, 0, 135751342, 135751342, 36, 149987398, 1574208, 0,
|
||||
103, 437043704, 1049728, 0, 65, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 22,
|
||||
],
|
||||
[
|
||||
0, 34466158, 0, 250, 0, 1049600, 0, 309, 0, 7925912, 0, 22, 17651953, 2740098251, 0, 65, 0, 11838546, 0, 233,
|
||||
2097568, 2634900, 0, 268, 153254644, 1081684, 1081684, 127, 0, 133423345, 133423345, 226, 663692876, 143277442,
|
||||
0, 212, 0, 134728066, 0, 211, 0, 140030946, 0, 203, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 37,
|
||||
0, 579337, 579337, 192, 0, 132758208, 0, 182, 0, 268410483, 0, 173, 264949600, 265302884, 0, 161, 285738740,
|
||||
1574208, 0, 104, 437043704, 1049728, 0, 66, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 23,
|
||||
],
|
||||
[
|
||||
0, 34466158, 0, 251, 0, 1049600, 1049600, 310, 0, 7925912, 7925912, 348, 17651953, 2739573923, 0, 66, 0, 524328,
|
||||
524328, 274, 0, 11838546, 0, 234, 2097568, 2634900, 2634900, 269, 951452549, 143277442, 0, 220, 0, 134728066, 0,
|
||||
212, 0, 140030946, 0, 204, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 37, 579337, 132758208, 0, 183,
|
||||
0, 268410483, 0, 174, 264949600, 265302884, 0, 157, 285738740, 1574208, 0, 105, 437043704, 1049728, 0, 67,
|
||||
135846488, 545034, 0, 37, 399424452, 2099200, 2099200, 24,
|
||||
],
|
||||
[
|
||||
0, 34466158, 5260690, 252, 26627465, 2739573923, 0, 67, 524328, 11838546, 0, 125, 956185017, 143277442, 0, 221,
|
||||
0, 134728066, 0, 213, 0, 140030946, 0, 205, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 38, 579337,
|
||||
132758208, 0, 184, 0, 268410483, 268410483, 175, 264949600, 265302884, 0, 158, 285738740, 1574208, 0, 106,
|
||||
437043704, 1049728, 0, 68, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
5260690, 29205468, 29205468, 253, 26627465, 409934141, 0, 68, 0, 2329639782, 0, 275, 524328, 11838546, 0, 126,
|
||||
956185017, 143277442, 143277442, 222, 0, 134728066, 0, 214, 0, 140030946, 0, 206, 1577815364, 132459516, 0, 38,
|
||||
265453672, 132985149, 132985149, 39, 579337, 132758208, 0, 185, 533360083, 265302884, 0, 162, 285738740,
|
||||
1574208, 0, 107, 437043704, 1049728, 0, 57, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 409934141, 0, 57, 0, 2329115366, 0, 277, 0, 524416, 524416, 276, 524328, 524376, 524376, 273, 0,
|
||||
11314170, 11314170, 127, 1099462459, 134728066, 0, 215, 0, 140030946, 0, 207, 1577815364, 132459516, 132459516,
|
||||
39, 399018158, 132758208, 132758208, 186, 533360083, 132657008, 132657008, 164, 0, 132645876, 132645876, 163,
|
||||
285738740, 1574208, 1574208, 108, 437043704, 1049728, 0, 69, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 178075376, 0, 69, 0, 231858765, 0, 312, 0, 14574244, 0, 303, 0, 1624251841, 0, 292, 0, 690289281, 0,
|
||||
278, 1112349749, 134728066, 0, 216, 0, 140030946, 140030946, 208, 3765070865, 1049728, 0, 70, 135846488, 545034,
|
||||
0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 178075376, 0, 70, 0, 231858765, 231858765, 313, 0, 14574244, 0, 304, 0, 1624251841, 0, 293, 0,
|
||||
690289281, 0, 279, 1112349749, 134728066, 134728066, 217, 3905101811, 1049728, 0, 71, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 178075376, 0, 71, 231858765, 14574244, 0, 305, 0, 1595244443, 0, 299, 0, 3274238, 3274238, 241, 0,
|
||||
24651476, 23602660, 254, 0, 1081684, 0, 294, 0, 690289281, 0, 280, 5152179626, 1049728, 0, 72, 135846488,
|
||||
545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 37385630, 0, 72, 0, 95081897, 0, 318, 0, 18338066, 0, 317, 0, 27269783, 0, 314, 231858765, 14574244,
|
||||
0, 306, 0, 1566824927, 1566824927, 302, 0, 1048656, 1048656, 301, 0, 27370860, 27370860, 300, 26876898, 1048816,
|
||||
1048816, 256, 0, 1081684, 0, 295, 0, 624126, 0, 289, 0, 689140843, 0, 283, 0, 524312, 0, 281, 5152179626,
|
||||
1049728, 0, 73, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 37385630, 0, 73, 0, 95081897, 6643414, 319, 0, 18338066, 0, 315, 0, 27269783, 0, 315, 231858765,
|
||||
14574244, 0, 307, 1623170157, 1081684, 0, 296, 0, 624126, 0, 290, 0, 12739870, 0, 286, 0, 676400973, 0, 284, 0,
|
||||
524312, 0, 262, 5152179626, 1049728, 0, 74, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 37385630, 0, 74, 6643414, 88438483, 0, 320, 0, 18338066, 0, 316, 0, 27269783, 0, 316, 231858765,
|
||||
524800, 0, 311, 0, 14049444, 0, 308, 1623170157, 1081684, 0, 297, 0, 624126, 624126, 291, 0, 12739870, 0, 287,
|
||||
0, 676400973, 0, 285, 0, 524312, 524312, 282, 5152179626, 1049728, 0, 75, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 27334886, 0, 75, 0, 9526424, 0, 328, 0, 524320, 0, 329, 6643414, 20792845, 0, 328, 0, 65465889, 0, 75,
|
||||
0, 2179749, 0, 114, 0, 18338066, 0, 114, 0, 27269783, 0, 114, 231858765, 524800, 0, 22, 0, 14049444, 0, 309,
|
||||
1623170157, 1081684, 1081684, 298, 624126, 12739870, 0, 288, 0, 676400973, 0, 78, 5152703938, 1049728, 0, 76,
|
||||
135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
61093623, 26810494, 1585182, 76, 0, 524392, 524392, 327, 0, 9526424, 0, 77, 0, 524320, 524320, 330, 6643414,
|
||||
20792845, 0, 77, 0, 8389952, 8389952, 327, 0, 57075937, 2171836, 76, 0, 2179749, 0, 321, 0, 18338066, 0, 78, 0,
|
||||
27269783, 0, 78, 231858765, 524800, 0, 23, 0, 14049444, 14049444, 310, 1624875967, 12739870, 0, 78, 0,
|
||||
676400973, 557321544, 79, 5152703938, 1049728, 0, 77, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
62678805, 25225312, 0, 77, 524392, 9526424, 0, 78, 7167734, 20792845, 0, 78, 10561788, 49063106, 0, 77, 0,
|
||||
1050624, 0, 324, 0, 4790371, 4790371, 323, 0, 2179749, 2179749, 322, 0, 18338066, 9242480, 79, 0, 27269783,
|
||||
18484960, 79, 231858765, 524800, 524800, 24, 1638925411, 12739870, 11090976, 79, 557321544, 119079429, 0, 80,
|
||||
5152703938, 1049728, 0, 78, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
62678805, 25225312, 0, 78, 524392, 9526424, 4621240, 79, 7167734, 20792845, 11090976, 79, 10561788, 49063106, 0,
|
||||
78, 0, 1050624, 0, 325, 16212600, 9095586, 5670636, 80, 18484960, 8784823, 8227085, 80, 1882399952, 1648894, 0,
|
||||
80, 557321544, 2097312, 0, 81, 0, 116982117, 116982117, 243, 5152703938, 1049728, 0, 79, 135846488, 545034, 0,
|
||||
37,
|
||||
],
|
||||
[
|
||||
62678805, 25225312, 14787968, 79, 5145632, 4905184, 1998711, 80, 18258710, 9701869, 6119875, 80, 10561788,
|
||||
49063106, 25878944, 79, 0, 1050624, 1050624, 326, 21883236, 3424950, 3424950, 115, 26712045, 557738, 557738,
|
||||
115, 1882399952, 524864, 524864, 81, 0, 1124030, 1124030, 243, 557321544, 2097312, 2097312, 82, 5269686055,
|
||||
1049728, 0, 80, 135846488, 545034, 0, 37,
|
||||
],
|
||||
[
|
||||
77466773, 10437344, 6336873, 80, 7144343, 2906473, 2906473, 115, 24378585, 3581994, 3581994, 115, 36440732,
|
||||
23184162, 14346960, 80, 7766782350, 1049728, 0, 81, 135846488, 545034, 0, 38,
|
||||
],
|
||||
[
|
||||
83803646, 4100471, 4100471, 115, 88799087, 8837202, 8837202, 115, 7766782350, 1049728, 1049728, 82, 135846488,
|
||||
545034, 545034, 39,
|
||||
],
|
||||
],
|
||||
numTicks: 8624078250,
|
||||
},
|
||||
timeline: null,
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
||||
import { LevelItem } from './dataTransform';
|
||||
import { levelsToString, textToDataContainer, trimLevelsString } from './testHelpers';
|
||||
|
||||
describe('textToDataContainer', () => {
|
||||
it('converts text to correct data container', () => {
|
||||
const container = textToDataContainer(`
|
||||
[1//////////////]
|
||||
[2][4//][7///]
|
||||
[3][5]
|
||||
[6]
|
||||
`)!;
|
||||
|
||||
const n6: LevelItem = { itemIndexes: [5], start: 3, children: [], value: 3 };
|
||||
|
||||
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [n6], value: 3 };
|
||||
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [], value: 3 };
|
||||
|
||||
const n7: LevelItem = { itemIndexes: [6], start: 8, children: [], value: 6 };
|
||||
const n4: LevelItem = { itemIndexes: [3], start: 3, children: [n5], value: 5 };
|
||||
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3], value: 3 };
|
||||
|
||||
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n4, n7], value: 17 };
|
||||
|
||||
n2.parents = [n1];
|
||||
n4.parents = [n1];
|
||||
n7.parents = [n1];
|
||||
|
||||
n3.parents = [n2];
|
||||
n5.parents = [n4];
|
||||
|
||||
n6.parents = [n5];
|
||||
|
||||
const levels = container.getLevels();
|
||||
|
||||
expect(levels[0][0]).toEqual(n1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelsToString', () => {
|
||||
it('converts data container to correct string', () => {
|
||||
const stringGraph = trimLevelsString(`
|
||||
[1//////////////]
|
||||
[2][4//][7///]
|
||||
[3][5]
|
||||
[6]
|
||||
`);
|
||||
const container = textToDataContainer(stringGraph)!;
|
||||
expect(levelsToString(container.getLevels(), container)).toEqual(stringGraph);
|
||||
});
|
||||
});
|
@ -0,0 +1,102 @@
|
||||
import { arrayToDataFrame } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
// Convert text to a FlameGraphDataContainer for testing. The format representing the flamegraph for example:
|
||||
// [0///////]
|
||||
// [1//][4//]
|
||||
// [2//][5]
|
||||
// [3] [6]
|
||||
// [7]
|
||||
// Each node starts with [ ends with ], single digit is used for label and the length of a node is it's value.
|
||||
export function textToDataContainer(text: string) {
|
||||
const levels = text.split('\n');
|
||||
|
||||
if (levels.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (levels[0] === '') {
|
||||
levels.shift();
|
||||
}
|
||||
|
||||
const dfValues: Array<{ level: number; value: number; label: string; self: number }> = [];
|
||||
const dfSorted: Array<{ level: number; value: number; label: string; self: number }> = [];
|
||||
const leftMargin = levels[0].indexOf('[');
|
||||
|
||||
let itemLevels: LevelItem[][] = [];
|
||||
const re = /\[(\d)[^\[]*]/g;
|
||||
let match;
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
while ((match = re.exec(levels[i])) !== null) {
|
||||
const currentNodeValue = match[0].length;
|
||||
dfValues.push({
|
||||
value: match[0].length,
|
||||
label: match[1],
|
||||
self: match[0].length,
|
||||
level: i,
|
||||
});
|
||||
|
||||
const node: LevelItem = {
|
||||
value: match[0].length,
|
||||
itemIndexes: [dfValues.length - 1],
|
||||
start: match.index - leftMargin,
|
||||
children: [],
|
||||
};
|
||||
|
||||
itemLevels[i] = itemLevels[i] || [];
|
||||
itemLevels[i].push(node);
|
||||
const prevLevel = itemLevels[i - 1];
|
||||
|
||||
if (prevLevel) {
|
||||
for (const n of prevLevel) {
|
||||
const nRow = dfValues[n.itemIndexes[0]];
|
||||
const value = nRow.value;
|
||||
if (n.start + value > node.start) {
|
||||
n.children.push(node);
|
||||
nRow.self = nRow.self - currentNodeValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const root = itemLevels[0][0];
|
||||
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const node = stack.shift()!;
|
||||
const index = node.itemIndexes[0];
|
||||
dfSorted.push(dfValues[index]);
|
||||
node.itemIndexes = [dfSorted.length - 1];
|
||||
if (node.children) {
|
||||
stack.unshift(...node.children);
|
||||
}
|
||||
}
|
||||
|
||||
const df = arrayToDataFrame(dfSorted);
|
||||
return new FlameGraphDataContainer(df);
|
||||
}
|
||||
|
||||
export function trimLevelsString(s: string) {
|
||||
const lines = s.split('\n').filter((l) => !l.match(/^\s*$/));
|
||||
const offset = Math.min(lines[0].indexOf('['), lines[lines.length - 1].indexOf('['));
|
||||
return lines.map((l) => l.substring(offset)).join('\n');
|
||||
}
|
||||
|
||||
// Convert levels array to a string representation that can be visually compared. Mainly useful together with
|
||||
// textToDataContainer to create more visual tests.
|
||||
export function levelsToString(levels: LevelItem[][], data: FlameGraphDataContainer) {
|
||||
let sLevels = [];
|
||||
for (const level of levels) {
|
||||
let sLevel = ' '.repeat(level[0].start);
|
||||
for (const node of level) {
|
||||
sLevel += ' '.repeat(node.start - sLevel.length);
|
||||
sLevel += `[${data.getLabel(node.itemIndexes[0])}${'/'.repeat(node.value - 3)}]`;
|
||||
}
|
||||
sLevels.push(sLevel);
|
||||
}
|
||||
return sLevels.join('\n');
|
||||
}
|
@ -0,0 +1,162 @@
|
||||
import { levelsToString, textToDataContainer, trimLevelsString } from './testHelpers';
|
||||
import { mergeParentSubtrees, mergeSubtrees } from './treeTransforms';
|
||||
|
||||
describe('mergeSubtrees', () => {
|
||||
it('correctly merges trees', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0////////////]
|
||||
[1//][4/////]
|
||||
[2] [1////]
|
||||
[3] [2][7/]
|
||||
[8]
|
||||
`)!;
|
||||
const levels = container.getLevels()!;
|
||||
const node1 = levels[1][0];
|
||||
const node2 = levels[2][1];
|
||||
const merged = mergeSubtrees([node1, node2], container);
|
||||
|
||||
expect(merged[0][0]).toMatchObject({ itemIndexes: [1, 5], start: 0 });
|
||||
expect(merged[1][0]).toMatchObject({ itemIndexes: [2, 6], start: 0 });
|
||||
expect(merged[1][1]).toMatchObject({ itemIndexes: [7], start: 6 });
|
||||
expect(merged[2][0]).toMatchObject({ itemIndexes: [3], start: 0 });
|
||||
expect(merged[2][1]).toMatchObject({ itemIndexes: [8], start: 6 });
|
||||
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[1/////////]
|
||||
[2///][7/]
|
||||
[3] [8]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes the tree offset for single node', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0////////////]
|
||||
[1//][4/////]
|
||||
[2] [5////]
|
||||
[3] [6][7/]
|
||||
[8]
|
||||
`)!;
|
||||
const levels = container.getLevels()!;
|
||||
const node = levels[1][1];
|
||||
const merged = mergeSubtrees([node], container);
|
||||
|
||||
expect(merged[0][0]).toMatchObject({ itemIndexes: [4], start: 0 });
|
||||
expect(merged[1][0]).toMatchObject({ itemIndexes: [5], start: 0 });
|
||||
expect(merged[2][0]).toMatchObject({ itemIndexes: [6], start: 0 });
|
||||
expect(merged[2][1]).toMatchObject({ itemIndexes: [7], start: 3 });
|
||||
expect(merged[3][0]).toMatchObject({ itemIndexes: [8], start: 3 });
|
||||
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[4/////]
|
||||
[5////]
|
||||
[6][7/]
|
||||
[8]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('handles repeating items', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
`)!;
|
||||
const levels = container.getLevels()!;
|
||||
const merged = mergeSubtrees([levels[0][0]], container);
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeParentSubtrees', () => {
|
||||
it('correctly merges trees', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0/////////////]
|
||||
[1//][4/////][6]
|
||||
[2] [5/////]
|
||||
[6] [6/][8/]
|
||||
[7]
|
||||
`)!;
|
||||
|
||||
const levels = container.getLevels()!;
|
||||
const merged = mergeParentSubtrees([levels[3][0], levels[3][1], levels[1][2]], container);
|
||||
|
||||
expect(merged[0][0]).toMatchObject({ itemIndexes: [0], start: 3, value: 3 });
|
||||
expect(merged[0][1]).toMatchObject({ itemIndexes: [0], start: 6, value: 4 });
|
||||
expect(merged[1][0]).toMatchObject({ itemIndexes: [1], start: 3, value: 3 });
|
||||
expect(merged[1][1]).toMatchObject({ itemIndexes: [4], start: 6, value: 4 });
|
||||
expect(merged[2][0]).toMatchObject({ itemIndexes: [0], start: 0, value: 3 });
|
||||
expect(merged[2][1]).toMatchObject({ itemIndexes: [2], start: 3, value: 3 });
|
||||
expect(merged[2][2]).toMatchObject({ itemIndexes: [5], start: 6, value: 4 });
|
||||
expect(merged[3][0]).toMatchObject({ itemIndexes: [3, 6, 9], start: 0, value: 10 });
|
||||
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[0][0/]
|
||||
[1][4/]
|
||||
[0][2][5/]
|
||||
[6///////]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('handles repeating nodes in single parent tree', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0]
|
||||
[1]
|
||||
[2]
|
||||
[1]
|
||||
[4]
|
||||
`)!;
|
||||
|
||||
const levels = container.getLevels()!;
|
||||
const merged = mergeParentSubtrees([levels[1][0], levels[3][0]], container);
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[0]
|
||||
[1]
|
||||
[0][2]
|
||||
[1///]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('handles single node', () => {
|
||||
const container = textToDataContainer(`[0]`)!;
|
||||
const levels = container.getLevels()!;
|
||||
const merged = mergeParentSubtrees([levels[0][0]], container);
|
||||
expect(levelsToString(merged, container)).toEqual(trimLevelsString(`[0]`));
|
||||
});
|
||||
|
||||
it('handles multiple same nodes', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
`)!;
|
||||
|
||||
const levels = container.getLevels()!;
|
||||
const merged = mergeParentSubtrees([levels[4][0]], container);
|
||||
expect(levelsToString(merged, container)).toEqual(
|
||||
trimLevelsString(`
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
[0]
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,125 @@
|
||||
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,
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp, createDataFrame } from '@grafana/data';
|
||||
|
||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
|
||||
|
||||
import { data } from './FlameGraph/testData/dataNestedSet';
|
||||
import FlameGraphContainer from './FlameGraphContainer';
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useMeasure: () => {
|
||||
const ref = React.useRef();
|
||||
return [ref, { width: 1600 }];
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FlameGraphContainer', () => {
|
||||
// Needed for AutoSizer to work in test
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { value: 500 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { value: 500 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { value: 500 });
|
||||
|
||||
const FlameGraphContainerWithProps = () => {
|
||||
const flameGraphData = createDataFrame(data);
|
||||
flameGraphData.meta = {
|
||||
custom: {
|
||||
ProfileTypeID: 'cpu:foo:bar',
|
||||
},
|
||||
};
|
||||
|
||||
return <FlameGraphContainer data={flameGraphData} app={CoreApp.Explore} />;
|
||||
};
|
||||
|
||||
it('should render without error', async () => {
|
||||
expect(() => render(<FlameGraphContainerWithProps />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should update search when row selected in top table', async () => {
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]);
|
||||
expect(screen.getByDisplayValue('net/http.HandlerFunc.ServeHTTP')).toBeInTheDocument();
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
|
||||
expect(screen.getByDisplayValue('total')).toBeInTheDocument();
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
|
||||
expect(screen.queryByDisplayValue('total')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render options', async () => {
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
expect(screen.getByText(/Top Table/)).toBeDefined();
|
||||
expect(screen.getByText(/Flame Graph/)).toBeDefined();
|
||||
expect(screen.getByText(/Both/)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update selected view', async () => {
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
|
||||
expect(screen.getByTestId('flameGraph')).toBeDefined();
|
||||
expect(screen.getByTestId('topTable')).toBeDefined();
|
||||
|
||||
await userEvent.click(screen.getByText(/Top Table/));
|
||||
expect(screen.queryByTestId('flameGraph')).toBeNull();
|
||||
expect(screen.getByTestId('topTable')).toBeDefined();
|
||||
|
||||
await userEvent.click(screen.getByText(/Flame Graph/));
|
||||
expect(screen.getByTestId('flameGraph')).toBeDefined();
|
||||
expect(screen.queryByTestId('topTable')).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByText(/Both/));
|
||||
expect(screen.getByTestId('flameGraph')).toBeDefined();
|
||||
expect(screen.getByTestId('topTable')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render both option if screen width >= threshold', async () => {
|
||||
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH;
|
||||
global.dispatchEvent(new Event('resize')); // Trigger the window resize event
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
|
||||
expect(screen.getByText(/Both/)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not render both option if screen width < threshold', async () => {
|
||||
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH - 1;
|
||||
global.dispatchEvent(new Event('resize'));
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
|
||||
expect(screen.queryByTestId(/Both/)).toBeNull();
|
||||
});
|
||||
});
|
@ -0,0 +1,157 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { DataFrame, CoreApp, GrafanaTheme2 } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { useStyles2, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
|
||||
|
||||
import FlameGraph from './FlameGraph/FlameGraph';
|
||||
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
||||
import FlameGraphHeader from './FlameGraphHeader';
|
||||
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
||||
import { ClickedItemData, SelectedView, TextAlign } from './types';
|
||||
|
||||
type Props = {
|
||||
data?: DataFrame;
|
||||
app: CoreApp;
|
||||
};
|
||||
|
||||
const FlameGraphContainer = (props: Props) => {
|
||||
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
||||
|
||||
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<HTMLDivElement>();
|
||||
const [textAlign, setTextAlign] = useState<TextAlign>('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<string>();
|
||||
|
||||
const theme = useTheme2();
|
||||
|
||||
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
|
||||
if (!props.data) {
|
||||
return;
|
||||
}
|
||||
return new FlameGraphDataContainer(props.data, theme);
|
||||
}, [props.data, theme]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// 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
|
||||
) {
|
||||
setSelectedView(SelectedView.FlameGraph);
|
||||
}
|
||||
}, [selectedView, setSelectedView, containerWidth]);
|
||||
|
||||
function resetFocus() {
|
||||
setFocusedItemData(undefined);
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
}
|
||||
|
||||
function resetSandwich() {
|
||||
setSandwichItem(undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resetFocus();
|
||||
resetSandwich();
|
||||
}, [props.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dataContainer && (
|
||||
<div ref={sizeRef} className={styles.container}>
|
||||
<FlameGraphHeader
|
||||
app={props.app}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
selectedView={selectedView}
|
||||
setSelectedView={setSelectedView}
|
||||
containerWidth={containerWidth}
|
||||
onReset={() => {
|
||||
resetFocus();
|
||||
resetSandwich();
|
||||
}}
|
||||
textAlign={textAlign}
|
||||
onTextAlignChange={setTextAlign}
|
||||
showResetButton={Boolean(focusedItemData || sandwichItem)}
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
{selectedView !== SelectedView.FlameGraph && (
|
||||
<FlameGraphTopTableContainer
|
||||
data={dataContainer}
|
||||
app={props.app}
|
||||
onSymbolClick={(symbol) => {
|
||||
if (search === symbol) {
|
||||
setSearch('');
|
||||
} else {
|
||||
reportInteraction('grafana_flamegraph_table_item_selected', {
|
||||
app: props.app,
|
||||
grafana_version: config.buildInfo.version,
|
||||
});
|
||||
setSearch(symbol);
|
||||
resetFocus();
|
||||
}
|
||||
}}
|
||||
height={selectedView === SelectedView.TopTable ? 600 : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView !== SelectedView.TopTable && (
|
||||
<FlameGraph
|
||||
data={dataContainer}
|
||||
rangeMin={rangeMin}
|
||||
rangeMax={rangeMax}
|
||||
search={search}
|
||||
setRangeMin={setRangeMin}
|
||||
setRangeMax={setRangeMax}
|
||||
onItemFocused={(data) => setFocusedItemData(data)}
|
||||
focusedItemData={focusedItemData}
|
||||
textAlign={textAlign}
|
||||
sandwichItem={sandwichItem}
|
||||
onSandwich={(label: string) => {
|
||||
resetFocus();
|
||||
setSandwichItem(label);
|
||||
}}
|
||||
onFocusPillClick={resetFocus}
|
||||
onSandwichPillClick={resetSandwich}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
container: css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flex: '1 1 0',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
body: css({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default FlameGraphContainer;
|
@ -0,0 +1,58 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp } from '@grafana/data';
|
||||
|
||||
import FlameGraphHeader from './FlameGraphHeader';
|
||||
import { SelectedView } from './types';
|
||||
|
||||
describe('FlameGraphHeader', () => {
|
||||
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
|
||||
const setSearch = jest.fn();
|
||||
const setSelectedView = jest.fn();
|
||||
const onReset = jest.fn();
|
||||
|
||||
const renderResult = render(
|
||||
<FlameGraphHeader
|
||||
app={CoreApp.Explore}
|
||||
search={''}
|
||||
setSearch={setSearch}
|
||||
selectedView={SelectedView.Both}
|
||||
setSelectedView={setSelectedView}
|
||||
containerWidth={1600}
|
||||
onReset={onReset}
|
||||
onTextAlignChange={jest.fn()}
|
||||
textAlign={'left'}
|
||||
showResetButton={true}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
renderResult,
|
||||
handlers: {
|
||||
setSearch,
|
||||
setSelectedView,
|
||||
onReset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('show reset button when needed', async () => {
|
||||
setup({ showResetButton: false });
|
||||
expect(screen.queryByLabelText(/Reset focus/)).toBeNull();
|
||||
|
||||
setup();
|
||||
expect(screen.getByLabelText(/Reset focus/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls on reset when reset button is clicked', async () => {
|
||||
const { handlers } = setup();
|
||||
const resetButton = screen.getByLabelText(/Reset focus/);
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
await userEvent.click(resetButton);
|
||||
expect(handlers.onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
@ -0,0 +1,214 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
|
||||
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { config } from '../../../../../core/config';
|
||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
|
||||
|
||||
import { SelectedView, TextAlign } from './types';
|
||||
|
||||
type Props = {
|
||||
app: CoreApp;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
selectedView: SelectedView;
|
||||
setSelectedView: (view: SelectedView) => void;
|
||||
containerWidth: number;
|
||||
onReset: () => void;
|
||||
textAlign: TextAlign;
|
||||
onTextAlignChange: (align: TextAlign) => void;
|
||||
showResetButton: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphHeader = ({
|
||||
app,
|
||||
search,
|
||||
setSearch,
|
||||
selectedView,
|
||||
setSelectedView,
|
||||
containerWidth,
|
||||
onReset,
|
||||
textAlign,
|
||||
onTextAlignChange,
|
||||
showResetButton,
|
||||
}: Props) => {
|
||||
const styles = useStyles2((theme) => getStyles(theme, app));
|
||||
function interaction(name: string, context: Record<string, string | number>) {
|
||||
reportInteraction(`grafana_flamegraph_${name}`, {
|
||||
app,
|
||||
grafana_version: config.buildInfo.version,
|
||||
...context,
|
||||
});
|
||||
}
|
||||
|
||||
const [localSearch, setLocalSearch] = useSearchInput(search, setSearch);
|
||||
|
||||
const suffix =
|
||||
localSearch !== '' ? (
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// We could set only one and wait them to sync but there is no need to debounce this.
|
||||
setSearch('');
|
||||
setLocalSearch('');
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.inputContainer}>
|
||||
<Input
|
||||
value={localSearch || ''}
|
||||
onChange={(v) => {
|
||||
setLocalSearch(v.currentTarget.value);
|
||||
}}
|
||||
placeholder={'Search..'}
|
||||
width={44}
|
||||
suffix={suffix}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rightContainer}>
|
||||
{showResetButton && (
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
fill={'outline'}
|
||||
size={'sm'}
|
||||
icon={'history-alt'}
|
||||
tooltip={'Reset focus and sandwich state'}
|
||||
onClick={() => {
|
||||
onReset();
|
||||
}}
|
||||
className={styles.buttonSpacing}
|
||||
aria-label={'Reset focus and sandwich state'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RadioButtonGroup<TextAlign>
|
||||
size="sm"
|
||||
disabled={selectedView === SelectedView.TopTable}
|
||||
options={alignOptions}
|
||||
value={textAlign}
|
||||
onChange={(val) => {
|
||||
interaction('text_align_selected', { align: val });
|
||||
onTextAlignChange(val);
|
||||
}}
|
||||
className={styles.buttonSpacing}
|
||||
/>
|
||||
<RadioButtonGroup<SelectedView>
|
||||
size="sm"
|
||||
options={getViewOptions(containerWidth)}
|
||||
value={selectedView}
|
||||
onChange={(view) => {
|
||||
interaction('view_selected', { view });
|
||||
setSelectedView(view);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const alignOptions: Array<SelectableValue<TextAlign>> = [
|
||||
{ value: 'left', description: 'Align text left', icon: 'align-left' },
|
||||
{ value: 'right', description: 'Align text right', icon: 'align-right' },
|
||||
];
|
||||
|
||||
function getViewOptions(width: number): Array<SelectableValue<SelectedView>> {
|
||||
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) {
|
||||
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, app: CoreApp) => ({
|
||||
header: css`
|
||||
label: header;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: ${theme.colors.background.primary};
|
||||
top: 0;
|
||||
z-index: ${theme.zIndex.navbarFixed};
|
||||
${app === CoreApp.Explore
|
||||
? css`
|
||||
position: sticky;
|
||||
padding-bottom: ${theme.spacing(1)};
|
||||
padding-top: ${theme.spacing(1)};
|
||||
`
|
||||
: ''};
|
||||
`,
|
||||
inputContainer: css`
|
||||
label: inputContainer;
|
||||
margin-right: 20px;
|
||||
`,
|
||||
rightContainer: css`
|
||||
label: rightContainer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
buttonSpacing: css`
|
||||
label: buttonSpacing;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
`,
|
||||
|
||||
resetButton: css`
|
||||
label: resetButton;
|
||||
display: flex;
|
||||
margin-right: ${theme.spacing(2)};
|
||||
`,
|
||||
resetButtonIconWrapper: css`
|
||||
label: resetButtonIcon;
|
||||
padding: 0 5px;
|
||||
color: ${theme.colors.text.disabled};
|
||||
`,
|
||||
});
|
||||
|
||||
export default FlameGraphHeader;
|
@ -0,0 +1,126 @@
|
||||
import { FlamegraphRenderer } from '@pyroscope/flamegraph';
|
||||
import React from 'react';
|
||||
import '@pyroscope/flamegraph/dist/index.css';
|
||||
|
||||
import { CoreApp, DataFrame, DataFrameView } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import FlameGraphContainer from './FlameGraphContainer';
|
||||
|
||||
type Props = {
|
||||
data?: DataFrame;
|
||||
app: CoreApp;
|
||||
// Height for flame graph when not used in explore.
|
||||
// This needs to be different to explore flame graph height as we
|
||||
// use panels with user adjustable heights in dashboards etc.
|
||||
flameGraphHeight?: number;
|
||||
};
|
||||
|
||||
export const FlameGraphTopWrapper = (props: Props) => {
|
||||
if (config.featureToggles.pyroscopeFlameGraph) {
|
||||
const profile = props.data ? dataFrameToFlameBearer(props.data) : undefined;
|
||||
return <FlamegraphRenderer profile={profile} />;
|
||||
}
|
||||
|
||||
return <FlameGraphContainer data={props.data} app={props.app} />;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
level: number;
|
||||
label: string;
|
||||
value: number;
|
||||
self: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a nested set format from a DataFrame to a Flamebearer format needed by the pyroscope flamegraph.
|
||||
* @param data
|
||||
*/
|
||||
function dataFrameToFlameBearer(data: DataFrame) {
|
||||
// Unfortunately we cannot use @pyroscope/models for now as they publish ts files which then get type checked and
|
||||
// they do not pass our with our tsconfig
|
||||
const profile: any = {
|
||||
version: 1,
|
||||
flamebearer: {
|
||||
names: [],
|
||||
levels: [],
|
||||
numTicks: 0,
|
||||
maxSelf: 0,
|
||||
},
|
||||
metadata: {
|
||||
format: 'single' as const,
|
||||
sampleRate: 100,
|
||||
spyName: 'gospy' as const,
|
||||
units: 'samples' as const,
|
||||
},
|
||||
};
|
||||
const view = new DataFrameView<Row>(data);
|
||||
const labelField = data.fields.find((f) => f.name === 'label');
|
||||
|
||||
if (labelField?.config?.type?.enum?.text) {
|
||||
profile.flamebearer.names = labelField.config.type.enum.text;
|
||||
}
|
||||
|
||||
const labelMap: Record<string, number> = {};
|
||||
|
||||
// Handle both cases where label is a string or a number pointing to enum config text array.
|
||||
const getLabel = (label: string | number) => {
|
||||
if (typeof label === 'number') {
|
||||
return label;
|
||||
} else {
|
||||
if (labelMap[label] === undefined) {
|
||||
labelMap[label] = profile.flamebearer.names.length;
|
||||
profile.flamebearer.names.push(label);
|
||||
}
|
||||
|
||||
return labelMap[label];
|
||||
}
|
||||
};
|
||||
|
||||
// Absolute offset where we are currently at.
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
// view.get() changes the underlying object, so we have to call this first get the value and then call get() for
|
||||
// current row.
|
||||
const prevLevel = i > 0 ? view.get(i - 1).level : undefined;
|
||||
const row = view.get(i);
|
||||
const currentLevel = row.level;
|
||||
const level = profile.flamebearer.levels[currentLevel];
|
||||
|
||||
// First row is the root and always the total number of ticks.
|
||||
if (i === 0) {
|
||||
profile.flamebearer.numTicks = row.value;
|
||||
}
|
||||
profile.flamebearer.maxSelf = Math.max(profile.flamebearer.maxSelf, row.self);
|
||||
|
||||
if (prevLevel && prevLevel >= currentLevel) {
|
||||
// we are going back to the previous level and adding sibling we have to figure out new offset
|
||||
offset = levelWidth(level);
|
||||
}
|
||||
|
||||
if (!level) {
|
||||
// Starting a new level. Offset is what ever current absolute offset is as there are no siblings yet.
|
||||
profile.flamebearer.levels[row.level] = [offset, row.value, row.self, getLabel(row.label)];
|
||||
} else {
|
||||
// We actually need offset relative to sibling while offset variable contains absolute offset.
|
||||
const width = levelWidth(level);
|
||||
level.push(offset - width, row.value, row.self, getLabel(row.label));
|
||||
}
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a width of a level. As offsets are relative to siblings we need to sum all the offsets and values in a level.
|
||||
* @param level
|
||||
*/
|
||||
function levelWidth(level: number[]) {
|
||||
let length = 0;
|
||||
for (let i = 0; i < level.length; i += 4) {
|
||||
const start = level[i];
|
||||
const value = level[i + 1];
|
||||
length += start + value;
|
||||
}
|
||||
return length;
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp, createDataFrame } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
|
||||
import { data } from '../FlameGraph/testData/dataNestedSet';
|
||||
|
||||
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
|
||||
|
||||
describe('FlameGraphTopTableContainer', () => {
|
||||
const FlameGraphTopTableContainerWithProps = () => {
|
||||
const flameGraphData = createDataFrame(data);
|
||||
const container = new FlameGraphDataContainer(flameGraphData);
|
||||
|
||||
return <FlameGraphTopTableContainer data={container} app={CoreApp.Explore} onSymbolClick={jest.fn()} />;
|
||||
};
|
||||
|
||||
it('should render without error', async () => {
|
||||
expect(() => render(<FlameGraphTopTableContainerWithProps />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should render correctly', async () => {
|
||||
// Needed for AutoSizer to work in test
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
|
||||
|
||||
render(<FlameGraphTopTableContainerWithProps />);
|
||||
const rows = screen.getAllByRole('row');
|
||||
expect(rows).toHaveLength(16);
|
||||
|
||||
const columnHeaders = screen.getAllByRole('columnheader');
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
expect(columnHeaders[0].textContent).toEqual('Symbol');
|
||||
expect(columnHeaders[1].textContent).toEqual('Self');
|
||||
expect(columnHeaders[2].textContent).toEqual('Total');
|
||||
|
||||
const cells = screen.getAllByRole('cell');
|
||||
expect(cells).toHaveLength(45); // 16 rows
|
||||
expect(cells[0].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
|
||||
expect(cells[1].textContent).toEqual('31.7 K');
|
||||
expect(cells[2].textContent).toEqual('31.7 Bil');
|
||||
expect(cells[24].textContent).toEqual('test/pkg/create.(*create).initServer.func2.1');
|
||||
expect(cells[25].textContent).toEqual('5.58 K');
|
||||
expect(cells[26].textContent).toEqual('5.58 Bil');
|
||||
});
|
||||
});
|
@ -0,0 +1,140 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { applyFieldOverrides, CoreApp, DataFrame, DataLinkClickEvent, Field, FieldType } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Table, TableSortByFieldState, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { TOP_TABLE_COLUMN_WIDTH } from '../../constants';
|
||||
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
|
||||
import { TableData } from '../types';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
app: CoreApp;
|
||||
onSymbolClick: (symbol: string) => void;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const FlameGraphTopTableContainer = ({ data, app, onSymbolClick, height }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [sort, setSort] = useState<TableSortByFieldState[]>([{ displayName: 'Self', desc: true }]);
|
||||
|
||||
return (
|
||||
<div className={styles.topTableContainer} data-testid="topTable">
|
||||
<AutoSizer style={{ width: '100%', height }}>
|
||||
{({ width, height }) => {
|
||||
if (width < 3 || height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frame = buildTableDataFrame(data, width, onSymbolClick);
|
||||
return (
|
||||
<Table
|
||||
initialSortBy={sort}
|
||||
onSortByChange={(s) => {
|
||||
if (s && s.length) {
|
||||
reportInteraction('grafana_flamegraph_table_sort_selected', {
|
||||
app,
|
||||
grafana_version: config.buildInfo.version,
|
||||
sort: s[0].displayName + '_' + (s[0].desc ? 'desc' : 'asc'),
|
||||
});
|
||||
}
|
||||
setSort(s);
|
||||
}}
|
||||
data={frame}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function buildTableDataFrame(
|
||||
data: FlameGraphDataContainer,
|
||||
width: number,
|
||||
onSymbolClick: (str: string) => void
|
||||
): DataFrame {
|
||||
// Group the data by label
|
||||
// TODO: should be by filename + funcName + linenumber?
|
||||
let table: { [key: string]: TableData } = {};
|
||||
for (let i = 0; i < data.data.length; i++) {
|
||||
const value = data.getValue(i);
|
||||
const self = data.getSelf(i);
|
||||
const label = data.getLabel(i);
|
||||
table[label] = table[label] || {};
|
||||
table[label].self = table[label].self ? table[label].self + self : self;
|
||||
table[label].total = table[label].total ? table[label].total + value : value;
|
||||
}
|
||||
|
||||
const symbolField: Field = {
|
||||
type: FieldType.string,
|
||||
name: 'Symbol',
|
||||
values: [],
|
||||
config: {
|
||||
custom: { width: width - 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);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const selfField: Field = {
|
||||
type: FieldType.number,
|
||||
name: 'Self',
|
||||
values: [],
|
||||
config: { unit: data.selfField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
|
||||
};
|
||||
|
||||
const totalField: Field = {
|
||||
type: FieldType.number,
|
||||
name: 'Total',
|
||||
values: [],
|
||||
config: { unit: data.valueField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
|
||||
};
|
||||
|
||||
for (let key in table) {
|
||||
symbolField.values.push(key);
|
||||
selfField.values.push(table[key].self);
|
||||
totalField.values.push(table[key].total);
|
||||
}
|
||||
|
||||
const frame = { fields: [symbolField, selfField, totalField], length: symbolField.values.length };
|
||||
|
||||
const dataFrames = applyFieldOverrides({
|
||||
data: [frame],
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (value: string) => value,
|
||||
theme: config.theme2,
|
||||
});
|
||||
|
||||
return dataFrames[0];
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
topTableContainer: css`
|
||||
flex-grow: 1;
|
||||
flex-basis: 50%;
|
||||
overflow: hidden;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
export default FlameGraphTopTableContainer;
|
@ -0,0 +1,45 @@
|
||||
import { LevelItem } from './FlameGraph/dataTransform';
|
||||
|
||||
export type ClickedItemData = {
|
||||
posX: number;
|
||||
posY: number;
|
||||
label: string;
|
||||
item: LevelItem;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export enum SampleUnit {
|
||||
Bytes = 'bytes',
|
||||
Short = 'short',
|
||||
Nanoseconds = 'ns',
|
||||
}
|
||||
|
||||
export enum ColumnTypes {
|
||||
Symbol = 'Symbol',
|
||||
Self = 'Self',
|
||||
Total = 'Total',
|
||||
}
|
||||
|
||||
export enum SelectedView {
|
||||
TopTable = 'topTable',
|
||||
FlameGraph = 'flameGraph',
|
||||
Both = 'both',
|
||||
}
|
||||
|
||||
export interface TableData {
|
||||
self: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TopTableData {
|
||||
symbol: string;
|
||||
self: TopTableValue;
|
||||
total: TopTableValue;
|
||||
}
|
||||
|
||||
export type TopTableValue = {
|
||||
value: number;
|
||||
unitValue: string;
|
||||
};
|
||||
|
||||
export type TextAlign = 'left' | 'right';
|
@ -0,0 +1,8 @@
|
||||
export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio;
|
||||
export const COLLAPSE_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 MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH = 800;
|
||||
export const TOP_TABLE_COLUMN_WIDTH = 120;
|
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 84.64 85.34"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#New_Gradient_Swatch_1);}</style><linearGradient id="New_Gradient_Swatch_1" x1="0" y1="4" x2="84.64" y2="4" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Icons"><rect class="cls-3" width="84.64" height="8"/><rect class="cls-2" x="44.06" y="11.05" width="18.28" height="8"/><rect class="cls-1" x="44.06" y="22.1" width="18.28" height="8"/><rect class="cls-2" x="44.06" y="33.14" width="16.28" height="8"/><rect class="cls-1" x="44.06" y="44.19" width="16.28" height="8"/><rect class="cls-2" x="44.06" y="55.24" width="14.23" height="8"/><rect class="cls-1" x="44.06" y="66.29" width="10.28" height="8"/><rect class="cls-2" x="44.06" y="77.34" width="10.28" height="8"/><rect class="cls-2" x="66.83" y="11.05" width="17.81" height="8"/><rect class="cls-1" x="66.83" y="22.1" width="17.81" height="8"/><rect class="cls-2" x="66.83" y="33.14" width="17.81" height="8"/><rect class="cls-1" x="66.83" y="44.19" width="13.81" height="8"/><rect class="cls-2" x="66.83" y="55.24" width="10.81" height="8"/><rect class="cls-2" y="11.05" width="17.28" height="8"/><rect class="cls-1" y="22.1" width="17.28" height="8"/><rect class="cls-2" y="33.14" width="9.28" height="8"/><rect class="cls-1" y="44.19" width="9.28" height="8"/><rect class="cls-2" y="55.24" width="9.28" height="8"/><rect class="cls-1" y="66.29" width="9.28" height="8"/><rect class="cls-2" y="77.34" width="5.28" height="8"/><rect class="cls-2" x="21.53" y="11.05" width="18.28" height="8"/><rect class="cls-1" x="21.53" y="22.1" width="18.28" height="8"/><rect class="cls-2" x="21.53" y="33.16" width="8.55" height="7.96"/><rect class="cls-1" x="21.53" y="44.21" width="8.55" height="7.96"/><rect class="cls-2" x="21.53" y="55.26" width="6.55" height="7.96"/><rect class="cls-1" x="21.53" y="66.31" width="4.55" height="7.96"/></g></svg>
|
After Width: | Height: | Size: 2.1 KiB |
Loading…
Reference in New Issue
Block a user