diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx index 93822458ac7..971c55e4101 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx @@ -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', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index efb36f26948..4e40044bab6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -38,9 +38,6 @@ const getStyles = (theme: GrafanaTheme2) => { color: 'inherit', display: 'flex', alignItems: 'center', - '&:hover': { - background: autoColor(theme, '#e8e8e8'), - }, }), AccordianLogsContent: css({ label: 'AccordianLogsContent', diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx index 10c31897ec6..9c80e9855b6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx @@ -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)', }, }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx index a6c79b2d2cf..58dec09f302 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx @@ -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', }), diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx index cca6e4f3562..f33691e0cd8 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/ShareSpanButton.tsx @@ -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 ( {/* 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) { } }} > - diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx index 8c0f82337f4..528c4ea7944 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.test.tsx @@ -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'); }); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx index 605280c292a..45828f7c247 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanDetailLinkButtons.tsx @@ -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 ; - } else { - return ( + content = ( <> - {links.map(({ linkModel, icon, className }, index) => ( - + + {shareButton} + + ); + } else if (links.length > 0) { + content = ( + <> + {links.map((spanLinkModel, index) => ( + ))} + {shareButton} ); } } - return <>; + if (!content) { + return <>; + } + + return ( + + {content} + + ); +}; + +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 ( + + + + ); }; const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { - const [isOpen, setIsOpen] = React.useState(false); + const [_, setIsOpen] = React.useState(false); + const styles = useStyles2(getResponsibleButtonStyles); const menu = ( @@ -144,14 +197,15 @@ const DropDownMenu = ({ links }: { links: SpanLinkModel[] }) => { return ( - Links - + ); }; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx index 85a15233995..238002e7975 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx @@ -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(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: , + }); + + const listOfContentCards = []; + + listOfContentCards.push( + tagsToggle(spanID)} + /> + ); + + if (process.tags) { + listOfContentCards.push( + processToggle(spanID)} + /> + ); + } + + if (logs && logs.length > 0) { + listOfContentCards.push( + logsToggle(spanID)} + onItemToggle={(logItem) => logItemToggle(spanID, logItem)} + timestamp={traceStartTime} + /> + ); + } + + if (warnings && warnings.length > 0) { + listOfContentCards.push( + ({ + 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( + ({ + 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( + referencesToggle(spanID)} + onItemToggle={(reference) => referenceItemToggle(spanID, reference)} + createFocusSpanLink={createFocusSpanLink} + /> + ); + } + + if (span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey)) { + listOfContentCards.push( + + ); + } + return ( -
+
-
- {operationName} -
+
+
+ {operationName} +
+ {linksComponent} +
-
-
{linksComponent}
-
- tagsToggle(spanID)} - /> - {process.tags && ( - processToggle(spanID)} - /> - )} -
- {logs && logs.length > 0 && ( - logsToggle(spanID)} - onItemToggle={(logItem) => logItemToggle(spanID, logItem)} - timestamp={traceStartTime} - /> - )} - - {warnings && warnings.length > 0 && ( - ({ - 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 ? ( - ({ - 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') && ( - referencesToggle(spanID)} - onItemToggle={(reference) => referenceItemToggle(spanID, reference)} - createFocusSpanLink={createFocusSpanLink} - /> - )} - {span.tags.some((tag) => tag.key === pyroscopeProfileIdTagKey) && ( - - )} + {/* 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; +}) => { + const styles = useStyles2(getStyles); + + const useTwoColumns = + mainContainerRef && mainContainerRef.current && mainContainerRef.current.getBoundingClientRect().width > 1000; + + if (useTwoColumns) { + return ( + <> +
+ {listOfContentCards.map((card, index) => + index % 2 === 0 ? ( +
+ {card} +
+ ) : null + )} +
+ +
+ {listOfContentCards.map((card, index) => + index % 2 === 1 ? ( +
+ {card} +
+ ) : null + )} +
+ + ); + } + + return ( +
+ {listOfContentCards.map((card, index) => ( +
+ {card} +
+ ))} +
+ ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a17404a80e6..2707b681ae4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",