New link extension point for DatasourceTestingStatus (#107571)

This commit is contained in:
Andres Martinez Gotor
2025-07-14 15:51:19 +02:00
committed by GitHub
parent 8eef17cb37
commit d1f785c8bf
7 changed files with 239 additions and 20 deletions

View File

@ -561,6 +561,7 @@ export {
type ComponentTypeWithExtensionMeta, type ComponentTypeWithExtensionMeta,
type PluginExtensionFunction, type PluginExtensionFunction,
type PluginExtensionEventHelpers, type PluginExtensionEventHelpers,
type DataSourceConfigErrorStatusContext,
type PluginExtensionPanelContext, type PluginExtensionPanelContext,
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
type PluginExtensionDataSourceConfigContext, type PluginExtensionDataSourceConfigContext,

View File

@ -188,6 +188,7 @@ export enum PluginExtensionPoints {
CommandPalette = 'grafana/commandpalette/action', CommandPalette = 'grafana/commandpalette/action',
DashboardPanelMenu = 'grafana/dashboard/panel/menu', DashboardPanelMenu = 'grafana/dashboard/panel/menu',
DataSourceConfig = 'grafana/datasources/config', DataSourceConfig = 'grafana/datasources/config',
DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status',
ExploreToolbarAction = 'grafana/explore/toolbar/action', ExploreToolbarAction = 'grafana/explore/toolbar/action',
UserProfileTab = 'grafana/user/profile/tab', UserProfileTab = 'grafana/user/profile/tab',
TraceViewDetails = 'grafana/traceview/details', 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<string, unknown>;
};
};
type Dashboard = { type Dashboard = {
uid: string; uid: string;
title: string; title: string;

View File

@ -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 { getMockDataSource } from '../mocks/dataSourcesMocks';
import { DataSourceTestingStatus, Props } from './DataSourceTestingStatus'; import { DataSourceTestingStatus, Props } from './DataSourceTestingStatus';
setPluginLinksHook(() => ({ links: [], isLoading: false }));
const getProps = (partialProps?: Partial<Props>): Props => ({ const getProps = (partialProps?: Partial<Props>): Props => ({
testingStatus: { testingStatus: {
status: 'success', status: 'success',
@ -89,4 +94,106 @@ describe('<DataSourceTestingStatus />', () => {
expect(() => screen.getByTestId('data-testid Alert success')).toThrow(); expect(() => screen.getByTestId('data-testid Alert success')).toThrow();
expect(() => screen.getByTestId('data-testid Alert error')).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(<DataSourceTestingStatus {...props} />);
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(<DataSourceTestingStatus {...props} />);
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(<DataSourceTestingStatus {...props} />);
expect(screen.queryByText('Help Documentation')).not.toBeInTheDocument();
});
});
}); });

View File

@ -1,10 +1,11 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { HTMLAttributes } from 'react'; 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 { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n'; 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 { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui';
import { contextSrv } from '../../../core/core'; import { contextSrv } from '../../../core/core';
@ -147,6 +148,18 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
}); });
}; };
const styles = useStyles2(getTestingStatusStyles); 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) { if (message) {
return ( return (
@ -169,6 +182,22 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
) : null} ) : null}
</> </>
)} )}
{severity === 'error' && links.length > 0 && (
<div className={styles.linksContainer}>
{links.map((link) => {
return (
<a
key={link.id}
href={link.path ? sanitizeUrl(link.path) : undefined}
onClick={link.onClick}
className={styles.pluginLink}
>
{link.title}
</a>
);
})}
</div>
)}
</Alert> </Alert>
</div> </div>
); );
@ -184,4 +213,23 @@ const getTestingStatusStyles = (theme: GrafanaTheme2) => ({
moreLink: css({ moreLink: css({
marginBlock: theme.spacing(1), 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,
},
}),
}); });

View File

