From 55bba165b1f46e5f33fc136906ecc81fd065cca1 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 25 May 2023 11:08:03 +0200 Subject: [PATCH] FlameGraph: Add option to align text left or right (#68893) --- packages/grafana-data/src/types/icon.ts | 2 + .../components/FlameGraph/FlameGraph.test.tsx | 1 + .../components/FlameGraph/FlameGraph.tsx | 19 ++++- .../components/FlameGraph/rendering.ts | 44 ++++++++--- .../components/FlameGraphContainer.tsx | 9 ++- .../components/FlameGraphHeader.test.tsx | 2 + .../components/FlameGraphHeader.tsx | 78 ++++++++++++++----- .../panel/flamegraph/components/types.ts | 2 + 8 files changed, 121 insertions(+), 36 deletions(-) diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 9c978f586fd..fa51db78428 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -18,6 +18,8 @@ export const availableIconsIndex = { 'angle-left': true, 'angle-right': true, 'angle-up': true, + 'align-left': true, + 'align-right': true, apps: true, arrow: true, 'arrow-down': true, diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx index 6641bf30c4c..b90b4353919 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx @@ -46,6 +46,7 @@ describe('FlameGraph', () => { setRangeMin={setRangeMin} setRangeMax={setRangeMax} selectedView={selectedView} + textAlign={'left'} /> ); }; diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx index 61da4958353..c2c9f5d4331 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx @@ -25,7 +25,7 @@ import { CoreApp } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { PIXELS_PER_LEVEL } from '../../constants'; -import { SelectedView, ContextMenuData } from '../types'; +import { SelectedView, ContextMenuData, TextAlign } from '../types'; import FlameGraphContextMenu from './FlameGraphContextMenu'; import FlameGraphMetadata from './FlameGraphMetadata'; @@ -48,6 +48,7 @@ type Props = { setRangeMax: (range: number) => void; selectedView: SelectedView; style?: React.CSSProperties; + textAlign: TextAlign; }; const FlameGraph = ({ @@ -64,6 +65,7 @@ const FlameGraph = ({ setRangeMin, setRangeMax, selectedView, + textAlign, }: Props) => { const styles = useStyles2(getStyles); const totalTicks = data.getValue(0); @@ -119,10 +121,21 @@ const FlameGraph = ({ const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick); for (const rect of 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(() => { if (graphRef.current) { diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.ts b/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.ts index 32793cc0f01..62cbf2e6dd2 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.ts +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/rendering.ts @@ -8,6 +8,7 @@ import { LABEL_THRESHOLD, PIXELS_PER_LEVEL, } from '../../constants'; +import { TextAlign } from '../types'; import { FlameGraphDataContainer, LevelItem } from './dataTransform'; @@ -80,7 +81,8 @@ export function renderRect( query: string, levelIndex: number, topLevelIndex: number, - foundNames: Set + foundNames: Set, + textAlign: TextAlign ) { if (rect.width < HIDE_THRESHOLD) { return; @@ -110,18 +112,40 @@ export function renderRect( ctx.fill(); if (!rect.collapsed && rect.width >= LABEL_THRESHOLD) { - ctx.save(); - ctx.clip(); // so text does not overflow - ctx.fillStyle = '#222'; - ctx.fillText( - `${name} (${rect.unitLabel})`, - Math.max(rect.x, 0) + BAR_TEXT_PADDING_LEFT, - rect.y + PIXELS_PER_LEVEL / 2 - ); - ctx.restore(); + 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. diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx index ae3724c4f0b..04538da8b23 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx @@ -11,7 +11,7 @@ import FlameGraph from './FlameGraph/FlameGraph'; import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './FlameGraph/dataTransform'; import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; -import { SelectedView } from './types'; +import { SelectedView, TextAlign } from './types'; type Props = { data?: DataFrame; @@ -26,6 +26,8 @@ const FlameGraphContainer = (props: Props) => { const [search, setSearch] = useState(''); const [selectedView, setSelectedView] = useState(SelectedView.Both); const [sizeRef, { width: containerWidth }] = useMeasure(); + const [textAlign, setTextAlign] = useState('left'); + const theme = useTheme2(); const [dataContainer, levels] = useMemo((): [FlameGraphDataContainer, LevelItem[][]] | [undefined, undefined] => { @@ -75,6 +77,8 @@ const FlameGraphContainer = (props: Props) => { selectedView={selectedView} setSelectedView={setSelectedView} containerWidth={containerWidth} + textAlign={textAlign} + onTextAlignChange={setTextAlign} />
@@ -108,6 +112,7 @@ const FlameGraphContainer = (props: Props) => { setRangeMin={setRangeMin} setRangeMax={setRangeMax} selectedView={selectedView} + textAlign={textAlign} /> )}
@@ -125,7 +130,7 @@ function getStyles(theme: GrafanaTheme2) { flex: '1 1 0', flexDirection: 'column', minHeight: 0, - gap: theme.spacing(2), + gap: theme.spacing(1), }), body: css({ display: 'flex', diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.test.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.test.tsx index b124a930a6f..2fd327ef0d4 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.test.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.test.tsx @@ -25,6 +25,8 @@ describe('FlameGraphHeader', () => { selectedView={selectedView} setSelectedView={setSelectedView} containerWidth={1600} + onTextAlignChange={jest.fn()} + textAlign={'left'} /> ); }; diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx index 00a90f1a1af..ad2fc2dc23b 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx @@ -3,14 +3,14 @@ import React, { useEffect, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; 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 { 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 } from './types'; +import { SelectedView, TextAlign } from './types'; type Props = { app: CoreApp; @@ -23,6 +23,8 @@ type Props = { selectedView: SelectedView; setSelectedView: (view: SelectedView) => void; containerWidth: number; + textAlign: TextAlign; + onTextAlignChange: (align: TextAlign) => void; }; const FlameGraphHeader = ({ @@ -36,19 +38,15 @@ const FlameGraphHeader = ({ selectedView, setSelectedView, containerWidth, + textAlign, + onTextAlignChange, }: Props) => { const styles = useStyles2((theme) => getStyles(theme, app)); - - 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 (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', + function interaction(name: string, context: Record) { + reportInteraction(`grafana_flamegraph_${name}`, { + app, + grafana_version: config.buildInfo.version, + ...context, }); } @@ -83,15 +81,23 @@ const FlameGraphHeader = ({
+ + size="sm" + disabled={selectedView === SelectedView.TopTable} + options={alignOptions} + value={textAlign} + onChange={(val) => { + interaction('text_align_selected', { align: val }); + onTextAlignChange(val); + }} + className={styles.buttonSpacing} + /> - options={viewOptions} + size="sm" + options={getViewOptions(containerWidth)} value={selectedView} onChange={(view) => { - reportInteraction('grafana_flamegraph_view_selected', { - app, - grafana_version: config.buildInfo.version, - view, - }); + interaction('view_selected', { view }); setSelectedView(view); }} /> @@ -100,6 +106,28 @@ const FlameGraphHeader = ({ ); }; +const alignOptions: Array> = [ + { value: 'left', description: 'Align text left', icon: 'align-left' }, + { value: 'right', description: 'Align text right', icon: 'align-right' }, +]; + +function getViewOptions(width: number): Array> { + let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [ + { value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' }, + { value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' }, + ]; + + if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH) { + 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 @@ -133,9 +161,14 @@ const getStyles = (theme: GrafanaTheme2, app: CoreApp) => ({ width: 100%; background: ${theme.colors.background.primary}; top: 0; - height: 50px; 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` float: left; @@ -147,6 +180,9 @@ const getStyles = (theme: GrafanaTheme2, app: CoreApp) => ({ rightContainer: css` float: right; `, + buttonSpacing: css` + margin-right: ${theme.spacing(1)}; + `, }); export default FlameGraphHeader; diff --git a/public/app/plugins/panel/flamegraph/components/types.ts b/public/app/plugins/panel/flamegraph/components/types.ts index a84b66b2fac..b13c0bc5c83 100644 --- a/public/app/plugins/panel/flamegraph/components/types.ts +++ b/public/app/plugins/panel/flamegraph/components/types.ts @@ -44,3 +44,5 @@ export type TopTableValue = { value: number; unitValue: string; }; + +export type TextAlign = 'left' | 'right';