diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go index acda5c7d1c9..314d36b602a 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/receiver_schema_gen.go @@ -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) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go index 339d0bd6d04..509bfc4aeca 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/templategroup_schema_gen.go @@ -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) diff --git a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go index a6c809e0d3b..8c5459f0059 100644 --- a/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go +++ b/apps/alerting/notifications/pkg/apis/alerting/v0alpha1/timeinterval_schema_gen.go @@ -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) diff --git a/apps/alerting/notifications/pkg/apis/alerting_manifest.go b/apps/alerting/notifications/pkg/apis/alerting_manifest.go index 40f05004004..bde16bfb81d 100644 --- a/apps/alerting/notifications/pkg/apis/alerting_manifest.go +++ b/apps/alerting/notifications/pkg/apis/alerting_manifest.go @@ -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", diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index 2a3c0408c29..591ce095303 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -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", diff --git a/apps/folder/pkg/apis/folder_manifest.go b/apps/folder/pkg/apis/folder_manifest.go index 8628efe6537..e6231165862 100644 --- a/apps/folder/pkg/apis/folder_manifest.go +++ b/apps/folder/pkg/apis/folder_manifest.go @@ -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", diff --git a/apps/secret/pkg/apis/secret_manifest.go b/apps/secret/pkg/apis/secret_manifest.go index e8ff6dbca3a..512e381f77d 100644 --- a/apps/secret/pkg/apis/secret_manifest.go +++ b/apps/secret/pkg/apis/secret_manifest.go @@ -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", diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index e64bf16cfdb..32764409a8d 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -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', diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 19203ec818a..28a47518b81 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -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} /> diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx deleted file mode 100644 index e1448a77f69..00000000000 --- a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx +++ /dev/null @@ -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 ( -
- {config.feedbackLinksEnabled && ( -
- - - Give feedback - -
- )} - - - -
- ); -} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx index 95d78fff092..97fc884d659 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.test.tsx @@ -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; + 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(); + return { + ...render(), + 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(); + }); }); }); diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx index 72d3a035401..f75fda8b582 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx @@ -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>; spanFilterMatches: Set | 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 ? ( - - {match[1]} - {match[2]} - - ) : ( - 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 = ( -

- - {formatDuration(trace.duration)} -

- ); + 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 ( - <> -
- - {'{{url}}'} or {'{{target}}'} or {'{{path}}'} - -
-
({url})
- - ); + 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 = ( + + { + navigator.clipboard.writeText(window.location.href); + notifyApp.success(t('explore.trace-page-header.link-copied', 'Link copied to clipboard')); + }} + /> + { + exportTrace(); + notifyApp.success(t('explore.trace-page-header.export-started', 'Export started')); + }} + /> + + ); + return (
+ {/* Main title row */}
- {links && links.length > 0 && } - {title} - +
+

{traceName}

+
+ {method && method.length > 0 && } + {status && status.length > 0 && } +
+
+ + {/* Action buttons */} +
+ {/* Plugin extension actions */} + {extensionLinks.length > 0 && ( +
+ {extensionLinks.map((link) => ( + + + + ))} +
+ )} + + {config.feedbackLinksEnabled && ( + + + Feedback + + + )} + + + + + + + +
-
- {timestamp(trace, timeZone)} - - {data.meta?.custom?.partial && ( - - - - - - )} - {method && method.length > 0 && ( - - - - - - )} - {status && status.length > 0 && ( - - - - - - )} - {url && url.length > 0 && ( - - {url[0].value} - - )} - + {/* Metadata row */} +
+
+ {t('explore.trace-page-header.trace-id', 'Trace ID')} + + + +
+ +
+ {t('explore.trace-page-header.start-time', 'Start time')} + + {formattedTimestamp} + ({dateTimeFormatTimeAgo(trace.startTime / 1000)}) + +
+ +
+ {t('explore.trace-page-header.duration', 'Duration')} + {formatDuration(trace.duration)} +
+ +
+ {t('explore.trace-page-header.services', 'Services')} + {serviceCount} +
+ + {url && url.length > 0 && ( +
+ + {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')} + + + +
+ + {'{{route}}'} or {'{{url}}'} or {'{{target}}'} or {'{{path}}'} + +
+
({url[0].value})
+
+ } + interactive={true} + > + {url[0].value} + + +
+ )}
{ 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, }), }; }; diff --git a/public/app/features/explore/TraceView/components/types/trace.ts b/public/app/features/explore/TraceView/components/types/trace.ts index 3c7dc4733e5..63216f3519b 100644 --- a/public/app/features/explore/TraceView/components/types/trace.ts +++ b/public/app/features/explore/TraceView/components/types/trace.ts @@ -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; + }; +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bdb0859ba38..e5cbc8978e4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",