@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { DataSourceJsonData, PluginExtensionDataSourceConfigContext, PluginState } from '@grafana/data'; 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 { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
@ -27,6 +27,8 @@ jest.mock('@grafana/runtime', () => {
}; };
}); });
setPluginLinksHook(() => ({ links: [], isLoading: false }));
const setup = (props?: Partial<ViewProps>) => { const setup = (props?: Partial<ViewProps>) => {
const store = configureStore(); const store = configureStore();

View File

@ -365,6 +365,21 @@ describe('Plugin Extension Validators', () => {
expect(log.warning).toHaveBeenCalledTimes(1); expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); 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()', () => { describe('isAddedComponentMetaInfoMissing()', () => {
@ -482,6 +497,21 @@ describe('Plugin Extension Validators', () => {
expect(log.warning).toHaveBeenCalledTimes(1); expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); 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()', () => { describe('isExposedComponentMetaInfoMissing()', () => {
@ -600,6 +630,21 @@ describe('Plugin Extension Validators', () => {
expect(log.warning).toHaveBeenCalledTimes(1); expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match'); 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()', () => { describe('isExposedComponentDependencyMissing()', () => {

View File

@ -155,25 +155,25 @@ export const isAddedLinkMetaInfoMissing = (
) => { ) => {
const logPrefix = 'Could not register link extension. Reason:'; const logPrefix = 'Could not register link extension. Reason:';
const app = config.apps[pluginId]; 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) { if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true; return true;
} }
if (!pluginJsonMetaInfo) { if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) {
log.error(`${logPrefix} ${errors.ADDED_LINK_META_INFO_MISSING}`); log.error(`${logPrefix} ${errors.ADDED_LINK_META_INFO_MISSING}`);
return true; return true;
} }
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; 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}`); log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true; return true;
} }
if (pluginJsonMetaInfo.description !== metaInfo.description) { if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
} }
@ -187,25 +187,25 @@ export const isAddedFunctionMetaInfoMissing = (
) => { ) => {
const logPrefix = 'Could not register function extension. Reason:'; const logPrefix = 'Could not register function extension. Reason:';
const app = config.apps[pluginId]; 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) { if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true; return true;
} }
if (!pluginJsonMetaInfo) { if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) {
log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`); log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`);
return true; return true;
} }
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; 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}`); log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true; return true;
} }
if (pluginJsonMetaInfo.description !== metaInfo.description) { if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
} }
@ -219,25 +219,27 @@ export const isAddedComponentMetaInfoMissing = (
) => { ) => {
const logPrefix = 'Could not register component extension. Reason:'; const logPrefix = 'Could not register component extension. Reason:';
const app = config.apps[pluginId]; 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) { if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true; return true;
} }
if (!pluginJsonMetaInfo) { if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) {
log.error(`${logPrefix} ${errors.ADDED_COMPONENT_META_INFO_MISSING}`); log.error(`${logPrefix} ${errors.ADDED_COMPONENT_META_INFO_MISSING}`);
return true; return true;
} }
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; 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}`); log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true; return true;
} }
if (pluginJsonMetaInfo.description !== metaInfo.description) { if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); 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 logPrefix = 'Could not register exposed component extension. Reason:';
const app = config.apps[pluginId]; 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) { if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true; return true;
} }
if (!pluginJsonMetaInfo) { if (!pluginJsonMetaInfo || pluginJsonMetaInfo.length === 0) {
log.error(`${logPrefix} ${errors.EXPOSED_COMPONENT_META_INFO_MISSING}`); log.error(`${logPrefix} ${errors.EXPOSED_COMPONENT_META_INFO_MISSING}`);
return true; return true;
} }
if (pluginJsonMetaInfo.title !== metaInfo.title) { if (pluginJsonMetaInfo.some(({ title }) => title !== metaInfo.title)) {
log.error(`${logPrefix} ${errors.TITLE_NOT_MATCHING_META_INFO}`); log.error(`${logPrefix} ${errors.TITLE_NOT_MATCHING_META_INFO}`);
return true; return true;
} }
if (pluginJsonMetaInfo.description !== metaInfo.description) { if (pluginJsonMetaInfo.some(({ description }) => description !== metaInfo.description)) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
} }