From d1f785c8bfbeedabed0485b9f293828d095f9f77 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Mon, 14 Jul 2025 15:51:19 +0200 Subject: [PATCH] New link extension point for DatasourceTestingStatus (#107571) --- packages/grafana-data/src/index.ts | 1 + .../src/types/pluginExtensions.ts | 14 +++ .../DataSourceTestingStatus.test.tsx | 109 +++++++++++++++++- .../components/DataSourceTestingStatus.tsx | 52 ++++++++- .../components/EditDataSource.test.tsx | 4 +- .../plugins/extensions/validators.test.tsx | 45 ++++++++ .../features/plugins/extensions/validators.ts | 34 +++--- 7 files changed, 239 insertions(+), 20 deletions(-) diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index c51f2f17c7c..23414822473 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -561,6 +561,7 @@ export { type ComponentTypeWithExtensionMeta, type PluginExtensionFunction, type PluginExtensionEventHelpers, + type DataSourceConfigErrorStatusContext, type PluginExtensionPanelContext, type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionDataSourceConfigContext, diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 83523e4b595..919ded3d92a 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -188,6 +188,7 @@ export enum PluginExtensionPoints { CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', + DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status', ExploreToolbarAction = 'grafana/explore/toolbar/action', UserProfileTab = 'grafana/user/profile/tab', TraceViewDetails = 'grafana/traceview/details', @@ -249,6 +250,19 @@ export type PluginExtensionResourceAttributesContext = { }; }; +export type DataSourceConfigErrorStatusContext = { + dataSource: { + type: string; + uid: string; + name: string; + }; + testingStatus: { + message?: string | null; + status?: string | null; + details?: Record; + }; +}; + type Dashboard = { uid: string; title: string; diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx index 1c42b13b080..dec9658d72f 100644 --- a/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx +++ b/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx @@ -1,9 +1,14 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { PluginExtensionTypes } from '@grafana/data'; +import { setPluginLinksHook } from '@grafana/runtime'; import { getMockDataSource } from '../mocks/dataSourcesMocks'; import { DataSourceTestingStatus, Props } from './DataSourceTestingStatus'; +setPluginLinksHook(() => ({ links: [], isLoading: false })); + const getProps = (partialProps?: Partial): Props => ({ testingStatus: { status: 'success', @@ -89,4 +94,106 @@ describe('', () => { expect(() => screen.getByTestId('data-testid Alert success')).toThrow(); expect(() => screen.getByTestId('data-testid Alert error')).toThrow(); }); + + describe('Plugin links', () => { + // 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; + }> = {} + ) => ({ + id: 'test-link', + type: PluginExtensionTypes.link as const, + title: 'Test Link', + description: 'Test link description', + pluginId: 'test-plugin', + path: '/test', + onClick: jest.fn(), + ...overrides, + }); + + 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' }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + const props = getProps({ + testingStatus: { + status: 'error', + message: 'Data source connection failed', + }, + }); + + render(); + + expect(screen.getByText('Help Documentation')).toBeInTheDocument(); + expect(screen.getByText('Troubleshooting Guide')).toBeInTheDocument(); + + const helpLink = screen.getByText('Help Documentation').closest('a'); + const troubleshootLink = screen.getByText('Troubleshooting Guide').closest('a'); + + expect(helpLink).toHaveAttribute('href', 'http://example.com/help'); + expect(troubleshootLink).toHaveAttribute('href', 'http://example.com/troubleshoot'); + }); + + it('should call onClick handler when plugin link is clicked', () => { + const mockOnClick = jest.fn(); + const mockLinks = [ + createMockPluginLink({ + id: 'link1', + path: 'http://example.com/help', + onClick: mockOnClick, + title: 'Help Documentation', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + const props = getProps({ + testingStatus: { + status: 'error', + message: 'Data source connection failed', + }, + }); + + render(); + + const helpLink = screen.getByText('Help Documentation'); + fireEvent.click(helpLink); + + 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' }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + const props = getProps({ + testingStatus: { + status: 'success', + message: 'Data source is working', + }, + }); + + render(); + + expect(screen.queryByText('Help Documentation')).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.tsx index f1e6463e019..813e2e55aa8 100644 --- a/public/app/features/datasources/components/DataSourceTestingStatus.tsx +++ b/public/app/features/datasources/components/DataSourceTestingStatus.tsx @@ -1,10 +1,11 @@ import { css, cx } from '@emotion/css'; import { HTMLAttributes } from 'react'; -import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2 } from '@grafana/data'; +import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2, PluginExtensionPoints } from '@grafana/data'; +import { sanitizeUrl } from '@grafana/data/internal'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { TestingStatus, config } from '@grafana/runtime'; +import { TestingStatus, config, usePluginLinks } from '@grafana/runtime'; import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui'; import { contextSrv } from '../../../core/core'; @@ -147,6 +148,18 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource }); }; const styles = useStyles2(getTestingStatusStyles); + const { links } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.DataSourceConfigErrorStatus, + context: { + dataSource: { + type: dataSource.type, + uid: dataSource.uid, + name: dataSource.name, + }, + testingStatus, + }, + limitPerPlugin: 3, + }); if (message) { return ( @@ -169,6 +182,22 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource ) : null} )} + {severity === 'error' && links.length > 0 && ( +
+ {links.map((link) => { + return ( + + {link.title} + + ); + })} +
+ )} ); @@ -184,4 +213,23 @@ const getTestingStatusStyles = (theme: GrafanaTheme2) => ({ moreLink: css({ marginBlock: theme.spacing(1), }), + linksContainer: css({ + display: 'flex', + justifyContent: 'flex-end', + marginTop: theme.spacing(1), + }), + pluginLink: css({ + color: theme.colors.text.link, + textDecoration: 'none', + marginLeft: theme.spacing(2), + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + '&:hover': { + color: theme.colors.text.primary, + textDecoration: 'underline', + }, + '&:first-child': { + marginLeft: 0, + }, + }), }); diff --git a/public/app/features/datasources/components/EditDataSource.test.tsx b/public/app/features/datasources/components/EditDataSource.test.tsx index 3631edc2e0a..6a1f6832485 100644 --- a/public/app/features/datasources/components/EditDataSource.test.tsx +++ b/public/app/features/datasources/components/EditDataSource.test.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { Provider } from 'react-redux'; import { DataSourceJsonData, PluginExtensionDataSourceConfigContext, PluginState } from '@grafana/data'; -import { setPluginComponentsHook } from '@grafana/runtime'; +import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents'; import { configureStore } from 'app/store/configureStore'; @@ -27,6 +27,8 @@ jest.mock('@grafana/runtime', () => { }; }); +setPluginLinksHook(() => ({ links: [], isLoading: false })); + const setup = (props?: Partial) => { const store = configureStore(); diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 46069467c44..1b7e71eba7b 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -365,6 +365,21 @@ describe('Plugin Extension Validators', () => { expect(log.warning).toHaveBeenCalledTimes(1); expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); }); + + it('should return FALSE with links with the same title but different targets', () => { + const log = createLogMock(); + config.apps[pluginId].extensions.addedLinks.push(extensionConfig); + const extensionConfig2 = { + ...extensionConfig, + targets: [PluginExtensionPoints.ExploreToolbarAction], + }; + config.apps[pluginId].extensions.addedLinks.push(extensionConfig2); + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig2, log); + + expect(returnValue).toBe(false); + expect(log.error).toHaveBeenCalledTimes(0); + }); }); describe('isAddedComponentMetaInfoMissing()', () => { @@ -482,6 +497,21 @@ describe('Plugin Extension Validators', () => { expect(log.warning).toHaveBeenCalledTimes(1); expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); }); + + it('should return FALSE with components with the same title but different targets', () => { + const log = createLogMock(); + config.apps[pluginId].extensions.addedComponents.push(extensionConfig); + const extensionConfig2 = { + ...extensionConfig, + targets: [PluginExtensionPoints.ExploreToolbarAction], + }; + config.apps[pluginId].extensions.addedComponents.push(extensionConfig2); + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig2, log); + + expect(returnValue).toBe(false); + expect(log.error).toHaveBeenCalledTimes(0); + }); }); describe('isExposedComponentMetaInfoMissing()', () => { @@ -600,6 +630,21 @@ describe('Plugin Extension Validators', () => { expect(log.warning).toHaveBeenCalledTimes(1); expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); }); + + it('should return FALSE with components with the same title but different targets', () => { + const log = createLogMock(); + config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); + const exposedComponentConfig2 = { + ...exposedComponentConfig, + targets: [PluginExtensionPoints.ExploreToolbarAction], + }; + config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig2); + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig2, log); + + expect(returnValue).toBe(false); + expect(log.error).toHaveBeenCalledTimes(0); + }); }); describe('isExposedComponentDependencyMissing()', () => { diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 6a6a7b51748..081ac98fbab 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -155,25 +155,25 @@ export const isAddedLinkMetaInfoMissing = ( ) => { const logPrefix = 'Could not register link extension. Reason:'; const app = config.apps[pluginId]; - const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null; + const pluginJsonMetaInfo = app ? app.extensions.addedLinks.filter(({ title }) => title === metaInfo.title) : null; if (!app) { log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); return true; } - if (!pluginJsonMetaInfo) { + if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) { log.error(`${logPrefix} ${errors.ADDED_LINK_META_INFO_MISSING}`); return true; } const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; - if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + if (!targets.every((target) => pluginJsonMetaInfo.some(({ targets }) => targets.includes(target)))) { log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); return true; } - if (pluginJsonMetaInfo.description !== metaInfo.description) { + if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) { log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); } @@ -187,25 +187,25 @@ export const isAddedFunctionMetaInfoMissing = ( ) => { const logPrefix = 'Could not register function extension. Reason:'; const app = config.apps[pluginId]; - const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null; + const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.filter(({ title }) => title === metaInfo.title) : null; if (!app) { log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); return true; } - if (!pluginJsonMetaInfo) { + if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) { log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`); return true; } const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; - if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + if (!targets.every((target) => pluginJsonMetaInfo.some(({ targets }) => targets.includes(target)))) { log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); return true; } - if (pluginJsonMetaInfo.description !== metaInfo.description) { + if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) { log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); } @@ -219,25 +219,27 @@ export const isAddedComponentMetaInfoMissing = ( ) => { const logPrefix = 'Could not register component extension. Reason:'; const app = config.apps[pluginId]; - const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null; + const pluginJsonMetaInfo = app + ? app.extensions.addedComponents.filter(({ title }) => title === metaInfo.title) + : null; if (!app) { log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); return true; } - if (!pluginJsonMetaInfo) { + if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) { log.error(`${logPrefix} ${errors.ADDED_COMPONENT_META_INFO_MISSING}`); return true; } const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; - if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + if (!targets.every((target) => pluginJsonMetaInfo.some(({ targets }) => targets.includes(target)))) { log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); return true; } - if (pluginJsonMetaInfo.description !== metaInfo.description) { + if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) { log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); } @@ -251,24 +253,24 @@ export const isExposedComponentMetaInfoMissing = ( ) => { const logPrefix = 'Could not register exposed component extension. Reason:'; const app = config.apps[pluginId]; - const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null; + const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.filter(({ id }) => id === metaInfo.id) : null; if (!app) { log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); return true; } - if (!pluginJsonMetaInfo) { + if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) { log.error(`${logPrefix} ${errors.EXPOSED_COMPONENT_META_INFO_MISSING}`); return true; } - if (pluginJsonMetaInfo.title !== metaInfo.title) { + if (pluginJsonMetaInfo.some(({ title }) => title !== metaInfo.title)) { log.error(`${logPrefix} ${errors.TITLE_NOT_MATCHING_META_INFO}`); return true; } - if (pluginJsonMetaInfo.description !== metaInfo.description) { + if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) { log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); }