Span Details: Two-column view (#112856)

* Span Details: Two-column view

Fixes #108465

* Use different flow

* Remove redundant comment

* Fix resizing and background color

* Clean up styles

* Fix tests

* Clean up

* Update types

* Revert i18n key changes

* Clean up i18n keys
This commit is contained in:
Piotr Jamróz
2025-11-13 13:59:18 +01:00
committed by GitHub
parent a2150b0b79
commit 3e4933ec60
9 changed files with 310 additions and 146 deletions
@@ -39,9 +39,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
padding: '0.25em 0.1em',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:hover': {
background: autoColor(theme, '#e8e8e8'),
},
}),
headerLabel: css({
width: '120px',
@@ -38,9 +38,6 @@ const getStyles = (theme: GrafanaTheme2) => {
color: 'inherit',
display: 'flex',
alignItems: 'center',
'&:hover': {
background: autoColor(theme, '#e8e8e8'),
},
}),
AccordianLogsContent: css({
label: 'AccordianLogsContent',
@@ -37,20 +37,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
AccordianReferences: css({
label: 'AccordianReferences',
position: 'relative',
marginBottom: '0.25rem',
}),
AccordianReferencesHeader: css({
label: 'AccordianReferencesHeader',
color: 'inherit',
display: 'block',
padding: '0.25rem 0',
'&:hover': {
background: autoColor(theme, '#dadada'),
},
}),
AccordianReferencesContent: css({
label: 'AccordianReferencesContent',
background: autoColor(theme, '#f0f0f0'),
borderTop: `1px solid ${autoColor(theme, '#d8d8d8')}`,
padding: '0.5rem 0.5rem 0.25rem 0.5rem',
}),
@@ -96,7 +91,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
debugLabel: css({
margin: '0 5px 0 5px',
'&::before': {
color: '#bbb',
color: autoColor(theme, '#666'),
content: 'attr(data-label)',
},
}),
@@ -32,8 +32,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
KeyValueTable: css({
label: 'KeyValueTable',
background: autoColor(theme, '#fff'),
border: `1px solid ${autoColor(theme, '#ddd')}`,
marginBottom: '0.5rem',
maxHeight: '450px',
overflow: 'auto',
}),
@@ -1,14 +1,29 @@
import { LinkModel } from '@grafana/data';
import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Button } from '@grafana/ui';
import { Button, useStyles2 } from '@grafana/ui';
type Props = {
focusSpanLink: LinkModel;
};
function getStyles(theme: GrafanaTheme2) {
return {
shareButton: css({
[theme.breakpoints.down('sm')]: {
span: {
display: 'none',
},
},
}),
};
}
export function ShareSpanButton(props: Props) {
const { focusSpanLink } = props;
const { interpolatedParams, ...linkProps } = focusSpanLink ?? {};
const styles = useStyles2(getStyles);
return (
<span>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
@@ -29,7 +44,7 @@ export function ShareSpanButton(props: Props) {
}
}}
>
<Button variant="secondary" size="sm" icon="share-alt" fill="outline">
<Button variant="secondary" size="sm" icon="share-alt" fill="outline" className={styles.shareButton}>
<Trans i18nKey="explore.span-detail.share-span">Share</Trans>
</Button>
</a>
@@ -1,3 +1,5 @@
import React from 'react';
import { CoreApp, TimeRange } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
@@ -25,6 +27,10 @@ const timeRange = {
to: new Date(1000),
} as unknown as TimeRange;
function getContent(result: React.ReactElement) {
return result.props.children.props.children[0];
}
describe('getSpanDetailLinkButtons', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -55,8 +61,9 @@ describe('getSpanDetailLinkButtons', () => {
app: CoreApp.Explore,
});
expect(result.props.children).toHaveLength(1);
expect(result.props.children[0].props.link.title).toBe('Logs for this span');
const content = getContent(result);
expect(content).toHaveLength(1);
expect(content[0].props.spanLinkModel.linkModel.title).toBe('Logs for this span');
});
it('should create profile link button when profiles link exists', () => {
@@ -75,8 +82,9 @@ describe('getSpanDetailLinkButtons', () => {
app: CoreApp.Dashboard,
});
expect(result.props.children).toHaveLength(1);
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
const content = getContent(result);
expect(content).toHaveLength(1);
expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span');
});
it('should create session link button when session link exists', () => {
@@ -91,8 +99,9 @@ describe('getSpanDetailLinkButtons', () => {
app: CoreApp.Explore,
});
expect(result.props.children).toHaveLength(1);
expect(result.props.children[0].props.link.title).toBe('Session for this span');
const content = getContent(result);
expect(content).toHaveLength(1);
expect(content[0].props.spanLinkModel.linkModel.title).toBe('Session for this span');
});
it('should create profile drilldown button when plugin link exists', () => {
@@ -121,9 +130,10 @@ describe('getSpanDetailLinkButtons', () => {
app: CoreApp.Explore,
});
expect(result.props.children).toHaveLength(2);
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
expect(result.props.children[1].props.link.title).toBe('Open in Profiles Drilldown');
const content = getContent(result);
expect(content).toHaveLength(2);
expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span');
expect(content[1].props.spanLinkModel.linkModel.title).toBe('Open in Profiles Drilldown');
});
it('should not create profile drilldown button when not in Explore', () => {
@@ -152,8 +162,9 @@ describe('getSpanDetailLinkButtons', () => {
app: CoreApp.Dashboard,
});
expect(result.props.children).toHaveLength(1);
expect(result.props.children[0].props.link.title).toBe('Profiles for this span');
const content = getContent(result);
expect(content).toHaveLength(1);
expect(content[0].props.spanLinkModel.linkModel.title).toBe('Profiles for this span');
});
});
@@ -1,11 +1,20 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { CoreApp, IconName, LinkModel, PluginExtensionPoints, RawTimeRange, TimeRange } from '@grafana/data';
import {
CoreApp,
GrafanaTheme2,
IconName,
LinkModel,
PluginExtensionPoints,
RawTimeRange,
TimeRange,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { TraceToProfilesOptions } from '@grafana/o11y-ds-frontend';
import { config, locationService, reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { DataLinkButton, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
import { Button, DataLinkButton, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
@@ -28,6 +37,7 @@ export type Props = {
timeRange: TimeRange;
createSpanLink?: SpanLinkFunc;
app: CoreApp;
shareButton?: React.ReactNode;
};
/**
@@ -51,9 +61,10 @@ const MAX_LINKS = 3;
const ABSOLUTE_LINK_PATTERN = /^https?:\/\//i;
export const getSpanDetailLinkButtons = (props: Props) => {
const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app } = props;
const { span, createSpanLink, traceToProfilesOptions, timeRange, datasourceType, app, shareButton } = props;
let linkToProfiles: SpanLinkDef | undefined;
let content = shareButton ? <>{shareButton}</> : undefined;
if (createSpanLink) {
const links = (createSpanLink(span) || [])
@@ -112,23 +123,65 @@ export const getSpanDetailLinkButtons = (props: Props) => {
});
if (links.length > MAX_LINKS) {
return <DropDownMenu links={links}></DropDownMenu>;
} else {
return (
content = (
<>
{links.map(({ linkModel, icon, className }, index) => (
<DataLinkButton key={index} link={linkModel} buttonProps={{ icon, className }}></DataLinkButton>
<DropDownMenu links={links}></DropDownMenu>
{shareButton}
</>
);
} else if (links.length > 0) {
content = (
<>
{links.map((spanLinkModel, index) => (
<SingleLinkButton spanLinkModel={spanLinkModel} key={index} />
))}
{shareButton}
</>
);
}
}
return <></>;
if (!content) {
return <></>;
}
return (
<span
className={css({
display: 'flex',
width: '100%',
flexDisplay: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: '5px',
})}
>
{content}
</span>
);
};
function getResponsibleButtonStyles(theme: GrafanaTheme2) {
return css({
[theme.breakpoints.down('sm')]: {
span: { display: 'none' },
},
});
}
const SingleLinkButton: React.FC<{ spanLinkModel: SpanLinkModel }> = ({ spanLinkModel }) => {
const styles = useStyles2(getResponsibleButtonStyles);
const { linkModel, icon, className } = spanLinkModel;
return (
<span className={styles}>
<DataLinkButton link={linkModel} buttonProps={{ icon, className }}></DataLinkButton>
</span>
);
};
const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => {
const [isOpen, setIsOpen] = React.useState(false);
const [_, setIsOpen] = React.useState(false);
const styles = useStyles2(getResponsibleButtonStyles);
const menu = (
<Menu>
@@ -144,14 +197,15 @@ const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => {
return (
<Dropdown overlay={menu} placement="bottom-start" onVisibleChange={setIsOpen}>
<ToolbarButton
<Button
variant="primary"
icon="link"
isOpen={isOpen}
size="sm"
className={styles}
aria-label={t('explore.drop-down-menu.aria-label-links', 'Links')}
>
<Trans i18nKey="explore.drop-down-menu.links">Links</Trans>
</ToolbarButton>
</Button>
</Dropdown>
);
};
@@ -14,7 +14,7 @@
import { css, cx } from '@emotion/css';
import { SpanStatusCode } from '@opentelemetry/api';
import { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import {
CoreApp,
@@ -114,17 +114,32 @@ const useResourceAttributesExtensionLinks = ({
const getStyles = (theme: GrafanaTheme2) => {
return {
card: css({
':not(:empty)': {
border: '1px solid ' + theme.colors.border.weak,
'&:hover': {
border: '1px solid ' + theme.colors.border.strong,
},
},
borderRadius: theme.shape.radius.md,
margin: '6px',
padding: '5px',
}),
header: css({
label: 'SpanDetailHeader',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '0 1rem',
marginBottom: '0.25rem',
flexDirection: 'column',
}),
content: css({
label: 'SpanDetailContent',
fontSize: theme.typography.bodySmall.fontSize,
}),
listWrapper: css({
label: 'SpanDetailListWrapper',
overflow: 'hidden',
flexGrow: 1,
display: 'flex',
@@ -133,13 +148,25 @@ const getStyles = (theme: GrafanaTheme2) => {
list: css({
textAlign: 'left',
}),
spanDetailComponent: css({
label: 'SpanDetailComponent',
display: 'flex',
flexDirection: 'column', // On bigger screens display attributes below service name
}),
serviceNameAndLinks: css({
label: 'ServiceNameAndLinks',
display: 'flex',
width: '100%',
marginBottom: '16px',
}),
operationName: css({
label: 'SpanDetailOperationName',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '50%',
flexGrow: 0,
flexGrow: 1,
flexShrink: 0,
}),
AccordianWarnings: css({
@@ -152,9 +179,6 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'AccordianWarningsHeader',
background: autoColor(theme, '#fff7e6'),
padding: '0.25rem 0.5rem',
'&:hover': {
background: autoColor(theme, '#ffe7ba'),
},
}),
AccordianWarningsHeaderOpen: css({
label: 'AccordianWarningsHeaderOpen',
@@ -180,6 +204,7 @@ const getStyles = (theme: GrafanaTheme2) => {
letterSpacing: '0.25px',
margin: '0.5em 0 -0.75em',
textAlign: 'right',
clear: 'both',
}),
debugLabel: css({
label: 'debugLabel',
@@ -314,6 +339,8 @@ export default function SpanDetail(props: SpanDetailProps) {
: []),
];
const mainContainerRef = useRef<HTMLDivElement>(null);
const styles = useStyles2(getStyles);
if (span.kind) {
overviewItems.push({
@@ -358,15 +385,6 @@ export default function SpanDetail(props: SpanDetailProps) {
});
}
const linksComponent = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType,
traceToProfilesOptions,
timeRange,
app,
});
const { interpolatedParams, ...focusSpanLink } = createFocusSpanLink(traceID, spanID);
const resourceLinksGetter = useResourceAttributesExtensionLinks({
process,
@@ -376,102 +394,132 @@ export default function SpanDetail(props: SpanDetailProps) {
timeRange,
});
const linksComponent = getSpanDetailLinkButtons({
span,
createSpanLink,
datasourceType,
traceToProfilesOptions,
timeRange,
app,
shareButton: <ShareSpanButton focusSpanLink={focusSpanLink} />,
});
const listOfContentCards = [];
listOfContentCards.push(
<AccordianKeyValues
data={tags}
label={t('explore.span-detail.label-span-attributes', 'Span attributes')}
isOpen={isTagsOpen}
linksGetter={resourceLinksGetter}
onToggle={() => tagsToggle(spanID)}
/>
);
if (process.tags) {
listOfContentCards.push(
<AccordianKeyValues
data={process.tags}
label={t('explore.span-detail.label-resource-attributes', 'Resource attributes')}
linksGetter={resourceLinksGetter}
isOpen={isProcessOpen}
onToggle={() => processToggle(spanID)}
/>
);
}
if (logs && logs.length > 0) {
listOfContentCards.push(
<AccordianLogs
logs={logs}
isOpen={logsState.isOpen}
openedItems={logsState.openedItems}
onToggle={() => logsToggle(spanID)}
onItemToggle={(logItem) => logItemToggle(spanID, logItem)}
timestamp={traceStartTime}
/>
);
}
if (warnings && warnings.length > 0) {
listOfContentCards.push(
<AccordianKeyValues
data={warnings.map((warning) => ({
key: '',
value: warning,
type: 'warning',
}))}
onlyValues={true}
showSummary={false}
showCountBadge={true}
isOpen={isWarningsOpen}
onToggle={() => warningsToggle(spanID)}
label={t('explore.span-detail.label-warnings', 'Warnings')}
/>
);
}
if (stackTraces?.length) {
listOfContentCards.push(
<AccordianKeyValues
data={stackTraces.map((stackTrace) => ({
key: '',
value: stackTrace,
type: 'code',
}))}
onlyValues={true}
showSummary={false}
showCountBadge={true}
isOpen={isStackTracesOpen}
onToggle={() => stackTracesToggle(spanID)}
label={t('explore.span-detail.label-stack-trace', 'Stack trace')}
/>
);
}
if (references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF')) {
listOfContentCards.push(
<AccordianReferences
data={references}
isOpen={referencesState.isOpen}
openedItems={referencesState.openedItems}
onToggle={() => referencesToggle(spanID)}
onItemToggle={(reference) => referenceItemToggle(spanID, reference)}
createFocusSpanLink={createFocusSpanLink}
/>
);
}
if (span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey)) {
listOfContentCards.push(
<SpanFlameGraph
span={span}
timeZone={timeZone}
traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs}
traceToProfilesOptions={traceToProfilesOptions}
setRedrawListView={setRedrawListView}
traceDuration={traceDuration}
traceName={traceName}
/>
);
}
return (
<div data-testid="span-detail-component">
<div data-testid="span-detail-component" ref={mainContainerRef} className={styles.spanDetailComponent}>
<div className={styles.header}>
<h6 className={styles.operationName} title={operationName}>
{operationName}
</h6>
<div className={styles.serviceNameAndLinks}>
<h6 className={styles.operationName} title={operationName}>
{operationName}
</h6>
{linksComponent}
</div>
<div className={styles.listWrapper}>
<LabeledList className={styles.list} divider={false} items={overviewItems} color={color} />
</div>
<ShareSpanButton focusSpanLink={focusSpanLink} />
</div>
<div className={styles.linkList}>{linksComponent}</div>
<div className={styles.content}>
<div>
<AccordianKeyValues
data={tags}
label={t('explore.span-detail.label-span-attributes', 'Span attributes')}
isOpen={isTagsOpen}
linksGetter={resourceLinksGetter}
onToggle={() => tagsToggle(spanID)}
/>
{process.tags && (
<AccordianKeyValues
data={process.tags}
label={t('explore.span-detail.label-resource-attributes', 'Resource attributes')}
linksGetter={resourceLinksGetter}
isOpen={isProcessOpen}
onToggle={() => processToggle(spanID)}
/>
)}
</div>
{logs && logs.length > 0 && (
<AccordianLogs
logs={logs}
isOpen={logsState.isOpen}
openedItems={logsState.openedItems}
onToggle={() => logsToggle(spanID)}
onItemToggle={(logItem) => logItemToggle(spanID, logItem)}
timestamp={traceStartTime}
/>
)}
{warnings && warnings.length > 0 && (
<AccordianKeyValues
data={warnings.map((warning) => ({
key: '',
value: warning,
type: 'text',
}))}
showSummary={false}
showCountBadge={true}
isOpen={isWarningsOpen}
onlyValues={true}
onToggle={() => warningsToggle(spanID)}
label={t('explore.span-detail.warnings', 'Warnings')}
/>
)}
{stackTraces?.length ? (
<AccordianKeyValues
data={stackTraces.map((stackTrace) => ({
key: '',
value: stackTrace,
type: 'code',
}))}
onlyValues={true}
showSummary={false}
showCountBadge={true}
isOpen={isStackTracesOpen}
onToggle={() => stackTracesToggle(spanID)}
label={t('explore.span-detail.label-stack-trace', 'Stack trace')}
/>
) : null}
{references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF') && (
<AccordianReferences
data={references}
isOpen={referencesState.isOpen}
openedItems={referencesState.openedItems}
onToggle={() => referencesToggle(spanID)}
onItemToggle={(reference) => referenceItemToggle(spanID, reference)}
createFocusSpanLink={createFocusSpanLink}
/>
)}
{span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && (
<SpanFlameGraph
span={span}
timeZone={timeZone}
traceFlameGraphs={traceFlameGraphs}
setTraceFlameGraphs={setTraceFlameGraphs}
traceToProfilesOptions={traceToProfilesOptions}
setRedrawListView={setRedrawListView}
traceDuration={traceDuration}
traceName={traceName}
/>
)}
<CardsContainer listOfContentCards={listOfContentCards} mainContainerRef={mainContainerRef} />
<small className={styles.debugInfo}>
{/* TODO: fix keyboard a11y */}
@@ -507,3 +555,52 @@ export const getAbsoluteTime = (startTime: number, timeZone: TimeZone) => {
const absoluteTime = match[1] ? match[1] : dateStr;
return ` (${absoluteTime})`;
};
const CardsContainer = ({
listOfContentCards,
mainContainerRef,
}: {
listOfContentCards: React.ReactNode[];
mainContainerRef?: React.RefObject<HTMLDivElement>;
}) => {
const styles = useStyles2(getStyles);
const useTwoColumns =
mainContainerRef && mainContainerRef.current && mainContainerRef.current.getBoundingClientRect().width > 1000;
if (useTwoColumns) {
return (
<>
<div className={css({ float: 'left', width: '50%' })}>
{listOfContentCards.map((card, index) =>
index % 2 === 0 ? (
<div className={styles.card} key={index}>
{card}
</div>
) : null
)}
</div>
<div className={css({ float: 'right', width: '50%' })}>
{listOfContentCards.map((card, index) =>
index % 2 === 1 ? (
<div className={styles.card} key={index}>
{card}
</div>
) : null
)}
</div>
</>
);
}
return (
<div className={css({ clear: 'both', width: '100%' })}>
{listOfContentCards.map((card, index) => (
<div className={styles.card} key={index}>
{card}
</div>
))}
</div>
);
};
+2 -2
View File
@@ -7465,6 +7465,7 @@
"label-resource-attributes": "Resource attributes",
"label-span-attributes": "Span attributes",
"label-stack-trace": "Stack trace",
"label-warnings": "Warnings",
"overview-items": {
"label": {
"child-count": "Child Count:",
@@ -7473,8 +7474,7 @@
"start-time": "Start Time:"
}
},
"share-span": "Share",
"warnings": "Warnings"
"share-span": "Share"
},
"span-filters": {
"aria-label-select-max-span-operator": "Select max span operator",