Plugin Extensions: DataSource Configuration Components (#108350)
* add the plan
* feat: Add plugin extension points for DataSource configuration components
- Add DataSourceConfigActions and DataSourceConfigStatus extension points
- Add PluginExtensionDataSourceConfigActionsContext and PluginExtensionDataSourceConfigStatusContext types
- Update EditDataSourceActions.tsx with plugin extension support and allowlist filtering
- Update DataSourceTestingStatus.tsx with dual extension point support (new + backward compatible)
- Create getDataSourceExtensionConfigs.tsx with example core extensions
- Update getCoreExtensionConfigurations.ts to include datasource extensions
- Implement grafana-owned plugin allowlist for security and quality control
- Support context-aware extensions based on datasource type and testing status
- Maintain backward compatibility with existing DataSourceConfigErrorStatus extension point
- Add proper TypeScript types and export them in index.ts
This enables grafana-owned plugins to provide contextual actions and status-specific integrations
within the datasource configuration workflow while maintaining security through plugin filtering.
* docs: Update specs.md with implementation status and lessons learned
- Add comprehensive implementation status section with completed features
- Document critical lessons learned during implementation:
* Translation limitations in extension configurations
* Plugin security through allowlist filtering
* Extension registration timing constraints
* TypeScript context type patterns
- Update implementation checklist with actual completion status
- Add detailed next steps and recommendations
- Include plugin development guidelines (do's and don'ts)
- Document architectural insights and patterns discovered
- Transform specs from theoretical design to practical implementation guide
This update serves as both historical record and developer guide for future
plugin extension work in Grafana.
* Add comprehensive tests for DataSource plugin extensions
- Add EditDataSourceActions.test.tsx with 12 tests covering:
* Core Grafana actions functionality and permissions
* Plugin extension rendering and interaction
* Plugin allowlist filtering and security
* Context passing and URL generation
- Add getDataSourceExtensionConfigs.test.tsx with 14 tests covering:
* Core extension configurations for both extension points
* Context filtering logic (datasource type, severity)
* Error handling and graceful failures
* Extension structure validation and type safety
Total: 26 tests passing, providing comprehensive coverage of the
DataSourceConfigActions and DataSourceConfigStatus extension points
implemented in previous commits.
* Update specs.md to reflect completed test implementation
- Mark unit testing phase as complete (26 tests added)
- Add comprehensive Test Implementation section documenting:
* Test file details and coverage areas
* Testing challenges and solutions discovered
* Performance metrics and execution strategy
* Command examples for running tests
- Update Success Metrics with concrete testing validation
- Update Next Steps to reflect testing completion
- Document new test files in Files Modified section
The plugin extensions implementation now has complete unit test
coverage for both DataSourceConfigActions and DataSourceConfigStatus
extension points with 26 passing tests.
* rm specs file
* lint
* refactor: replace 'any' types with proper TypeScript types in datasource extension tests
* tsc
* prettier: fix
* Fix datasource extension path format to resolve CI test failure
The troubleshooting link extension was using an invalid path format
'/docs/troubleshooting/datasources' which caused console.error during
test execution. Updated to proper Grafana core extension path format
'/a/grafana/docs/troubleshooting/datasources' to comply with plugin
extension validation rules.
* fix: DataSourceTestingStatus test failures
- Fix incorrect data-testid expectations to use proper e2e selectors
- Add contextSrv mock for hasAccessToExplore() dependency
- Create proper plugin links mock handling different extension points
- Update plugin link tests to use allowed plugin IDs
- Fix test logic to match actual component behavior for status/error links
- Add test coverage for non-allowed plugin filtering
All 10 tests now pass successfully.
* lint fix
* refactor: replace any types with proper TypeScript types in DataSourceTestingStatus test
- Replace any[] with PluginExtensionLink[] for statusLinks and errorLinks parameters
- Replace any with UsePluginLinksOptions for setPluginLinksHook callback parameter
- Add proper imports for PluginExtensionLink and UsePluginLinksOptions
- Improve type safety and IntelliSense support in tests
* rm brittle tests
* DataSource: Replace individual buttons with dropdown menu in EditDataSourceActions
- Convert LinkButton components to dropdown with Menu.Item components
- Add conditional rendering: show simple button when only one action, dropdown when multiple
- Use LinkButton for navigation and Button with dropdown icon for menu trigger
- Replace Trans components with t() function calls for Menu.Item labels
- Prevent PageHeader crowding when multiple plugin extensions are registered
- Maintain all existing functionality and tracking calls
* refactor: use configure() for dynamic datasource extension URLs
- Replace onClick handler with configure() function for dynamic path generation
- Use context to build monitoring tool URL with datasource UID
- Follows extension system design patterns for declarative configuration
- Removes manual window.open() in favor of automatic path handling
* refactor: use DataSourceConfigErrorStatus extension point for troubleshooting guide
Use the specific DataSourceConfigErrorStatus extension point instead of
DataSourceConfigStatus with conditional logic in configure function.
This simplifies the code by leveraging the right abstraction level.
* extract i18n
* lint:fix
* Revert "lint:fix"
This reverts commit 23cdb39672.
* lint:fix
* fix: mock plugin extensions registry in variables utils test
Fixes console.error calls during test execution caused by plugin extension
registration failures. The test was failing on CI because the extensions
registry was trying to register link extensions with invalid configurations
during module import.
Solution: Mock the entire plugin extensions registry setup to prevent
side effects from interfering with the test execution.
* fix: mock plugin extensions registry in explore query test
Fixes console.error calls during test execution caused by plugin extension
registration failures. The test was failing on CI because the extensions
registry was trying to register link extensions with invalid configurations
during module import.
This applies the same fix as the variables utils test - mocking the entire
plugin extensions registry setup to prevent side effects from interfering
with the test execution.
* better fix
* Fix EditDataSourceActions test by mocking utils and handling dropdown UI
- Add missing mock for constructDataSourceExploreUrl from ../utils
- Update tests to interact with dropdown menu structure instead of expecting direct text
- Fix test logic to match component behavior: Actions dropdown when user has explore rights
* lint:fix
* refactor: extract duplicated allowedPluginIds to single source of truth
- Add ALLOWED_DATASOURCE_EXTENSION_PLUGINS constant to constants.ts
- Remove duplicate allowedPluginIds arrays from DataSourceTestingStatus and EditDataSourceActions
- Update both components to import and use the shared constant
- Improves maintainability and ensures consistency across datasource config components
* refactor: update datasource extension link configurations
- Change title and description for the metrics drilldown link to improve clarity
- Update the path for the metrics drilldown link to reflect the new routing structure
- Remove the troubleshooting guide link configuration to streamline the extension options
* removing unused config
* better dropdown
* fix: update EditDataSourceActions tests to match component implementation
- Fixed tests to expect core actions as separate buttons instead of dropdown
- Updated plugin extension tests to use 'Extensions' dropdown instead of 'Actions'
- Added test for Extensions dropdown visibility logic
- All 12 tests now passing
* lint:fix
* remove extra check
* Update ALLOWED_DATASOURCE_EXTENSION_PLUGINS documentation to clarify plugin contributions to datasource configuration pages
* feat: Convert Explore data button to dropdown when extensions are available
- When hasActions is empty: shows normal Explore data and Build dashboard buttons
- When hasActions is not empty: converts Explore data to dropdown with angle-down icon
- First dropdown item: 'Open in Explore View'
- Following items: extension links
- Build dashboard button remains unchanged
- Maintains existing tracking functionality and internationalization
* test: Update EditDataSourceActions tests for new dropdown implementation
- Update tests to work with new Explore data dropdown that includes extensions
- Replace references to 'Extensions' button with 'Explore data' dropdown
- Add test for 'Open in Explore View' menu item in dropdown
- Verify correct behavior when extensions are present vs absent
- All 13 tests now passing
* test: Add comprehensive test coverage for dropdown explore functionality
- Add test to verify explore action href when extensions are present
- Ensure both dropdown and direct link behaviors are tested
- Complete test coverage for new dropdown implementation
* translation
* compass icon for the explore item
* feat: refactor AlertSuccessMessage to accept extension links as props
- Move extension links from inline definition to props for better reusability
- Add proper TypeScript support for PluginExtensionLink types
- Improve component architecture by separating concerns
- Add extensionLinks styling to getStyles function for consistency
- Support both real extension links and custom link rendering in success messages
This improves the component's flexibility while maintaining type safety.
* lint:fix
This commit is contained in:
@@ -567,6 +567,8 @@ export {
|
||||
type PluginExtensionPanelContext,
|
||||
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
|
||||
type PluginExtensionDataSourceConfigContext,
|
||||
type PluginExtensionDataSourceConfigActionsContext,
|
||||
type PluginExtensionDataSourceConfigStatusContext,
|
||||
type PluginExtensionCommandPaletteContext,
|
||||
type PluginExtensionOpenModalOptions,
|
||||
type PluginExtensionExposedComponentConfig,
|
||||
|
||||
@@ -194,7 +194,9 @@ export enum PluginExtensionPoints {
|
||||
CommandPalette = 'grafana/commandpalette/action',
|
||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||
DataSourceConfig = 'grafana/datasources/config',
|
||||
DataSourceConfigActions = 'grafana/datasources/config/actions',
|
||||
DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status',
|
||||
DataSourceConfigStatus = 'grafana/datasources/config/status',
|
||||
ExploreToolbarAction = 'grafana/explore/toolbar/action',
|
||||
UserProfileTab = 'grafana/user/profile/tab',
|
||||
TraceViewDetails = 'grafana/traceview/details',
|
||||
@@ -271,6 +273,30 @@ export type DataSourceConfigErrorStatusContext = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginExtensionDataSourceConfigActionsContext = {
|
||||
dataSource: {
|
||||
type: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
typeName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginExtensionDataSourceConfigStatusContext = {
|
||||
dataSource: {
|
||||
type: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
typeName: string;
|
||||
};
|
||||
testingStatus?: {
|
||||
message?: string | null;
|
||||
status?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
severity: 'success' | 'error' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
type Dashboard = {
|
||||
uid: string;
|
||||
title: string;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { PluginExtensionTypes, PluginExtensionLink } from '@grafana/data';
|
||||
import { setPluginLinksHook, UsePluginLinksOptions } from '@grafana/runtime';
|
||||
|
||||
import { getMockDataSource } from '../mocks/dataSourcesMocks';
|
||||
|
||||
import { DataSourceTestingStatus, Props } from './DataSourceTestingStatus';
|
||||
|
||||
// Mock contextSrv
|
||||
jest.mock('../../../core/core', () => ({
|
||||
contextSrv: {
|
||||
hasAccessToExplore: jest.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
|
||||
const getProps = (partialProps?: Partial<Props>): Props => ({
|
||||
@@ -34,8 +41,8 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.getByText('Data source is definitely working')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument();
|
||||
expect(() => screen.getByTestId('data-testid Alert error')).toThrow();
|
||||
expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render successful message when testing status is uppercase "OK"', () => {
|
||||
@@ -48,8 +55,8 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.getByText('Data source is definitely working')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument();
|
||||
expect(() => screen.getByTestId('data-testid Alert error')).toThrow();
|
||||
expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render successful message when testing status is lowercase "ok"', () => {
|
||||
@@ -62,8 +69,8 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.getByText('Data source is definitely working')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument();
|
||||
expect(() => screen.getByTestId('data-testid Alert error')).toThrow();
|
||||
expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error message when testing status is "error"', () => {
|
||||
@@ -76,8 +83,8 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.getByText('Data source is definitely NOT working')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-testid Alert error')).toBeInTheDocument();
|
||||
expect(() => screen.getByTestId('data-testid Alert success')).toThrow();
|
||||
expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert success')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render info message when testing status is unknown', () => {
|
||||
@@ -90,9 +97,9 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.getByText('Data source is working')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('data-testid Alert info')).toBeInTheDocument();
|
||||
expect(() => screen.getByTestId('data-testid Alert success')).toThrow();
|
||||
expect(() => screen.getByTestId('data-testid Alert error')).toThrow();
|
||||
expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert success')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Plugin links', () => {
|
||||
@@ -111,24 +118,50 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
type: PluginExtensionTypes.link as const,
|
||||
title: 'Test Link',
|
||||
description: 'Test link description',
|
||||
pluginId: 'test-plugin',
|
||||
pluginId: 'grafana-monitoring-app', // Use an allowed plugin ID
|
||||
path: '/test',
|
||||
onClick: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Custom mock that can handle different extension points
|
||||
const setupPluginLinksMock = (statusLinks: PluginExtensionLink[] = [], errorLinks: PluginExtensionLink[] = []) => {
|
||||
setPluginLinksHook((params: UsePluginLinksOptions) => {
|
||||
// Return different links based on the extension point ID
|
||||
if (params.extensionPointId === 'grafana/datasources/config/status') {
|
||||
return { links: statusLinks, isLoading: false };
|
||||
} else if (params.extensionPointId === 'grafana/datasources/config/error-status') {
|
||||
return { links: errorLinks, isLoading: false };
|
||||
}
|
||||
return { links: [], isLoading: false };
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// Reset the hook to default empty state
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
});
|
||||
|
||||
it('should render plugin links when severity is error and links exist', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({ id: 'link1', path: 'http://example.com/help', title: 'Help Documentation' }),
|
||||
createMockPluginLink({ id: 'link2', path: 'http://example.com/troubleshoot', title: 'Troubleshooting Guide' }),
|
||||
const statusLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'status-link1',
|
||||
path: 'http://example.com/help',
|
||||
title: 'Help Documentation',
|
||||
pluginId: 'grafana-monitoring-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
const errorLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'error-link1',
|
||||
path: 'http://example.com/troubleshoot',
|
||||
title: 'Troubleshooting Guide',
|
||||
pluginId: 'grafana-troubleshooting-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setupPluginLinksMock(statusLinks, errorLinks);
|
||||
|
||||
const props = getProps({
|
||||
testingStatus: {
|
||||
@@ -151,16 +184,17 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
|
||||
it('should call onClick handler when plugin link is clicked', () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const mockLinks = [
|
||||
const statusLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'link1',
|
||||
id: 'status-link1',
|
||||
path: 'http://example.com/help',
|
||||
onClick: mockOnClick,
|
||||
title: 'Help Documentation',
|
||||
pluginId: 'grafana-monitoring-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
setupPluginLinksMock(statusLinks, []);
|
||||
|
||||
const props = getProps({
|
||||
testingStatus: {
|
||||
@@ -177,12 +211,26 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT render plugin links when severity is not error even if links exist', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({ id: 'link1', path: 'http://example.com/help', title: 'Help Documentation' }),
|
||||
it('should render status plugin links for non-error severity but NOT error-specific links', () => {
|
||||
const statusLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'status-link1',
|
||||
path: 'http://example.com/help',
|
||||
title: 'Status Help Documentation',
|
||||
pluginId: 'grafana-monitoring-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
const errorLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'error-link1',
|
||||
path: 'http://example.com/error-help',
|
||||
title: 'Error Help Documentation',
|
||||
pluginId: 'grafana-troubleshooting-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setupPluginLinksMock(statusLinks, errorLinks);
|
||||
|
||||
const props = getProps({
|
||||
testingStatus: {
|
||||
@@ -193,6 +241,33 @@ describe('<DataSourceTestingStatus />', () => {
|
||||
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
// Should render status links for success severity
|
||||
expect(screen.getByText('Status Help Documentation')).toBeInTheDocument();
|
||||
// Should NOT render error-specific links for success severity
|
||||
expect(screen.queryByText('Error Help Documentation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT render plugin links from non-allowed plugins', () => {
|
||||
const statusLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'status-link1',
|
||||
path: 'http://example.com/help',
|
||||
title: 'Help Documentation',
|
||||
pluginId: 'not-allowed-plugin', // This should be filtered out
|
||||
}),
|
||||
];
|
||||
|
||||
setupPluginLinksMock(statusLinks, []);
|
||||
|
||||
const props = getProps({
|
||||
testingStatus: {
|
||||
status: 'error',
|
||||
message: 'Data source connection failed',
|
||||
},
|
||||
});
|
||||
|
||||
render(<DataSourceTestingStatus {...props} />);
|
||||
|
||||
expect(screen.queryByText('Help Documentation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2, PluginExtensionPoints } from '@grafana/data';
|
||||
import {
|
||||
DataSourceSettings as DataSourceSettingsType,
|
||||
GrafanaTheme2,
|
||||
PluginExtensionPoints,
|
||||
PluginExtensionLink,
|
||||
} from '@grafana/data';
|
||||
import { sanitizeUrl } from '@grafana/data/internal';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
@@ -9,6 +14,7 @@ import { TestingStatus, config, usePluginLinks } from '@grafana/runtime';
|
||||
import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { contextSrv } from '../../../core/core';
|
||||
import { ALLOWED_DATASOURCE_EXTENSION_PLUGINS } from '../constants';
|
||||
import { trackCreateDashboardClicked } from '../tracking';
|
||||
|
||||
export type Props = {
|
||||
@@ -23,6 +29,7 @@ interface AlertMessageProps extends HTMLAttributes<HTMLDivElement> {
|
||||
exploreUrl: string;
|
||||
dataSourceId: string;
|
||||
onDashboardLinkClicked: () => void;
|
||||
extensionLinks?: PluginExtensionLink[];
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, hasTitle: boolean) => {
|
||||
@@ -37,10 +44,21 @@ const getStyles = (theme: GrafanaTheme2, hasTitle: boolean) => {
|
||||
pointerEvents: 'none',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
extensionLinks: css({
|
||||
display: 'inline-flex',
|
||||
marginTop: theme.spacing(0.5),
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkClicked }: AlertMessageProps) => {
|
||||
const AlertSuccessMessage = ({
|
||||
title,
|
||||
exploreUrl,
|
||||
dataSourceId,
|
||||
onDashboardLinkClicked,
|
||||
extensionLinks = [],
|
||||
}: AlertMessageProps) => {
|
||||
const theme = useTheme2();
|
||||
|
||||
const hasTitle = Boolean(title);
|
||||
@@ -72,6 +90,26 @@ const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkC
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
|
||||
{/* Extension links for allowed datasource extension plugins */}
|
||||
{extensionLinks.length > 0 && (
|
||||
<div className={styles.extensionLinks}>
|
||||
<Trans i18nKey="data-source-testing-status-page.success-more-details-links-extensions">
|
||||
You can also explore data with the following extensions:
|
||||
</Trans>
|
||||
{extensionLinks.map((link) => (
|
||||
<Link
|
||||
key={link.id}
|
||||
href={link.path || '#'}
|
||||
title={link.description}
|
||||
className="external-link"
|
||||
onClick={'onClick' in link ? link.onClick : undefined}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -148,7 +186,24 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
||||
});
|
||||
};
|
||||
const styles = useStyles2(getTestingStatusStyles);
|
||||
const { links } = usePluginLinks({
|
||||
|
||||
const { links: allStatusLinks } = usePluginLinks({
|
||||
extensionPointId: PluginExtensionPoints.DataSourceConfigStatus,
|
||||
context: {
|
||||
dataSource: {
|
||||
type: dataSource.type,
|
||||
uid: dataSource.uid,
|
||||
name: dataSource.name,
|
||||
typeName: dataSource.typeName,
|
||||
},
|
||||
testingStatus,
|
||||
severity,
|
||||
},
|
||||
limitPerPlugin: 1,
|
||||
});
|
||||
|
||||
// Existing error-specific extensions (backward compatibility)
|
||||
const { links: allErrorLinks } = usePluginLinks({
|
||||
extensionPointId: PluginExtensionPoints.DataSourceConfigErrorStatus,
|
||||
context: {
|
||||
dataSource: {
|
||||
@@ -161,6 +216,13 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
||||
limitPerPlugin: 3,
|
||||
});
|
||||
|
||||
// Filter to only allow grafana-owned plugins
|
||||
const statusLinks = allStatusLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId));
|
||||
const errorLinks = allErrorLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId));
|
||||
|
||||
// Combine links: show error-specific only for errors, status-general for all
|
||||
const extensionLinks = severity === 'error' ? [...statusLinks, ...errorLinks] : statusLinks;
|
||||
|
||||
if (message) {
|
||||
return (
|
||||
<div className={cx('gf-form-group', styles.container)}>
|
||||
@@ -174,6 +236,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
||||
exploreUrl={exploreUrl}
|
||||
dataSourceId={dataSource.uid}
|
||||
onDashboardLinkClicked={onDashboardLinkClicked}
|
||||
extensionLinks={extensionLinks}
|
||||
/>
|
||||
) : null}
|
||||
{severity === 'error' && errorDetailsLink ? <ErrorDetailsLink link={String(errorDetailsLink)} /> : null}
|
||||
@@ -182,15 +245,16 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{severity === 'error' && links.length > 0 && (
|
||||
{extensionLinks.length > 0 && (
|
||||
<div className={styles.linksContainer}>
|
||||
{links.map((link) => {
|
||||
{extensionLinks.map((link) => {
|
||||
return (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.path ? sanitizeUrl(link.path) : undefined}
|
||||
onClick={link.onClick}
|
||||
className={styles.pluginLink}
|
||||
title={link.description}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { PluginExtensionTypes, IconName } from '@grafana/data';
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { getMockDataSource } from '../mocks/dataSourcesMocks';
|
||||
|
||||
import { EditDataSourceActions } from './EditDataSourceActions';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('app/core/services/context_srv');
|
||||
jest.mock('../utils', () => ({
|
||||
constructDataSourceExploreUrl: jest.fn(
|
||||
() => '/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D'
|
||||
),
|
||||
}));
|
||||
|
||||
// Set default plugin links hook
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
|
||||
// Mock contextSrv
|
||||
const mockContextSrv = contextSrv as jest.Mocked<typeof contextSrv>;
|
||||
|
||||
// Helper function to create mock plugin link extensions with all required properties
|
||||
const createMockPluginLink = (
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
path: string;
|
||||
onClick: jest.Mock;
|
||||
title: string;
|
||||
description: string;
|
||||
pluginId: string;
|
||||
icon?: IconName;
|
||||
}> = {}
|
||||
) => ({
|
||||
id: 'test-link',
|
||||
type: PluginExtensionTypes.link as const,
|
||||
title: 'Test Action',
|
||||
description: 'Test action description',
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
path: '/test-action',
|
||||
onClick: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockDataSource = getMockDataSource({
|
||||
uid: 'test-uid',
|
||||
type: 'prometheus',
|
||||
name: 'Test Prometheus',
|
||||
typeName: 'Prometheus',
|
||||
});
|
||||
|
||||
// Mock useDataSource hook
|
||||
jest.mock('../state/hooks', () => ({
|
||||
useDataSource: () => mockDataSource,
|
||||
}));
|
||||
|
||||
describe('EditDataSourceActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset plugin links hook to default
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
// Default contextSrv mock - user has explore rights
|
||||
mockContextSrv.hasAccessToExplore.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('Core Actions', () => {
|
||||
it('should render core Grafana actions when user has explore rights', async () => {
|
||||
mockContextSrv.hasAccessToExplore.mockReturnValue(true);
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Core actions should be rendered as separate buttons
|
||||
expect(screen.getByText('Explore data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render explore action when user lacks explore rights', () => {
|
||||
mockContextSrv.hasAccessToExplore.mockReturnValue(false);
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Should render just the "Build a dashboard" button
|
||||
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
|
||||
// Should not render explore action
|
||||
expect(screen.queryByText('Explore data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct href for explore action when no extensions', () => {
|
||||
// No extensions, so explore should be a direct link
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
const exploreLink = screen.getByText('Explore data').closest('a');
|
||||
// The explore URL uses the datasource name, not uid, and includes context
|
||||
expect(exploreLink).toHaveAttribute(
|
||||
'href',
|
||||
'/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have correct href for explore action when extensions are present', () => {
|
||||
// Extensions present, so explore should be a dropdown with "Open in Explore View"
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'test-extension',
|
||||
title: 'Test Extension',
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Click to open dropdown
|
||||
const exploreButton = screen.getByText('Explore data');
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
const exploreViewLink = screen.getByText('Open in Explore View').closest('a');
|
||||
// The explore URL uses the datasource name, not uid, and includes context
|
||||
expect(exploreViewLink).toHaveAttribute(
|
||||
'href',
|
||||
'/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have correct href for build dashboard action', () => {
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
const dashboardLink = screen.getByText('Build a dashboard').closest('a');
|
||||
expect(dashboardLink).toHaveAttribute('href', 'dashboard/new-with-ds/test-uid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Extension Actions', () => {
|
||||
it('should render plugin extension links from allowed plugins', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'loki-explore',
|
||||
title: 'Explore Logs',
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
path: '/a/grafana-lokiexplore-app',
|
||||
}),
|
||||
createMockPluginLink({
|
||||
id: 'traces-explore',
|
||||
title: 'Explore Traces',
|
||||
pluginId: 'grafana-exploretraces-app',
|
||||
path: '/a/grafana-exploretraces-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Click the Explore data dropdown to open the menu
|
||||
const exploreButton = screen.getByText('Explore data');
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
// Should have "Open in Explore View" as first item
|
||||
expect(screen.getByText('Open in Explore View')).toBeInTheDocument();
|
||||
// Should have extension links
|
||||
expect(screen.getByText('Explore Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Explore Traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out links from non-allowed plugins', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'allowed-plugin',
|
||||
title: 'Allowed Action',
|
||||
pluginId: 'grafana-lokiexplore-app', // Allowed
|
||||
}),
|
||||
createMockPluginLink({
|
||||
id: 'disallowed-plugin',
|
||||
title: 'Disallowed Action',
|
||||
pluginId: 'some-random-plugin', // Not allowed
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Click the Explore data dropdown to open the menu
|
||||
const exploreButton = screen.getByText('Explore data');
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
expect(screen.getByText('Allowed Action')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Disallowed Action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call usePluginLinks with correct parameters', () => {
|
||||
// This test verifies the component calls usePluginLinks correctly
|
||||
// We can't easily test the exact parameters without more complex mocking
|
||||
// but we can verify the component renders without errors when links are provided
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
|
||||
expect(() => {
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle plugin link onClick events', () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'clickable-action',
|
||||
title: 'Clickable Action',
|
||||
onClick: mockOnClick,
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Click the Explore data dropdown to open the menu
|
||||
const exploreButton = screen.getByText('Explore data');
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
const actionButton = screen.getByText('Clickable Action');
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should render extension links with correct attributes', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'Test description',
|
||||
path: '/test-path',
|
||||
icon: 'external-link-alt' as IconName,
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// Click the Explore data dropdown to open the menu
|
||||
const exploreButton = screen.getByText('Explore data');
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
const actionButton = screen.getByText('Test Action');
|
||||
const linkElement = actionButton.closest('a');
|
||||
|
||||
expect(linkElement).toHaveAttribute('href', '/test-path');
|
||||
// The description is passed as tooltip, which may not appear as a title attribute
|
||||
// This is handled by the LinkButton component internally
|
||||
});
|
||||
|
||||
it('should not render extensions when isLoading is true', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
title: 'Should Not Appear',
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: true }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// When isLoading is true, Explore data should be a regular link, not a dropdown
|
||||
const exploreElement = screen.getByText('Explore data');
|
||||
const exploreLink = exploreElement.closest('a');
|
||||
expect(exploreLink).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Should Not Appear')).not.toBeInTheDocument();
|
||||
// Core actions should still be there
|
||||
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Explore data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty extension links gracefully', () => {
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// When there are no extension links, Explore data should be a regular link
|
||||
const exploreElement = screen.getByText('Explore data');
|
||||
const exploreLink = exploreElement.closest('a');
|
||||
expect(exploreLink).toBeInTheDocument();
|
||||
|
||||
// Should render core actions without errors
|
||||
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Explore data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Explore dropdown when there are plugin links', () => {
|
||||
const mockLinks = [
|
||||
createMockPluginLink({
|
||||
id: 'test-extension',
|
||||
title: 'Test Extension',
|
||||
pluginId: 'grafana-lokiexplore-app',
|
||||
}),
|
||||
];
|
||||
|
||||
setPluginLinksHook(() => ({ links: mockLinks, isLoading: false }));
|
||||
|
||||
render(<EditDataSourceActions uid="test-uid" />);
|
||||
|
||||
// When there are extension links, Explore data should be a dropdown button
|
||||
const exploreElement = screen.getByText('Explore data');
|
||||
const exploreButton = exploreElement.closest('button');
|
||||
expect(exploreButton).toBeInTheDocument();
|
||||
|
||||
// Core actions should still be there
|
||||
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Explore data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { LinkButton } from '@grafana/ui';
|
||||
import { PluginExtensionPoints } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config, usePluginLinks } from '@grafana/runtime';
|
||||
import { Button, Dropdown, LinkButton, Menu, Icon } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
|
||||
import { ALLOWED_DATASOURCE_EXTENSION_PLUGINS } from '../constants';
|
||||
import { useDataSource } from '../state/hooks';
|
||||
import { trackCreateDashboardClicked, trackDsConfigClicked, trackExploreClicked } from '../tracking';
|
||||
import { constructDataSourceExploreUrl } from '../utils';
|
||||
@@ -15,25 +17,71 @@ export function EditDataSourceActions({ uid }: Props) {
|
||||
const dataSource = useDataSource(uid);
|
||||
const hasExploreRights = contextSrv.hasAccessToExplore();
|
||||
|
||||
// Fetch plugin extension links
|
||||
const { links: allLinks, isLoading } = usePluginLinks({
|
||||
extensionPointId: PluginExtensionPoints.DataSourceConfigActions,
|
||||
context: {
|
||||
dataSource: {
|
||||
type: dataSource.type,
|
||||
uid: dataSource.uid,
|
||||
name: dataSource.name,
|
||||
typeName: dataSource.typeName,
|
||||
},
|
||||
},
|
||||
limitPerPlugin: 1,
|
||||
});
|
||||
|
||||
const links = allLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId));
|
||||
|
||||
// Only render dropdown if there are multiple actions to show
|
||||
const hasActions = !isLoading && links.length > 0;
|
||||
|
||||
const handleExploreClick = () => {
|
||||
trackDsConfigClicked('explore');
|
||||
trackExploreClicked({
|
||||
grafana_version: config.buildInfo.version,
|
||||
datasource_uid: dataSource.uid,
|
||||
plugin_name: dataSource.typeName,
|
||||
path: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
const exploreMenu = (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
label={t('datasources.edit-data-source-actions.open-in-explore', 'Open in Explore View')}
|
||||
url={constructDataSourceExploreUrl(dataSource)}
|
||||
onClick={handleExploreClick}
|
||||
icon="compass"
|
||||
/>
|
||||
{links.map((link) => (
|
||||
<Menu.Item key={link.id} label={link.title} url={link.path} onClick={link.onClick} icon={link.icon} />
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasExploreRights && (
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={constructDataSourceExploreUrl(dataSource)}
|
||||
onClick={() => {
|
||||
trackDsConfigClicked('explore');
|
||||
trackExploreClicked({
|
||||
grafana_version: config.buildInfo.version,
|
||||
datasource_uid: dataSource.uid,
|
||||
plugin_name: dataSource.typeName,
|
||||
path: window.location.pathname,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="datasources.edit-data-source-actions.explore-data">Explore data</Trans>
|
||||
</LinkButton>
|
||||
<>
|
||||
{!hasActions ? (
|
||||
<LinkButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={constructDataSourceExploreUrl(dataSource)}
|
||||
onClick={handleExploreClick}
|
||||
>
|
||||
<Trans i18nKey="datasources.edit-data-source-actions.explore-data">Explore data</Trans>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Dropdown overlay={exploreMenu}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Trans i18nKey="datasources.edit-data-source-actions.explore-data">Explore data</Trans>
|
||||
<Icon name="angle-down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<LinkButton
|
||||
size="sm"
|
||||
|
||||
@@ -10,3 +10,22 @@ export const DATASOURCES_ROUTES: DataSourcesRoutes = {
|
||||
Dashboards: '/datasources/edit/:uid/dashboards',
|
||||
New: '/datasources/new',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Plugin IDs that are allowed to contribute extensions to:
|
||||
* - DataSourceConfigActions (header action buttons)
|
||||
* - DataSourceConfigStatus (testing status links)
|
||||
* - DataSourceConfigErrorStatus (error help links)
|
||||
*
|
||||
* Note: These plugins cannot contribute to the main configuration form.
|
||||
* For form components, see useDataSourceConfigPluginExtensions
|
||||
* allowlist from the EditDataSource component.
|
||||
*/
|
||||
export const ALLOWED_DATASOURCE_EXTENSION_PLUGINS = [
|
||||
'grafana-lokiexplore-app',
|
||||
'grafana-exploretraces-app',
|
||||
'grafana-metricsdrilldown-app',
|
||||
'grafana-pyroscope-app',
|
||||
'grafana-monitoring-app',
|
||||
'grafana-troubleshooting-app',
|
||||
];
|
||||
|
||||
@@ -6394,7 +6394,8 @@
|
||||
},
|
||||
"data-source-testing-status-page": {
|
||||
"error-more-details-link": "Click <2>here</2> to learn more about this error.",
|
||||
"success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard</2>, or by querying data in the <5>Explore view</5>."
|
||||
"success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard</2>, or by querying data in the <5>Explore view</5>.",
|
||||
"success-more-details-links-extensions": "You can also explore data with the following extensions:"
|
||||
},
|
||||
"data-sources": {
|
||||
"datasource-add-button": {
|
||||
@@ -6533,7 +6534,8 @@
|
||||
},
|
||||
"edit-data-source-actions": {
|
||||
"build-a-dashboard": "Build a dashboard",
|
||||
"explore-data": "Explore data"
|
||||
"explore-data": "Explore data",
|
||||
"open-in-explore": "Open in Explore View"
|
||||
},
|
||||
"error-details-link": {
|
||||
"aria-label-more-details-about-the-error": "More details about the error"
|
||||
|
||||
Reference in New Issue
Block a user