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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
-107
@@ -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>
|
||||
);
|
||||
}
|
||||
+435
-8
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+349
-170
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user