mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
FlameGraph: Add option to align text left or right (#68893)
This commit is contained in:
parent
25b89babac
commit
55bba165b1
@ -18,6 +18,8 @@ export const availableIconsIndex = {
|
|||||||
'angle-left': true,
|
'angle-left': true,
|
||||||
'angle-right': true,
|
'angle-right': true,
|
||||||
'angle-up': true,
|
'angle-up': true,
|
||||||
|
'align-left': true,
|
||||||
|
'align-right': true,
|
||||||
apps: true,
|
apps: true,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
'arrow-down': true,
|
'arrow-down': true,
|
||||||
|
@ -46,6 +46,7 @@ describe('FlameGraph', () => {
|
|||||||
setRangeMin={setRangeMin}
|
setRangeMin={setRangeMin}
|
||||||
setRangeMax={setRangeMax}
|
setRangeMax={setRangeMax}
|
||||||
selectedView={selectedView}
|
selectedView={selectedView}
|
||||||
|
textAlign={'left'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -25,7 +25,7 @@ import { CoreApp } from '@grafana/data';
|
|||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { PIXELS_PER_LEVEL } from '../../constants';
|
import { PIXELS_PER_LEVEL } from '../../constants';
|
||||||
import { SelectedView, ContextMenuData } from '../types';
|
import { SelectedView, ContextMenuData, TextAlign } from '../types';
|
||||||
|
|
||||||
import FlameGraphContextMenu from './FlameGraphContextMenu';
|
import FlameGraphContextMenu from './FlameGraphContextMenu';
|
||||||
import FlameGraphMetadata from './FlameGraphMetadata';
|
import FlameGraphMetadata from './FlameGraphMetadata';
|
||||||
@ -48,6 +48,7 @@ type Props = {
|
|||||||
setRangeMax: (range: number) => void;
|
setRangeMax: (range: number) => void;
|
||||||
selectedView: SelectedView;
|
selectedView: SelectedView;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
textAlign: TextAlign;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FlameGraph = ({
|
const FlameGraph = ({
|
||||||
@ -64,6 +65,7 @@ const FlameGraph = ({
|
|||||||
setRangeMin,
|
setRangeMin,
|
||||||
setRangeMax,
|
setRangeMax,
|
||||||
selectedView,
|
selectedView,
|
||||||
|
textAlign,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const totalTicks = data.getValue(0);
|
const totalTicks = data.getValue(0);
|
||||||
@ -119,10 +121,21 @@ const FlameGraph = ({
|
|||||||
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
|
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
|
||||||
for (const rect of dimensions) {
|
for (const rect of dimensions) {
|
||||||
// Render each rectangle based on the computed dimensions
|
// Render each rectangle based on the computed dimensions
|
||||||
renderRect(ctx, rect, totalTicks, rangeMin, rangeMax, search, levelIndex, topLevelIndex, foundLabels);
|
renderRect(
|
||||||
|
ctx,
|
||||||
|
rect,
|
||||||
|
totalTicks,
|
||||||
|
rangeMin,
|
||||||
|
rangeMax,
|
||||||
|
search,
|
||||||
|
levelIndex,
|
||||||
|
topLevelIndex,
|
||||||
|
foundLabels,
|
||||||
|
textAlign
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [data, levels, wrapperWidth, totalTicks, rangeMin, rangeMax, search, topLevelIndex, foundLabels]);
|
}, [data, levels, wrapperWidth, totalTicks, rangeMin, rangeMax, search, topLevelIndex, foundLabels, textAlign]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (graphRef.current) {
|
if (graphRef.current) {
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
LABEL_THRESHOLD,
|
LABEL_THRESHOLD,
|
||||||
PIXELS_PER_LEVEL,
|
PIXELS_PER_LEVEL,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
import { TextAlign } from '../types';
|
||||||
|
|
||||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||||
|
|
||||||
@ -80,7 +81,8 @@ export function renderRect(
|
|||||||
query: string,
|
query: string,
|
||||||
levelIndex: number,
|
levelIndex: number,
|
||||||
topLevelIndex: number,
|
topLevelIndex: number,
|
||||||
foundNames: Set<string>
|
foundNames: Set<string>,
|
||||||
|
textAlign: TextAlign
|
||||||
) {
|
) {
|
||||||
if (rect.width < HIDE_THRESHOLD) {
|
if (rect.width < HIDE_THRESHOLD) {
|
||||||
return;
|
return;
|
||||||
@ -110,18 +112,40 @@ export function renderRect(
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
if (!rect.collapsed && rect.width >= LABEL_THRESHOLD) {
|
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.save();
|
||||||
ctx.clip(); // so text does not overflow
|
ctx.clip(); // so text does not overflow
|
||||||
ctx.fillStyle = '#222';
|
ctx.fillStyle = '#222';
|
||||||
ctx.fillText(
|
|
||||||
`${name} (${rect.unitLabel})`,
|
// We only measure name here instead of full label because of how we deal with the units and aligning later.
|
||||||
Math.max(rect.x, 0) + BAR_TEXT_PADDING_LEFT,
|
const measure = ctx.measureText(name);
|
||||||
rect.y + PIXELS_PER_LEVEL / 2
|
const spaceForTextInRect = rect.width - BAR_TEXT_PADDING_LEFT;
|
||||||
);
|
|
||||||
ctx.restore();
|
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
|
* 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.
|
* section of the graph we align and shift the X coordinates accordingly.
|
||||||
|
@ -11,7 +11,7 @@ import FlameGraph from './FlameGraph/FlameGraph';
|
|||||||
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './FlameGraph/dataTransform';
|
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './FlameGraph/dataTransform';
|
||||||
import FlameGraphHeader from './FlameGraphHeader';
|
import FlameGraphHeader from './FlameGraphHeader';
|
||||||
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
||||||
import { SelectedView } from './types';
|
import { SelectedView, TextAlign } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: DataFrame;
|
data?: DataFrame;
|
||||||
@ -26,6 +26,8 @@ const FlameGraphContainer = (props: Props) => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedView, setSelectedView] = useState(SelectedView.Both);
|
const [selectedView, setSelectedView] = useState(SelectedView.Both);
|
||||||
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
||||||
|
const [textAlign, setTextAlign] = useState<TextAlign>('left');
|
||||||
|
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
|
|
||||||
const [dataContainer, levels] = useMemo((): [FlameGraphDataContainer, LevelItem[][]] | [undefined, undefined] => {
|
const [dataContainer, levels] = useMemo((): [FlameGraphDataContainer, LevelItem[][]] | [undefined, undefined] => {
|
||||||
@ -75,6 +77,8 @@ const FlameGraphContainer = (props: Props) => {
|
|||||||
selectedView={selectedView}
|
selectedView={selectedView}
|
||||||
setSelectedView={setSelectedView}
|
setSelectedView={setSelectedView}
|
||||||
containerWidth={containerWidth}
|
containerWidth={containerWidth}
|
||||||
|
textAlign={textAlign}
|
||||||
|
onTextAlignChange={setTextAlign}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
@ -108,6 +112,7 @@ const FlameGraphContainer = (props: Props) => {
|
|||||||
setRangeMin={setRangeMin}
|
setRangeMin={setRangeMin}
|
||||||
setRangeMax={setRangeMax}
|
setRangeMax={setRangeMax}
|
||||||
selectedView={selectedView}
|
selectedView={selectedView}
|
||||||
|
textAlign={textAlign}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +130,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
flex: '1 1 0',
|
flex: '1 1 0',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
body: css({
|
body: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -25,6 +25,8 @@ describe('FlameGraphHeader', () => {
|
|||||||
selectedView={selectedView}
|
selectedView={selectedView}
|
||||||
setSelectedView={setSelectedView}
|
setSelectedView={setSelectedView}
|
||||||
containerWidth={1600}
|
containerWidth={1600}
|
||||||
|
onTextAlignChange={jest.fn()}
|
||||||
|
textAlign={'left'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,14 +3,14 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import useDebounce from 'react-use/lib/useDebounce';
|
import useDebounce from 'react-use/lib/useDebounce';
|
||||||
import usePrevious from 'react-use/lib/usePrevious';
|
import usePrevious from 'react-use/lib/usePrevious';
|
||||||
|
|
||||||
import { GrafanaTheme2, CoreApp } from '@grafana/data';
|
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { config } from '../../../../core/config';
|
import { config } from '../../../../core/config';
|
||||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
|
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
|
||||||
|
|
||||||
import { SelectedView } from './types';
|
import { SelectedView, TextAlign } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
app: CoreApp;
|
app: CoreApp;
|
||||||
@ -23,6 +23,8 @@ type Props = {
|
|||||||
selectedView: SelectedView;
|
selectedView: SelectedView;
|
||||||
setSelectedView: (view: SelectedView) => void;
|
setSelectedView: (view: SelectedView) => void;
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
|
textAlign: TextAlign;
|
||||||
|
onTextAlignChange: (align: TextAlign) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FlameGraphHeader = ({
|
const FlameGraphHeader = ({
|
||||||
@ -36,19 +38,15 @@ const FlameGraphHeader = ({
|
|||||||
selectedView,
|
selectedView,
|
||||||
setSelectedView,
|
setSelectedView,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
|
textAlign,
|
||||||
|
onTextAlignChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = useStyles2((theme) => getStyles(theme, app));
|
const styles = useStyles2((theme) => getStyles(theme, app));
|
||||||
|
function interaction(name: string, context: Record<string, string | number>) {
|
||||||
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
|
reportInteraction(`grafana_flamegraph_${name}`, {
|
||||||
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
|
app,
|
||||||
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
|
grafana_version: config.buildInfo.version,
|
||||||
];
|
...context,
|
||||||
|
|
||||||
if (containerWidth >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH) {
|
|
||||||
viewOptions.push({
|
|
||||||
value: SelectedView.Both,
|
|
||||||
label: 'Both',
|
|
||||||
description: 'Show both the top table and flame graph',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,15 +81,23 @@ const FlameGraphHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.rightContainer}>
|
<div className={styles.rightContainer}>
|
||||||
|
<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>
|
<RadioButtonGroup<SelectedView>
|
||||||
options={viewOptions}
|
size="sm"
|
||||||
|
options={getViewOptions(containerWidth)}
|
||||||
value={selectedView}
|
value={selectedView}
|
||||||
onChange={(view) => {
|
onChange={(view) => {
|
||||||
reportInteraction('grafana_flamegraph_view_selected', {
|
interaction('view_selected', { view });
|
||||||
app,
|
|
||||||
grafana_version: config.buildInfo.version,
|
|
||||||
view,
|
|
||||||
});
|
|
||||||
setSelectedView(view);
|
setSelectedView(view);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -100,6 +106,28 @@ const FlameGraphHeader = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(
|
function useSearchInput(
|
||||||
search: string,
|
search: string,
|
||||||
setSearch: (search: string) => void
|
setSearch: (search: string) => void
|
||||||
@ -133,9 +161,14 @@ const getStyles = (theme: GrafanaTheme2, app: CoreApp) => ({
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: ${theme.colors.background.primary};
|
background: ${theme.colors.background.primary};
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 50px;
|
|
||||||
z-index: ${theme.zIndex.navbarFixed};
|
z-index: ${theme.zIndex.navbarFixed};
|
||||||
${app === CoreApp.Explore ? 'position: sticky; margin-bottom: 8px; padding-top: 9px' : ''};
|
${app === CoreApp.Explore
|
||||||
|
? css`
|
||||||
|
position: sticky;
|
||||||
|
padding-bottom: ${theme.spacing(1)};
|
||||||
|
padding-top: ${theme.spacing(1)};
|
||||||
|
`
|
||||||
|
: ''};
|
||||||
`,
|
`,
|
||||||
inputContainer: css`
|
inputContainer: css`
|
||||||
float: left;
|
float: left;
|
||||||
@ -147,6 +180,9 @@ const getStyles = (theme: GrafanaTheme2, app: CoreApp) => ({
|
|||||||
rightContainer: css`
|
rightContainer: css`
|
||||||
float: right;
|
float: right;
|
||||||
`,
|
`,
|
||||||
|
buttonSpacing: css`
|
||||||
|
margin-right: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default FlameGraphHeader;
|
export default FlameGraphHeader;
|
||||||
|
@ -44,3 +44,5 @@ export type TopTableValue = {
|
|||||||
value: number;
|
value: number;
|
||||||
unitValue: string;
|
unitValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TextAlign = 'left' | 'right';
|
||||||
|
Loading…
Reference in New Issue
Block a user