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
pull/99337/head
Syerikjan Kh 5 months ago committed by GitHub
parent b11d3bc045
commit 97d8f68b70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 8
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 16
      pkg/services/featuremgmt/toggles_gen.json
  7. 386
      public/app/features/plugins/admin/__mocks__/catalogPlugin.mock.ts
  8. 127
      public/app/features/plugins/admin/components/ConnectionsTab.test.tsx
  9. 107
      public/app/features/plugins/admin/components/ConnectionsTab.tsx
  10. 18
      public/app/features/plugins/admin/components/PluginDetailsBody.test.tsx
  11. 16
      public/app/features/plugins/admin/components/PluginDetailsBody.tsx
  12. 13
      public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
  13. 1
      public/app/features/plugins/admin/hooks/usePluginConfig.tsx
  14. 10
      public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx
  15. 2
      public/app/features/plugins/admin/types.ts
  16. 3
      public/locales/en-US/grafana.json
  17. 3
      public/locales/pseudo-LOCALE/grafana.json

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
235 queryLibraryDashboards experimental @grafana/grafana-frontend-platform false false false
236 grafanaAdvisor experimental @grafana/plugins-platform-backend false false false
237 elasticsearchImprovedParsing experimental @grafana/aws-datasources false false false
238 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čįęş",

Loading…
Cancel
Save