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 && (
-
- )}
-
-
-
-
- );
-}
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 = (
+
+ );
+
return (