Trace View: Header redesign (#108473)

* Improve trace view header design

* Added plugin extension for more actions

* Translations

* Tweaks based on feedback

* lint

* Limit to 2 links per plugin

* Add datasource to context so LLM has less to figure out

* Fix conflict :)

* Remove unused file

* i18n extract

* Updated tests

* lint

* Re-add the feedback link

* i18n-extract

* fix tests

---------

Co-authored-by: Joey <joey.tawadrous@grafana.com>
This commit is contained in:
Andre Pereira
2025-07-30 09:20:39 +00:00
committed by GitHub
co-authored by Joey
parent e10063b0c7
commit d5bcc606b0
14 changed files with 817 additions and 307 deletions
@@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"),
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Receiver)
@@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"),
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TemplateGroup)
@@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites
var (
schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"),
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.name",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TimeInterval)
@@ -15,8 +15,6 @@ import (
v0alpha1 "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alerting/v0alpha1"
)
var ()
var appManifestData = app.ManifestData{
AppName: "alerting",
Group: "notifications.alerting.grafana.app",
@@ -18,8 +18,6 @@ import (
v2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2"
)
var ()
var appManifestData = app.ManifestData{
AppName: "dashboard",
Group: "dashboard.grafana.app",
-2
View File
@@ -15,8 +15,6 @@ import (
v1beta1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
)
var ()
var appManifestData = app.ManifestData{
AppName: "folder",
Group: "folder.grafana.app",
-2
View File
@@ -15,8 +15,6 @@ import (
v1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
)
var ()
var appManifestData = app.ManifestData{
AppName: "secret",
Group: "secret.grafana.app",
@@ -197,6 +197,7 @@ export enum PluginExtensionPoints {
ExploreToolbarAction = 'grafana/explore/toolbar/action',
UserProfileTab = 'grafana/user/profile/tab',
TraceViewDetails = 'grafana/traceview/details',
TraceViewHeaderActions = 'grafana/traceview/header/actions',
QueryEditorRowAdaptiveTelemetryV1 = 'grafana/query-editor-row/adaptivetelemetry/v1',
TraceViewResourceAttributes = 'grafana/traceview/resource-attributes',
LogsViewResourceAttributes = 'grafana/logsview/resource-attributes',
@@ -167,6 +167,7 @@ export function TraceView(props: Props) {
);
const timeZone = useSelector((state) => getTimeZone(state.user));
const datasourceType = datasource ? datasource?.type : 'unknown';
const datasourceName = datasource ? datasource?.name : 'unknown';
const datasourceUid = datasource ? datasource?.uid : '';
const scrollElement = props.scrollElement
? props.scrollElement
@@ -189,6 +190,8 @@ export function TraceView(props: Props) {
setFocusedSpanIdForSearch={setFocusedSpanIdForSearch}
spanFilterMatches={spanFilterMatches}
datasourceType={datasourceType}
datasourceName={datasourceName}
datasourceUid={datasourceUid}
setHeaderHeight={setHeaderHeight}
app={exploreId ? CoreApp.Explore : CoreApp.Unknown}
/>
@@ -1,107 +0,0 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2, CoreApp, DataFrame } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Icon, useTheme2 } from '@grafana/ui';
import { config } from '../../../../../../core/config';
import { downloadTraceAsJson } from '../../../../../inspector/utils/download';
import ActionButton from './ActionButton';
export const getStyles = (theme: GrafanaTheme2) => {
return {
TracePageActions: css({
label: 'TracePageActions',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
marginBottom: '10px',
}),
feedbackContainer: css({
color: theme.colors.text.link,
}),
feedback: css({
margin: '6px',
color: theme.colors.text.link,
fontSize: theme.typography.bodySmall.fontSize,
'&:hover': {
textDecoration: 'underline',
},
}),
};
};
export type TracePageActionsProps = {
traceId: string;
data: DataFrame;
app?: CoreApp;
};
export default function TracePageActions(props: TracePageActionsProps) {
const { traceId, data, app } = props;
const theme = useTheme2();
const styles = getStyles(theme);
const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false);
const copyTraceId = () => {
navigator.clipboard.writeText(traceId);
setCopyTraceIdClicked(true);
setTimeout(() => {
setCopyTraceIdClicked(false);
}, 5000);
};
const exportTrace = () => {
const traceFormat = downloadTraceAsJson(data, 'Trace-' + traceId.substring(traceId.length - 6));
reportInteraction('grafana_traces_download_traces_clicked', {
app,
grafana_version: config.buildInfo.version,
trace_format: traceFormat,
location: 'trace-view',
});
};
return (
<div className={styles.TracePageActions}>
{config.feedbackLinksEnabled && (
<div className={styles.feedbackContainer}>
<Icon name="comment-alt-message" />
<a
href="https://forms.gle/RZDEx8ScyZNguDoC8"
className={styles.feedback}
title={t(
'explore.trace-page-actions.title-share-thoughts-about-tracing-grafana',
'Share your thoughts about tracing in Grafana.'
)}
target="_blank"
rel="noreferrer noopener"
>
<Trans i18nKey="explore.trace-page-actions.give-feedback">Give feedback</Trans>
</a>
</div>
)}
<ActionButton
onClick={copyTraceId}
ariaLabel={t('explore.trace-page-actions.ariaLabel-copy-trace-id', 'Copy Trace ID')}
label={
copyTraceIdClicked
? t('explore.trace-page-actions.label-copied', 'Copied!')
: t('explore.trace-page-actions.label-trace-id', 'Trace ID')
}
icon={'copy'}
/>
<ActionButton
onClick={exportTrace}
ariaLabel={t('explore.trace-page-actions.ariaLabel-export-trace', 'Export Trace')}
label={t('explore.trace-page-actions.label-export', 'Export')}
icon={'save'}
/>
</div>
);
}
@@ -12,15 +12,86 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { getByText, render } from '@testing-library/react';
import { fireEvent, getByText, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MutableDataFrame } from '@grafana/data';
import {
IconName,
MutableDataFrame,
PluginExtensionLink,
PluginExtensionPoints,
PluginExtensionTypes,
} from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants';
import { TraceViewPluginExtensionContext } from '../types/trace';
import { TracePageHeader } from './TracePageHeader';
import { trace } from './mocks';
const setup = () => {
// Mock @grafana/runtime
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
usePluginLinks: jest.fn(),
reportInteraction: jest.fn(),
}));
// Mock useAppNotification
jest.mock('app/core/copy/appNotification', () => ({
useAppNotification: jest.fn(() => ({
success: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
})),
}));
// Mock config
jest.mock('../../../../../core/config', () => ({
config: {
feedbackLinksEnabled: false, // Default to false to avoid interference with tests
},
}));
// Mock navigator.clipboard
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
// Mock window.open
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true,
});
// Helper function to create properly typed mock plugin extension links
const createMockExtension = (
id: string,
title: string,
description = '',
options: {
icon?: string;
path?: string;
onClick?: () => void;
} = {}
): PluginExtensionLink => ({
id,
type: PluginExtensionTypes.link,
title,
description,
pluginId: 'test-plugin',
icon: options.icon as IconName,
path: options.path,
onClick: options.onClick,
});
const setup = (pluginLinks: { links: PluginExtensionLink[]; isLoading: boolean } = { links: [], isLoading: false }) => {
const mockUsePluginLinks = usePluginLinks as jest.MockedFunction<typeof usePluginLinks>;
mockUsePluginLinks.mockReturnValue(pluginLinks);
const defaultProps = {
trace,
timeZone: '',
@@ -37,12 +108,22 @@ const setup = () => {
datasourceType: 'tempo',
setHeaderHeight: jest.fn(),
data: new MutableDataFrame(),
datasourceName: 'test-datasource',
datasourceUid: 'test-datasource-uid',
};
return render(<TracePageHeader {...defaultProps} />);
return {
...render(<TracePageHeader {...defaultProps} />),
mockUsePluginLinks,
};
};
describe('TracePageHeader test', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
});
it('should render the new trace header', () => {
setup();
@@ -51,13 +132,359 @@ describe('TracePageHeader test', () => {
const status = getByText(header!, '200');
const url = getByText(header!, '/v2/gamma/792edh2w897y2huehd2h89');
const duration = getByText(header!, '2.36s');
const timestampPart1 = getByText(header!, '2023-02-05 08:50');
const timestampPart2 = getByText(header!, ':56.289');
const timestampElement = getByText(header!, '2023-02-05 08:50:56.289');
expect(method).toBeInTheDocument();
expect(status).toBeInTheDocument();
expect(url).toBeInTheDocument();
expect(duration).toBeInTheDocument();
expect(timestampPart1).toBeInTheDocument();
expect(timestampPart2).toBeInTheDocument();
expect(timestampElement).toBeInTheDocument();
});
describe('Plugin Extensions', () => {
it('should call usePluginLinks with correct parameters including datasource context', () => {
const { mockUsePluginLinks } = setup();
expect(mockUsePluginLinks).toHaveBeenCalledWith({
extensionPointId: PluginExtensionPoints.TraceViewHeaderActions,
context: {
...trace,
datasource: {
name: 'test-datasource',
uid: 'test-datasource-uid',
type: 'tempo',
},
},
limitPerPlugin: 2,
});
});
it('should not render plugin extension buttons when no extensions are available', () => {
setup({ links: [], isLoading: false });
const extensionButtons = screen.queryByTestId('plugin-extension-button');
expect(extensionButtons).not.toBeInTheDocument();
});
it('should render plugin extension buttons when extensions are available', () => {
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension 1', 'Test extension description', {
icon: 'external-link-alt',
path: 'https://example.com',
onClick: jest.fn(),
}),
createMockExtension('test-extension-2', 'Test Extension 2', 'Another test extension', {
icon: 'cloud',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
expect(screen.getByText('Test Extension 1')).toBeInTheDocument();
expect(screen.getByText('Test Extension 2')).toBeInTheDocument();
});
it('should display tooltips for extension buttons', async () => {
const user = userEvent.setup();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'This is a test extension description', {
icon: 'external-link-alt',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Test Extension');
await user.hover(button);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument();
expect(screen.getByText('This is a test extension description')).toBeInTheDocument();
});
});
it('should use title as tooltip when description is not provided', async () => {
const user = userEvent.setup();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension Title', 'Test Extension Title', {
icon: 'external-link-alt',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByRole('button', { name: /Test Extension Title/i });
await user.hover(button);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument();
expect(screen.getByRole('tooltip')).toHaveTextContent('Test Extension Title');
});
});
it('should handle extension button clicks with onClick handler', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
icon: 'external-link-alt',
onClick: mockOnClick,
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Test Extension');
await user.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
expect(mockOnClick).toHaveBeenCalledWith(expect.any(Object));
});
it('should handle extension button clicks with path navigation', async () => {
const user = userEvent.setup();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
icon: 'external-link-alt',
path: 'https://example.com/trace-details',
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Test Extension');
await user.click(button);
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
expect(mockWindowOpen).toHaveBeenCalledWith('https://example.com/trace-details', '_blank');
});
it('should handle extension with both path and onClick', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
icon: 'external-link-alt',
path: 'https://example.com/trace-details',
onClick: mockOnClick,
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Test Extension');
await user.click(button);
expect(mockWindowOpen).toHaveBeenCalledTimes(1);
expect(mockWindowOpen).toHaveBeenCalledWith('https://example.com/trace-details', '_blank');
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('should render extension buttons with correct styling', () => {
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
icon: 'external-link-alt',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByRole('button', { name: /Test Extension/i });
expect(button).toBeInTheDocument();
expect(button).toHaveClass('css-7byezq-button'); // Grafana button primary class
});
it('should render extension icons when provided', () => {
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
icon: 'external-link-alt',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByRole('button', { name: /Test Extension/i });
const iconElement = button.querySelector('svg');
expect(iconElement).toBeInTheDocument();
});
it('should handle multiple extensions correctly', () => {
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Extension 1', 'First extension', {
icon: 'external-link-alt',
onClick: jest.fn(),
}),
createMockExtension('test-extension-2', 'Extension 2', 'Second extension', {
icon: 'cloud',
path: 'https://example.com',
}),
createMockExtension('test-extension-3', 'Extension 3', 'Third extension', {
icon: 'apps',
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
expect(screen.getByText('Extension 1')).toBeInTheDocument();
expect(screen.getByText('Extension 2')).toBeInTheDocument();
expect(screen.getByText('Extension 3')).toBeInTheDocument();
});
it('should maintain extension context with trace data and datasource information', () => {
const { mockUsePluginLinks } = setup();
const [callArgs] = mockUsePluginLinks.mock.calls;
expect(callArgs[0]).toEqual({
extensionPointId: PluginExtensionPoints.TraceViewHeaderActions,
context: {
...trace,
datasource: {
name: 'test-datasource',
uid: 'test-datasource-uid',
type: 'tempo',
},
},
limitPerPlugin: 2,
});
// Verify the context contains the expected trace properties
expect(callArgs[0].context).toHaveProperty('traceID', trace.traceID);
expect(callArgs[0].context).toHaveProperty('spans');
expect(callArgs[0].context).toHaveProperty('duration', trace.duration);
expect(callArgs[0].context).toHaveProperty('startTime', trace.startTime);
// Verify the context contains the datasource information
expect(callArgs[0].context).toHaveProperty('datasource');
const contextWithDatasource = callArgs[0].context as TraceViewPluginExtensionContext;
expect(contextWithDatasource.datasource).toEqual({
name: 'test-datasource',
uid: 'test-datasource-uid',
type: 'tempo',
});
});
it('should handle loading state gracefully', () => {
setup({ links: [], isLoading: true });
// Should not crash when loading and should not show any extension buttons
const extensionButtons = screen.queryByTestId('plugin-extension-button');
expect(extensionButtons).not.toBeInTheDocument();
});
it('should handle extensions without icons', () => {
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Extension Without Icon', 'Extension without icon', {
onClick: jest.fn(),
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Extension Without Icon');
expect(button).toBeInTheDocument();
// Should render the button even without an icon
});
it('should handle extension click without event parameter', async () => {
const mockOnClick = jest.fn();
const mockExtensions: PluginExtensionLink[] = [
createMockExtension('test-extension-1', 'Test Extension', 'Test extension', {
onClick: mockOnClick,
}),
];
setup({ links: mockExtensions, isLoading: false });
const button = screen.getByText('Test Extension');
// Simulate a click that might not pass event
fireEvent.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('should provide datasource context to plugin extensions', () => {
const { mockUsePluginLinks } = setup();
const contextArg = mockUsePluginLinks.mock.calls[0][0].context as TraceViewPluginExtensionContext;
// Verify that plugin extensions receive datasource information in context
expect(contextArg.datasource).toBeDefined();
expect(contextArg.datasource.name).toBe('test-datasource');
expect(contextArg.datasource.uid).toBe('test-datasource-uid');
expect(contextArg.datasource.type).toBe('tempo');
// Verify that trace data is still available
expect(contextArg.traceID).toBe(trace.traceID);
expect(contextArg.spans).toBe(trace.spans);
});
});
describe('Feedback Button', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should not render feedback button when feedbackLinksEnabled is false', () => {
// config.feedbackLinksEnabled is already mocked to false
setup();
const feedbackButton = screen.queryByText('Feedback');
expect(feedbackButton).not.toBeInTheDocument();
});
it('should render feedback button when feedbackLinksEnabled is true', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
const feedbackButton = screen.getByText('Feedback');
expect(feedbackButton).toBeInTheDocument();
expect(feedbackButton.closest('a')).toHaveAttribute('href', 'https://forms.gle/RZDEx8ScyZNguDoC8');
expect(feedbackButton.closest('a')).toHaveAttribute('target', '_blank');
});
it('should display tooltip for feedback button', async () => {
const user = userEvent.setup();
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
const feedbackButton = screen.getByText('Feedback');
await user.hover(feedbackButton);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument();
expect(screen.getByText('Share your thoughts about tracing in Grafana.')).toBeInTheDocument();
});
});
it('should render feedback button with correct styling and icon', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
const feedbackButton = screen.getByText('Feedback');
const buttonElement = feedbackButton.closest('a');
expect(buttonElement).toBeInTheDocument();
expect(buttonElement).toHaveClass('css-125ehy6-button'); // Secondary variant class
// Check for icon
const iconElement = buttonElement?.querySelector('svg');
expect(iconElement).toBeInTheDocument();
});
});
});
@@ -12,24 +12,43 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { css } from '@emotion/css';
import cx from 'classnames';
import { memo, useEffect, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { memo, useEffect, useMemo, useState } from 'react';
import * as React from 'react';
import { TraceSearchProps, CoreApp, DataFrame, dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
import {
CoreApp,
TraceSearchProps,
DataFrame,
dateTimeFormat,
dateTimeFormatTimeAgo,
GrafanaTheme2,
PluginExtensionPoints,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction, usePluginLinks } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { Badge, BadgeColor, Tooltip, useStyles2 } from '@grafana/ui';
import {
Badge,
BadgeColor,
Button,
ButtonGroup,
Dropdown,
Icon,
LinkButton,
Menu,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import ExternalLinks from '../common/ExternalLinks';
import TraceName from '../common/TraceName';
import { getTraceLinks } from '../model/link-patterns';
import { config } from '../../../../../core/config';
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
import { Trace } from '../types/trace';
import { Trace, TraceViewPluginExtensionContext } from '../types/trace';
import { formatDuration } from '../utils/date';
import TracePageActions from './Actions/TracePageActions';
import { SpanFilters } from './SpanFilters/SpanFilters';
export type TracePageHeaderProps = {
@@ -44,6 +63,8 @@ export type TracePageHeaderProps = {
setFocusedSpanIdForSearch: React.Dispatch<React.SetStateAction<string>>;
spanFilterMatches: Set<string> | undefined;
datasourceType: string;
datasourceName: string;
datasourceUid: string;
setHeaderHeight: (height: number) => void;
};
@@ -60,47 +81,51 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
setFocusedSpanIdForSearch,
spanFilterMatches,
datasourceType,
datasourceName,
datasourceUid,
setHeaderHeight,
} = props;
const styles = useStyles2(getNewStyles);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const notifyApp = useAppNotification();
const [copyTraceIdClicked, setCopyTraceIdClicked] = useState(false);
useEffect(() => {
setHeaderHeight(document.querySelector('.' + styles.header)?.scrollHeight ?? 0);
}, [setHeaderHeight, showSpanFilters, styles.header]);
const links = useMemo(() => {
if (!trace) {
return [];
}
return getTraceLinks(trace);
}, [trace]);
if (!trace) {
return null;
}
const timestamp = (trace: Trace, timeZone: TimeZone) => {
// Convert date from micro to milli seconds
const dateStr = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true });
const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/);
return match ? (
<span className={styles.TracePageHeaderOverviewItemValue}>
{match[1]}
<span className={styles.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span>
</span>
) : (
dateStr
);
const { method, status, url } = getHeaderTags(trace.spans);
const traceName = getTraceName(trace.spans);
// Convert date from micro to milli seconds
const formattedTimestamp = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true });
// Memoize service count to avoid recomputing on every render
const serviceCount = useMemo(() => {
return new Set(trace.spans.map((span) => span.process?.serviceName)).size;
}, [trace.spans]);
// Get plugin extensions for trace view header actions
const traceContext: TraceViewPluginExtensionContext = {
...trace,
datasource: {
name: datasourceName,
uid: datasourceUid,
type: datasourceType,
},
};
const title = (
<h1 className={cx(styles.title)}>
<TraceName traceName={getTraceName(trace.spans)} />
<small className={styles.duration}>{formatDuration(trace.duration)}</small>
</h1>
);
const { links: extensionLinks } = usePluginLinks({
extensionPointId: PluginExtensionPoints.TraceViewHeaderActions,
context: traceContext,
limitPerPlugin: 2,
});
const { method, status, url } = getHeaderTags(trace.spans);
let statusColor: BadgeColor = 'green';
if (status && status.length > 0) {
if (status[0].value.toString().charAt(0) === '4') {
@@ -110,76 +135,198 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
}
}
const urlTooltip = (url: string) => {
return (
<>
<div>
<Trans
i18nKey="explore.trace-page-header.tooltip-url"
values={{
url: 'http.url',
target: 'http.target',
path: 'http.path',
}}
>
{'{{url}}'} or {'{{target}}'} or {'{{path}}'}
</Trans>
</div>
<div>({url})</div>
</>
);
const copyTraceId = () => {
navigator.clipboard.writeText(trace.traceID);
setCopyTraceIdClicked(true);
setTimeout(() => {
setCopyTraceIdClicked(false);
}, 5000);
};
const exportTrace = () => {
const traceFormat = downloadTraceAsJson(data, 'Trace-' + trace.traceID.substring(trace.traceID.length - 6));
reportInteraction('grafana_traces_download_traces_clicked', {
app,
grafana_version: config.buildInfo.version,
trace_format: traceFormat,
location: 'trace-view',
});
};
const shareDropdownMenu = (
<Menu>
<Menu.Item
label={t('explore.trace-page-header.share-copy-link', 'Copy link')}
icon="link"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
notifyApp.success(t('explore.trace-page-header.link-copied', 'Link copied to clipboard'));
}}
/>
<Menu.Item
label={t('explore.trace-page-header.share-export-json', 'Export as JSON')}
icon="download-alt"
onClick={() => {
exportTrace();
notifyApp.success(t('explore.trace-page-header.export-started', 'Export started'));
}}
/>
</Menu>
);
return (
<header className={styles.header}>
{/* Main title row */}
<div className={styles.titleRow}>
{links && links.length > 0 && <ExternalLinks links={links} className={styles.TracePageHeaderBack} />}
{title}
<TracePageActions traceId={trace.traceID} data={data} app={app} />
<div className={styles.titleSection}>
<h1 className={styles.title}>{traceName}</h1>
<div className={styles.badges}>
{method && method.length > 0 && <Badge text={method[0].value} color="blue" />}
{status && status.length > 0 && <Badge text={status[0].value} color={statusColor} />}
</div>
</div>
{/* Action buttons */}
<div className={styles.actions}>
{/* Plugin extension actions */}
{extensionLinks.length > 0 && (
<div className={styles.actions}>
{extensionLinks.map((link) => (
<Tooltip key={link.id} content={link.description || link.title}>
<Button
size="sm"
variant="primary"
fill="outline"
icon={link.icon}
onClick={(event) => {
if (link.path) {
window.open(link.path, '_blank');
}
link.onClick?.(event);
}}
>
{link.title}
</Button>
</Tooltip>
))}
</div>
)}
{config.feedbackLinksEnabled && (
<Tooltip
content={t(
'explore.trace-page-header.title-share-thoughts-about-tracing-grafana',
'Share your thoughts about tracing in Grafana.'
)}
>
<LinkButton
size="sm"
variant="secondary"
fill="outline"
icon="comment-alt-message"
href="https://forms.gle/RZDEx8ScyZNguDoC8"
target="_blank"
>
<Trans i18nKey="explore.trace-page-header.give-feedback">Feedback</Trans>
</LinkButton>
</Tooltip>
)}
<ButtonGroup>
<Tooltip content={t('explore.trace-page-header.share-tooltip', 'Share trace')}>
<Button
size="sm"
variant="secondary"
fill="outline"
icon="share-alt"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
notifyApp.success(t('explore.trace-page-header.link-copied', 'Link copied to clipboard'));
}}
>
{t('explore.trace-page-header.share', 'Share')}
</Button>
</Tooltip>
<Dropdown overlay={shareDropdownMenu} placement="bottom-end">
<Button size="sm" variant="secondary" fill="outline" icon="angle-down" />
</Dropdown>
</ButtonGroup>
</div>
</div>
<div className={styles.subtitle}>
<span className={styles.timestamp}>{timestamp(trace, timeZone)}</span>
<span className={styles.tagMeta}>
{data.meta?.custom?.partial && (
<Tooltip content={data.meta?.custom?.message} interactive={true}>
<span className={styles.tag}>
<Badge
icon={'info-circle'}
text={t('explore.trace-page-header.text-partial-trace', 'Partial trace')}
color={'orange'}
/>
</span>
</Tooltip>
)}
{method && method.length > 0 && (
<Tooltip
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
content="http.method"
interactive={true}
>
<span className={styles.tag}>
<Badge text={method[0].value} color="blue" />
</span>
</Tooltip>
)}
{status && status.length > 0 && (
<Tooltip
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
content="http.status_code"
interactive={true}
>
<span className={styles.tag}>
<Badge text={status[0].value} color={statusColor} />
</span>
</Tooltip>
)}
{url && url.length > 0 && (
<Tooltip content={urlTooltip(url[0].value)} interactive={true}>
<span className={styles.url}>{url[0].value}</span>
</Tooltip>
)}
</span>
{/* Metadata row */}
<div className={styles.metadataRow}>
<div className={styles.metadataItem}>
<span className={styles.metadataLabel}>{t('explore.trace-page-header.trace-id', 'Trace ID')}</span>
<span className={styles.metadataValue}>
<button className={styles.traceIdButton} onClick={copyTraceId}>
{trace.traceID}
<Icon name={copyTraceIdClicked ? 'check' : 'copy'} size="sm" className={styles.copyIcon} />
</button>
</span>
</div>
<div className={styles.metadataItem}>
<span className={styles.metadataLabel}>{t('explore.trace-page-header.start-time', 'Start time')}</span>
<span
className={cx(
styles.metadataValue,
css({
gap: theme.spacing(0.5),
})
)}
>
<span>{formattedTimestamp}</span>
<span className={styles.timestampDetail}>({dateTimeFormatTimeAgo(trace.startTime / 1000)})</span>
</span>
</div>
<div className={styles.metadataItem}>
<span className={styles.metadataLabel}>{t('explore.trace-page-header.duration', 'Duration')}</span>
<span className={styles.metadataValue}>{formatDuration(trace.duration)}</span>
</div>
<div className={styles.metadataItem}>
<span className={styles.metadataLabel}>{t('explore.trace-page-header.services', 'Services')}</span>
<span className={styles.metadataValue}>{serviceCount}</span>
</div>
{url && url.length > 0 && (
<div className={styles.metadataItem}>
<span className={styles.metadataLabel}>
{url[0].key === 'http.route' && t('explore.trace-page-header.route', 'Route')}
{url[0].key === 'http.url' && t('explore.trace-page-header.url', 'URL')}
{url[0].key === 'http.target' && t('explore.trace-page-header.target', 'Target')}
{url[0].key === 'http.path' && t('explore.trace-page-header.path', 'Path')}
</span>
<span className={styles.metadataValue}>
<Tooltip
content={
<div>
<div>
<Trans
i18nKey="explore.trace-page-header.tooltip-url"
values={{
route: 'http.route',
url: 'http.url',
target: 'http.target',
path: 'http.path',
}}
>
{'{{route}}'} or {'{{url}}'} or {'{{target}}'} or {'{{path}}'}
</Trans>
</div>
<div>({url[0].value})</div>
</div>
}
interactive={true}
>
<span className={styles.url}>{url[0].value}</span>
</Tooltip>
</span>
</div>
)}
</div>
<SpanFilters
@@ -198,94 +345,126 @@ export const TracePageHeader = memo((props: TracePageHeaderProps) => {
TracePageHeader.displayName = 'TracePageHeader';
const getNewStyles = (theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
TracePageHeaderBack: css({
label: 'TracePageHeaderBack',
alignItems: 'center',
alignSelf: 'stretch',
backgroundColor: '#fafafa',
borderBottom: '1px solid #ddd',
borderRight: '1px solid #ddd',
color: 'inherit',
display: 'flex',
fontSize: '1.4rem',
padding: '0 1rem',
marginBottom: '-1px',
'&:hover': {
backgroundColor: '#f0f0f0',
borderColor: '#ccc',
},
}),
TracePageHeaderOverviewItemValueDetail: cx(
css({
label: 'TracePageHeaderOverviewItemValueDetail',
color: '#aaa',
}),
'trace-item-value-detail'
),
TracePageHeaderOverviewItemValue: css({
label: 'TracePageHeaderOverviewItemValue',
'&:hover > .trace-item-value-detail': {
color: 'unset',
},
}),
header: css({
label: 'TracePageHeader',
backgroundColor: theme.colors.background.primary,
padding: '0.5em 0 0 0',
padding: '0.5em',
position: 'sticky',
top: 0,
zIndex: 5,
textAlign: 'left',
}),
titleRow: css({
alignItems: 'flex-start',
display: 'flex',
padding: '0 8px',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: theme.spacing(1),
gap: theme.spacing(2),
}),
titleSection: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2),
flex: 1,
minWidth: 0, // Allow text truncation
}),
title: css({
color: 'inherit',
flex: 1,
fontSize: '1.7em',
lineHeight: '1em',
marginBottom: 0,
minWidth: '200px',
}),
subtitle: css({
flex: 1,
lineHeight: '1em',
margin: '-0.5em 0.5em 0.75em 0.5em',
}),
tag: css({
margin: '0 0.5em 0 0',
}),
duration: css({
color: '#aaa',
margin: '0 0.75em',
}),
timestamp: css({
verticalAlign: 'middle',
}),
tagMeta: css({
margin: '0 0.75em',
verticalAlign: 'text-top',
}),
url: css({
margin: '-2.5px 0.3em',
height: '15px',
color: theme.colors.text.primary,
fontSize: theme.typography.h3.fontSize,
fontWeight: theme.typography.h3.fontWeight,
lineHeight: theme.typography.h3.lineHeight,
margin: 0,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '700px',
display: 'inline-block',
}),
TracePageHeaderTraceId: css({
label: 'TracePageHeaderTraceId',
whiteSpace: 'nowrap',
}),
badges: css({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
flexShrink: 0,
}),
actions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
flexShrink: 0,
}),
metadataRow: css({
display: 'flex',
alignItems: 'center',
columnGap: theme.spacing(3),
marginBottom: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
flexWrap: 'wrap',
}),
metadataItem: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
}),
metadataLabel: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.secondary,
}),
metadataValue: css({
color: theme.colors.text.primary,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
traceIdButton: css({
background: 'none',
border: 'none',
color: theme.colors.text.primary,
cursor: 'pointer',
textDecoration: 'underline',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
padding: 0,
font: 'inherit',
'&:hover': {
color: theme.colors.emphasize(theme.colors.text.primary, 0.15),
},
}),
copyIcon: css({
opacity: 0.7,
}),
copiedText: css({
color: theme.colors.success.text,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
}),
timestampDetail: css({
color: theme.colors.text.disabled,
}),
url: css({
maxWidth: '700px',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '30%',
whiteSpace: 'nowrap',
display: 'inline-block',
color: theme.colors.text.primary,
}),
};
};
@@ -100,3 +100,12 @@ export type CriticalPathSection = {
section_start: number;
section_end: number;
};
// Type for the plugin link context that includes trace data and datasource information
export type TraceViewPluginExtensionContext = Trace & {
datasource: {
name: string;
uid: string;
type: string;
};
};
+17 -11
View File
@@ -7275,18 +7275,24 @@
"split-tooltip": "Split the pane",
"split-widen": "Widen pane"
},
"trace-page-actions": {
"ariaLabel-copy-trace-id": "Copy Trace ID",
"ariaLabel-export-trace": "Export Trace",
"give-feedback": "Give feedback",
"label-copied": "Copied!",
"label-export": "Export",
"label-trace-id": "Trace ID",
"title-share-thoughts-about-tracing-grafana": "Share your thoughts about tracing in Grafana."
},
"trace-page-header": {
"text-partial-trace": "Partial trace",
"tooltip-url": "{{url}} or {{target}} or {{path}}"
"duration": "Duration",
"export-started": "Export started",
"give-feedback": "Feedback",
"link-copied": "Link copied to clipboard",
"path": "Path",
"route": "Route",
"services": "Services",
"share": "Share",
"share-copy-link": "Copy link",
"share-export-json": "Export as JSON",
"share-tooltip": "Share trace",
"start-time": "Start time",
"target": "Target",
"title-share-thoughts-about-tracing-grafana": "Share your thoughts about tracing in Grafana.",
"tooltip-url": "{{route}} or {{url}} or {{target}} or {{path}}",
"trace-id": "Trace ID",
"url": "URL"
},
"trace-page-search-bar": {
"aria-label-clear-filters": "Clear filters button",