Feat: Data source connections tab in plugin detail page (#99049)
* feat: datasource connections tab in plugin detail page * test: no ds defined test added * feat: configure feature toggle * chore: i18n extract * test: added unit tests for page and list
This commit is contained in:
@@ -235,6 +235,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `queryLibraryDashboards` | Enables Query Library feature in Dashboards |
|
||||
| `grafanaAdvisor` | Enables Advisor app |
|
||||
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
|
||||
| `datasourceConnectionsTab` | Shows defined connections for a data source in the plugins detail page |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -254,4 +254,5 @@ export interface FeatureToggles {
|
||||
queryLibraryDashboards?: boolean;
|
||||
grafanaAdvisor?: boolean;
|
||||
elasticsearchImprovedParsing?: boolean;
|
||||
datasourceConnectionsTab?: boolean;
|
||||
}
|
||||
|
||||
@@ -1759,6 +1759,14 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: awsDatasourcesSquad,
|
||||
},
|
||||
{
|
||||
Name: "datasourceConnectionsTab",
|
||||
Description: "Shows defined connections for a data source in the plugins detail page",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
RequiresDevMode: false,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -235,3 +235,4 @@ ABTestFeatureToggleB,experimental,@grafana/sharing-squad,false,false,false
|
||||
queryLibraryDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
grafanaAdvisor,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
|
||||
datasourceConnectionsTab,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
|
||||
|
@@ -950,4 +950,8 @@ const (
|
||||
// FlagElasticsearchImprovedParsing
|
||||
// Enables less memory intensive Elasticsearch result parsing
|
||||
FlagElasticsearchImprovedParsing = "elasticsearchImprovedParsing"
|
||||
|
||||
// FlagDatasourceConnectionsTab
|
||||
// Shows defined connections for a data source in the plugins detail page
|
||||
FlagDatasourceConnectionsTab = "datasourceConnectionsTab"
|
||||
)
|
||||
|
||||
@@ -1155,6 +1155,22 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "datasourceConnectionsTab",
|
||||
"resourceVersion": "1737049826022",
|
||||
"creationTimestamp": "2025-01-16T17:36:09Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-01-16 17:50:26.022636488 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Shows defined connections for a data source in the plugins detail page",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/plugins-platform-backend",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "datasourceProxyDisableRBAC",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,127 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
|
||||
import { getMockDataSources } from 'app/features/datasources/__mocks__';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { datasourcePlugin } from '../__mocks__/catalogPlugin.mock';
|
||||
|
||||
import ConnectionsTab, { ConnectionsList } from './ConnectionsTab';
|
||||
|
||||
jest.mock('app/features/datasources/state', () => ({
|
||||
...jest.requireActual('app/features/datasources/state'),
|
||||
useLoadDataSource: jest.fn().mockReturnValue({ isLoading: false }),
|
||||
getDataSources: jest.fn(() => 'getDataSources mock implementation'),
|
||||
}));
|
||||
|
||||
const setupContextSrv = () => {
|
||||
const testContextSrv = new ContextSrv();
|
||||
testContextSrv.user.permissions = {
|
||||
[AccessControlAction.DataSourcesCreate]: true,
|
||||
[AccessControlAction.DataSourcesWrite]: true,
|
||||
[AccessControlAction.DataSourcesExplore]: true,
|
||||
};
|
||||
|
||||
setContextSrv(testContextSrv);
|
||||
};
|
||||
|
||||
describe('<ConnectionsTab>', () => {
|
||||
const oldExporeEnabled = config.exploreEnabled;
|
||||
const olddatasourceConnectionsTab = config.featureToggles.datasourceConnectionsTab;
|
||||
config.exploreEnabled = true;
|
||||
config.featureToggles.datasourceConnectionsTab = true;
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.exploreEnabled = oldExporeEnabled;
|
||||
config.featureToggles.datasourceConnectionsTab = olddatasourceConnectionsTab;
|
||||
});
|
||||
|
||||
it('should onnly render list of datasources with type=plugin.id', async () => {
|
||||
setupContextSrv();
|
||||
const mockedConnections = getMockDataSources(3, { type: datasourcePlugin.id });
|
||||
mockedConnections[2].type = 'other-plugin-id';
|
||||
jest.requireMock('app/features/datasources/state').getDataSources.mockReturnValue(mockedConnections);
|
||||
|
||||
render(<ConnectionsTab plugin={datasourcePlugin} />);
|
||||
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(2);
|
||||
expect(await screen.findAllByRole('heading')).toHaveLength(2);
|
||||
expect(await screen.findByRole('link', { name: /Connections - Data sources/i })).toBeVisible();
|
||||
expect(await screen.findAllByRole('link', { name: /Build a dashboard/i })).toHaveLength(2);
|
||||
expect(await screen.findAllByRole('link', { name: 'Explore' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render add new datasource button when no datasources are defined', async () => {
|
||||
setupContextSrv();
|
||||
jest.requireMock('app/features/datasources/state').getDataSources.mockReturnValue(getMockDataSources(1));
|
||||
render(<ConnectionsTab plugin={datasourcePlugin} />);
|
||||
|
||||
expect(screen.getByText('Add new data source')).toBeVisible();
|
||||
expect(screen.getByText(`No data sources defined`)).toBeVisible();
|
||||
});
|
||||
|
||||
describe('<ConnectionsList>', () => {
|
||||
it('should render list of datasources', async () => {
|
||||
const dss = getMockDataSources(2, { type: datasourcePlugin.id });
|
||||
render(
|
||||
<ConnectionsList
|
||||
plugin={datasourcePlugin}
|
||||
hasExploreRights={true}
|
||||
isLoading={false}
|
||||
hasWriteRights={true}
|
||||
dataSources={dss}
|
||||
dataSourcesCount={dss.length}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(2);
|
||||
expect(await screen.findAllByRole('heading')).toHaveLength(2);
|
||||
expect(await screen.findByRole('link', { name: /Connections - Data sources/i })).toBeVisible();
|
||||
expect(await screen.findAllByRole('link', { name: /Build a dashboard/i })).toHaveLength(2);
|
||||
expect(await screen.findAllByRole('link', { name: 'Explore' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not render Explore button when user has no access', async () => {
|
||||
const dss = getMockDataSources(2, { type: datasourcePlugin.id });
|
||||
render(
|
||||
<ConnectionsList
|
||||
plugin={datasourcePlugin}
|
||||
hasExploreRights={false}
|
||||
isLoading={false}
|
||||
hasWriteRights={true}
|
||||
dataSources={dss}
|
||||
dataSourcesCount={dss.length}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(2);
|
||||
expect(await screen.findAllByRole('heading')).toHaveLength(2);
|
||||
expect(await screen.findByRole('link', { name: /Connections - Data sources/i })).toBeVisible();
|
||||
expect(await screen.findAllByRole('link', { name: /Build a dashboard/i })).toHaveLength(2);
|
||||
expect(screen.queryAllByRole('link', { name: 'Explore' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render add new datasource button when no datasources are defined', async () => {
|
||||
const dss = [] as DataSourceSettings[];
|
||||
render(
|
||||
<ConnectionsList
|
||||
plugin={datasourcePlugin}
|
||||
hasExploreRights={true}
|
||||
isLoading={false}
|
||||
hasWriteRights={true}
|
||||
dataSources={dss}
|
||||
dataSourcesCount={dss.length}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add new data source')).toBeVisible();
|
||||
expect(screen.getByText(`No data sources defined`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { EmptyState, Stack, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { ViewProps } from 'app/features/datasources/components/DataSourcesList';
|
||||
import { DataSourcesListCard } from 'app/features/datasources/components/DataSourcesListCard';
|
||||
import { getDataSources, useLoadDataSources } from 'app/features/datasources/state';
|
||||
import { AccessControlAction, useSelector } from 'app/types';
|
||||
|
||||
import { CatalogPlugin } from '../types';
|
||||
|
||||
import { GetStartedWithDataSource } from './GetStartedWithPlugin/GetStartedWithDataSource';
|
||||
|
||||
interface Props {
|
||||
plugin: CatalogPlugin;
|
||||
}
|
||||
|
||||
export default function ConnectionsTab({ plugin }: Props) {
|
||||
const { isLoading } = useLoadDataSources();
|
||||
|
||||
const allDataSources = useSelector((state) => getDataSources(state.dataSources));
|
||||
const dataSources = allDataSources.filter((ds) => ds.type === plugin.id);
|
||||
const hasWriteRights = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
|
||||
const hasExploreRights = contextSrv.hasAccessToExplore();
|
||||
|
||||
return (
|
||||
<ConnectionsList
|
||||
dataSources={dataSources}
|
||||
dataSourcesCount={dataSources.length}
|
||||
isLoading={isLoading}
|
||||
plugin={plugin}
|
||||
hasWriteRights={hasWriteRights}
|
||||
hasExploreRights={hasExploreRights}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ListProps = Omit<ViewProps, 'hasCreateRights'> & {
|
||||
plugin: CatalogPlugin;
|
||||
};
|
||||
|
||||
export function ConnectionsList({
|
||||
dataSources,
|
||||
dataSourcesCount,
|
||||
isLoading,
|
||||
hasWriteRights,
|
||||
hasExploreRights,
|
||||
plugin,
|
||||
}: ListProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!isLoading && dataSourcesCount === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="call-to-action"
|
||||
button={<GetStartedWithDataSource plugin={plugin} />}
|
||||
message={t('data-source-list.empty-state.title', 'No data sources defined')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getDataSourcesList = () => {
|
||||
if (isLoading) {
|
||||
return new Array(5)
|
||||
.fill(null)
|
||||
.map((_, index) => <DataSourcesListCard.Skeleton key={index} hasExploreRights={hasExploreRights} />);
|
||||
}
|
||||
|
||||
return dataSources.map((dataSource) => (
|
||||
<li key={dataSource.uid}>
|
||||
<DataSourcesListCard
|
||||
dataSource={dataSource}
|
||||
hasWriteRights={hasWriteRights}
|
||||
hasExploreRights={hasExploreRights}
|
||||
/>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<span>
|
||||
<Trans i18nKey="plugins.details.connections-tab.description" values={{ pluginName: plugin.name }}>
|
||||
The data source connections below are all {'{{pluginName}}'}. You can find all of your data source connections
|
||||
of all types in{' '}
|
||||
<TextLink href="/connections/datasources">
|
||||
<Trans i18nKey="nav.connections.title">Connections</Trans> -{' '}
|
||||
<Trans i18nKey="nav.data-sources.title">Data sources</Trans>.
|
||||
</TextLink>
|
||||
</Trans>
|
||||
</span>
|
||||
<ul className={styles.list}>{getDataSourcesList()}</ul>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
list: css({
|
||||
listStyle: 'none',
|
||||
display: 'grid',
|
||||
// gap: '8px', Add back when legacy support for old Card interface is dropped
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -99,4 +99,22 @@ describe('PluginDetailsBody', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render data source connections tab content for installed data source plugin', async () => {
|
||||
const plugin = getCatalogPluginMock({ type: PluginType.datasource });
|
||||
config.featureToggles.datasourceConnectionsTab = true;
|
||||
await act(async () => {
|
||||
renderWithStore(
|
||||
<PluginDetailsBody
|
||||
plugin={plugin}
|
||||
info={[]}
|
||||
queryParams={{}}
|
||||
pageId={PluginTabIds.DATASOURCE_CONNECTIONS}
|
||||
showDetails={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('No data sources defined')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AppPlugin, GrafanaTheme2, PluginContextProvider, UrlQueryMap } from '@grafana/data';
|
||||
import { AppPlugin, GrafanaTheme2, PluginContextProvider, UrlQueryMap, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage';
|
||||
import { CellProps, Column, InteractiveTable, Stack, useStyles2 } from '@grafana/ui';
|
||||
@@ -14,6 +14,7 @@ import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
import { CatalogPlugin, Permission, PluginTabIds } from '../types';
|
||||
|
||||
import { AppConfigCtrlWrapper } from './AppConfigWrapper';
|
||||
import Connections from './ConnectionsTab';
|
||||
import { PluginDashboards } from './PluginDashboards';
|
||||
import { PluginUsage } from './PluginUsage';
|
||||
|
||||
@@ -30,7 +31,6 @@ type Cell<T extends keyof Permission = keyof Permission> = CellProps<Permission,
|
||||
export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetails }: Props): JSX.Element {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { value: pluginConfig } = usePluginConfig(plugin);
|
||||
|
||||
const columns: Array<Column<Permission>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -91,6 +91,18 @@ export function PluginDetailsBody({ plugin, queryParams, pageId, info, showDetai
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
config.featureToggles.datasourceConnectionsTab &&
|
||||
pageId === PluginTabIds.DATASOURCE_CONNECTIONS &&
|
||||
plugin.type === PluginType.datasource
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<Connections plugin={plugin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Permissions will be returned in the iam field for installed plugins and in the details.iam field when fetching details from gcom
|
||||
const permissions = plugin.iam?.permissions || plugin.details?.iam?.permissions;
|
||||
|
||||
|
||||
@@ -76,7 +76,12 @@ jest.mock('../state/hooks', () => ({
|
||||
useFetchDetailsLazy: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePluginConfig', () => ({
|
||||
usePluginConfig: jest.fn().mockReturnValue({ value: {}, loading: false }),
|
||||
}));
|
||||
|
||||
const mockUseGetSingle = jest.requireMock('../state/hooks').useGetSingle;
|
||||
const mockUsePluginConfig = jest.requireMock('../hooks/usePluginConfig').usePluginConfig;
|
||||
|
||||
describe('PluginDetailsPage', () => {
|
||||
beforeEach(() => {
|
||||
@@ -141,4 +146,12 @@ describe('PluginDetailsPage', () => {
|
||||
render(<PluginDetailsPage pluginId="test-plugin" />);
|
||||
expect(screen.getByRole('tab', { name: 'Plugin details' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Datasource connections" tab when plugin is type of datasource', () => {
|
||||
config.featureToggles.datasourceConnectionsTab = true;
|
||||
mockUseGetSingle.mockReturnValue({ ...plugin, type: PluginType.datasource });
|
||||
mockUsePluginConfig.mockReturnValue({ value: {}, loading: false });
|
||||
render(<PluginDetailsPage pluginId={plugin.id} />);
|
||||
expect(screen.getByRole('tab', { name: 'Data source connections' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
|
||||
config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall
|
||||
? plugin.isFullyInstalled
|
||||
: plugin.isInstalled;
|
||||
|
||||
if (isPluginInstalled && !plugin.isDisabled) {
|
||||
return loadPlugin(plugin.id);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,16 @@ export const usePluginDetailsTabs = (
|
||||
});
|
||||
}
|
||||
|
||||
if (config.featureToggles.datasourceConnectionsTab && plugin?.type === PluginType.datasource) {
|
||||
navModelChildren.push({
|
||||
text: PluginTabLabels.DATASOURCE_CONNECTIONS,
|
||||
icon: 'database',
|
||||
id: PluginTabIds.DATASOURCE_CONNECTIONS,
|
||||
url: `${pathname}?page=${PluginTabIds.DATASOURCE_CONNECTIONS}`,
|
||||
active: PluginTabIds.DATASOURCE_CONNECTIONS === currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canConfigurePlugins) {
|
||||
return navModelChildren;
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ export enum PluginTabLabels {
|
||||
IAM = 'IAM',
|
||||
CHANGELOG = 'Changelog',
|
||||
PLUGINDETAILS = 'Plugin details',
|
||||
DATASOURCE_CONNECTIONS = 'Data source connections',
|
||||
}
|
||||
|
||||
export enum PluginTabIds {
|
||||
@@ -268,6 +269,7 @@ export enum PluginTabIds {
|
||||
IAM = 'iam',
|
||||
CHANGELOG = 'changelog',
|
||||
PLUGINDETAILS = 'right-panel',
|
||||
DATASOURCE_CONNECTIONS = 'datasource-connections',
|
||||
}
|
||||
|
||||
export enum RequestStatus {
|
||||
|
||||
@@ -2540,6 +2540,9 @@
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"connections-tab": {
|
||||
"description": "The data source connections below are all {{pluginName}}. You can find all of your data source connections of all types in <4><0>Connections</0> - <3>Data sources</3>.</4>"
|
||||
},
|
||||
"labels": {
|
||||
"contactGrafanaLabs": "Contact Grafana Labs",
|
||||
"dependencies": "Dependencies",
|
||||
|
||||
@@ -2540,6 +2540,9 @@
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"connections-tab": {
|
||||
"description": "Ŧĥę đäŧä şőūřčę čőʼnʼnęčŧįőʼnş þęľőŵ äřę äľľ {{pluginName}}. Ÿőū čäʼn ƒįʼnđ äľľ őƒ yőūř đäŧä şőūřčę čőʼnʼnęčŧįőʼnş őƒ äľľ ŧypęş įʼn <4><0>Cőʼnʼnęčŧįőʼnş</0> - <3>Đäŧä şőūřčęş</3>.</4>"
|
||||
},
|
||||
"labels": {
|
||||
"contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş",
|
||||
"dependencies": "Đępęʼnđęʼnčįęş",
|
||||
|
||||
Reference in New Issue
Block a user