DataSources: refactor datasource pages to be reusable (#51874)

* refactor: move utility functions out of the redux actions

* refactor: move password handlers to the feature root

* refactor: move API related functions to an api.ts

* refactor: move components under a /components folder

* refactor: move page containers under a /pages folder and extract components

* refactor: update mocks to be easier to reuse

* refactor: move tests into a state/tests/ subfolder

* refactor: expose 'initialState' for plugins

* refactor: move generic types to the root folder of the feature

* refactor: import path fixe

* refactor: update import paths for app routes

* chore: update betterer

* refactor: fix type errors due to changed mock functions

* chore: fix mocking context_srv in tests

* refactor: udpate imports to be more concise

* fix: update failing test because of mocks

* refactor: use the new `navId` prop where we can

* fix: use UID instead ID in datasource edit links

* fix:clean up Redux state when unmounting the edit page

* refactor: use `uid` instead of `id`

* refactor: always fetch the plugin details when editing a datasource

The deleted lines could provide performance benefits, although they also make the
implementation more prone to errors. (Mostly because we are storing the information
about the currently loaded plugin in a single field, and it was not validating if it
is for the latest one).

We are planning to introduce some kind of caching, but first we would like to clean up
the underlying state a bit (plugins & datasources.

* fix: add missing dispatch() wrapper for update datasource callback

* refactor: prefer using absolute import paths

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* fix: ESLINT import order issue

* refactor: put test files next to their files

* refactor: use implicit return types for components

* fix: remove caching from datasource fetching

I have introduced a cache to only fetch data-sources once, however as we
are missing a good logic for updating the instances in the Redux store
when they change (create, update, delete), this approach is not keeping the UI in sync.
Due to this reason I have removed the caching for now, and will reintroduce it once we have a
more robust client-side state logic.

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Levente Balogh 2022-07-20 09:25:09 +02:00 committed by GitHub
parent 9f4683b3d0
commit d6d49d8ba3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2053 additions and 1435 deletions

View File

@ -4756,16 +4756,16 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
],
"public/app/features/datasources/DataSourceDashboards.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/datasources/__mocks__/dataSourcesMocks.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/datasources/settings/ButtonRow.tsx:5381": [
"public/app/features/datasources/components/ButtonRow.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/datasources/passwordHandlers.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/datasources/passwordHandlers.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/datasources/state/actions.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@ -4790,12 +4790,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/datasources/utils/passwordHandlers.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/datasources/utils/passwordHandlers.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dimensions/color.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@ -5466,9 +5460,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/plugins/admin/state/reducer.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/admin/state/selectors.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -1,44 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { RouteDescriptor } from 'app/core/navigation/types';
import { PluginDashboard } from 'app/types';
import { DataSourceDashboards, Props } from './DataSourceDashboards';
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
...getRouteComponentProps(),
navModel: { main: { text: 'nav-text' }, node: { text: 'node-text' } },
dashboards: [] as PluginDashboard[],
dataSource: {} as DataSourceSettings,
dataSourceId: 'x',
importDashboard: jest.fn(),
loadDataSource: jest.fn(),
loadPluginDashboards: jest.fn(),
removeDashboard: jest.fn(),
route: {} as RouteDescriptor,
isLoading: false,
...propOverrides,
};
return render(<DataSourceDashboards {...props} />);
};
describe('Render', () => {
it('should render without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('should render component', () => {
setup();
expect(screen.getByRole('heading', { name: 'nav-text' })).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Documentation' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Community' })).toBeInTheDocument();
});
});

View File

@ -1,89 +0,0 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { PluginDashboard, StoreState } from 'app/types';
import { importDashboard, removeDashboard } from '../dashboard/state/actions';
import { loadPluginDashboards } from '../plugins/admin/state/actions';
import DashboardTable from './DashboardsTable';
import { loadDataSource } from './state/actions';
import { getDataSource } from './state/selectors';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
function mapStateToProps(state: StoreState, props: OwnProps) {
const dataSourceId = props.match.params.uid;
return {
navModel: getNavModel(state.navIndex, `datasource-dashboards-${dataSourceId}`),
dashboards: state.plugins.dashboards,
dataSource: getDataSource(state.dataSources, dataSourceId),
isLoading: state.plugins.isLoadingPluginDashboards,
dataSourceId,
};
}
const mapDispatchToProps = {
importDashboard,
loadDataSource,
loadPluginDashboards,
removeDashboard,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export class DataSourceDashboards extends PureComponent<Props> {
async componentDidMount() {
const { loadDataSource, dataSourceId } = this.props;
await loadDataSource(dataSourceId);
this.props.loadPluginDashboards();
}
onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
const { dataSource, importDashboard } = this.props;
const data: any = {
pluginId: dashboard.pluginId,
path: dashboard.path,
overwrite,
inputs: [],
};
if (dataSource) {
data.inputs.push({
name: '*',
type: 'datasource',
pluginId: dataSource.type,
value: dataSource.name,
});
}
importDashboard(data, dashboard.title);
};
onRemove = (dashboard: PluginDashboard) => {
this.props.removeDashboard(dashboard.uid);
};
render() {
const { dashboards, navModel, isLoading } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
<DashboardTable
dashboards={dashboards}
onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
onRemove={(dashboard) => this.onRemove(dashboard)}
/>
</Page.Contents>
</Page>
);
}
}
export default connector(DataSourceDashboards);

View File

@ -1,54 +0,0 @@
// Libraries
import { css } from '@emotion/css';
import React from 'react';
// Types
import { DataSourceSettings } from '@grafana/data';
import { Card, Tag, useStyles } from '@grafana/ui';
export type Props = {
dataSources: DataSourceSettings[];
};
export const DataSourcesList = ({ dataSources }: Props) => {
const styles = useStyles(getStyles);
return (
<ul className={styles.list}>
{dataSources.map((dataSource) => {
return (
<li key={dataSource.id}>
<Card href={`datasources/edit/${dataSource.uid}`}>
<Card.Heading>{dataSource.name}</Card.Heading>
<Card.Figure>
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />
</Card.Figure>
<Card.Meta>
{[
dataSource.typeName,
dataSource.url,
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
]}
</Card.Meta>
</Card>
</li>
);
})}
</ul>
);
};
export default DataSourcesList;
const getStyles = () => {
return {
list: css({
listStyle: 'none',
display: 'grid',
// gap: '8px', Add back when legacy support for old Card interface is dropped
}),
logo: css({
objectFit: 'contain',
}),
};
};

View File

@ -1,22 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Page } from 'app/core/components/Page/Page';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState } from 'app/types';
import { DataSourcesListPageContent } from './DataSourcesListPageContent';
export const DataSourcesListPage = () => {
const navModel = useSelector(({ navIndex }: StoreState) => getNavModel(navIndex, 'datasources'));
return (
<Page navModel={navModel}>
<Page.Contents>
<DataSourcesListPageContent />
</Page.Contents>
</Page>
);
};
export default DataSourcesListPage;

View File

@ -1,58 +0,0 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconName } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { contextSrv } from 'app/core/core';
import { StoreState, AccessControlAction } from 'app/types';
import DataSourcesList from './DataSourcesList';
import { DataSourcesListHeader } from './DataSourcesListHeader';
import { loadDataSources } from './state/actions';
import { getDataSourcesCount, getDataSources } from './state/selectors';
const buttonIcon: IconName = 'database';
const emptyListModel = {
title: 'No data sources defined',
buttonIcon,
buttonLink: 'datasources/new',
buttonTitle: 'Add data source',
proTip: 'You can also define data sources through configuration files.',
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
proTipLinkTitle: 'Learn more',
proTipTarget: '_blank',
};
export const DataSourcesListPageContent = () => {
const dispatch = useDispatch();
const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources));
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched);
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
const emptyList = {
...emptyListModel,
buttonDisabled: !canCreateDataSource,
};
useEffect(() => {
if (!hasFetched) {
dispatch(loadDataSources());
}
}, [dispatch, hasFetched]);
if (!hasFetched) {
return <PageLoader />;
}
if (dataSourcesCount === 0) {
return <EmptyListCTA {...emptyList} />;
}
return (
<>
<DataSourcesListHeader />
<DataSourcesList dataSources={dataSources} />
</>
);
};

View File

@ -1,244 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { DataSourcePluginMeta, GrafanaTheme2, NavModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, LinkButton, List, PluginSignatureBadge, FilterInput, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { StoreState } from 'app/types';
import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo';
import { addDataSource, loadDataSourcePlugins } from './state/actions';
import { setDataSourceTypeSearchQuery } from './state/reducers';
import { getDataSourcePlugins } from './state/selectors';
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(),
plugins: getDataSourcePlugins(state.dataSources),
searchQuery: state.dataSources.dataSourceTypeSearchQuery,
categories: state.dataSources.categories,
isLoading: state.dataSources.isLoadingDataSources,
};
}
const mapDispatchToProps = {
addDataSource,
loadDataSourcePlugins,
setDataSourceTypeSearchQuery,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
class NewDataSourcePage extends PureComponent<Props> {
componentDidMount() {
this.props.loadDataSourcePlugins();
}
onDataSourceTypeClicked = (plugin: DataSourcePluginMeta) => {
this.props.addDataSource(plugin);
};
onSearchQueryChange = (value: string) => {
this.props.setDataSourceTypeSearchQuery(value);
};
renderPlugins(plugins: DataSourcePluginMeta[], id?: string) {
if (!plugins || !plugins.length) {
return null;
}
return (
<List
items={plugins}
className={css`
> li {
margin-bottom: 2px;
}
`}
getItemKey={(item) => item.id.toString()}
renderItem={(item) => (
<DataSourceTypeCard
plugin={item}
onClick={() => this.onDataSourceTypeClicked(item)}
onLearnMoreClick={this.onLearnMoreClick}
/>
)}
aria-labelledby={id}
/>
);
}
onLearnMoreClick = (evt: React.SyntheticEvent<HTMLElement>) => {
evt.stopPropagation();
};
renderCategories() {
const { categories } = this.props;
return (
<>
{categories.map((category) => (
<div className="add-data-source-category" key={category.id}>
<div className="add-data-source-category__header" id={category.id}>
{category.title}
</div>
{this.renderPlugins(category.plugins, category.id)}
</div>
))}
<div className="add-data-source-more">
<LinkButton
variant="secondary"
href="https://grafana.com/plugins?type=datasource&utm_source=grafana_add_ds"
target="_blank"
rel="noopener"
>
Find more data source plugins on grafana.com
</LinkButton>
</div>
</>
);
}
render() {
const { navModel, isLoading, searchQuery, plugins } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
<div className="page-action-bar">
<FilterInput value={searchQuery} onChange={this.onSearchQueryChange} placeholder="Filter by name or type" />
<div className="page-action-bar__spacer" />
<LinkButton href="datasources" fill="outline" variant="secondary" icon="arrow-left">
Cancel
</LinkButton>
</div>
{!searchQuery && <PluginsErrorsInfo />}
<div>
{searchQuery && this.renderPlugins(plugins)}
{!searchQuery && this.renderCategories()}
</div>
</Page.Contents>
</Page>
);
}
}
interface DataSourceTypeCardProps {
plugin: DataSourcePluginMeta;
onClick: () => void;
onLearnMoreClick: (evt: React.SyntheticEvent<HTMLElement>) => void;
}
const DataSourceTypeCard: FC<DataSourceTypeCardProps> = (props) => {
const { plugin, onLearnMoreClick } = props;
const isPhantom = plugin.module === 'phantom';
const onClick = !isPhantom && !plugin.unlicensed ? props.onClick : () => {};
// find first plugin info link
const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null;
const styles = useStyles2(getStyles);
return (
<Card className={cx(styles.card, 'card-parent')} onClick={onClick}>
<Card.Heading
className={styles.heading}
aria-label={selectors.pages.AddDataSource.dataSourcePluginsV2(plugin.name)}
>
{plugin.name}
</Card.Heading>
<Card.Figure align="center" className={styles.figure}>
<img className={styles.logo} src={plugin.info.logos.small} alt="" />
</Card.Figure>
<Card.Description className={styles.description}>{plugin.info.description}</Card.Description>
{!isPhantom && (
<Card.Meta className={styles.meta}>
<PluginSignatureBadge status={plugin.signature} />
</Card.Meta>
)}
<Card.Actions className={styles.actions}>
{learnMoreLink && (
<LinkButton
variant="secondary"
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
target="_blank"
rel="noopener"
onClick={onLearnMoreClick}
icon="external-link-alt"
aria-label={`${plugin.name}, learn more.`}
>
{learnMoreLink.name}
</LinkButton>
)}
</Card.Actions>
</Card>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontSize: theme.v1.typography.heading.h5,
fontWeight: 'inherit',
}),
figure: css({
width: 'inherit',
marginRight: '0px',
'> img': {
width: theme.spacing(7),
},
}),
meta: css({
marginTop: '6px',
position: 'relative',
}),
description: css({
margin: '0px',
fontSize: theme.typography.size.sm,
}),
actions: css({
position: 'relative',
alignSelf: 'center',
marginTop: '0px',
opacity: 0,
'.card-parent:hover &, .card-parent:focus-within &': {
opacity: 1,
},
}),
card: css({
gridTemplateAreas: `
"Figure Heading Actions"
"Figure Description Actions"
"Figure Meta Actions"
"Figure - Actions"`,
}),
logo: css({
marginRight: theme.v1.spacing.lg,
marginLeft: theme.v1.spacing.sm,
width: theme.spacing(7),
maxHeight: theme.spacing(7),
}),
};
}
export function getNavModel(): NavModel {
const main = {
icon: 'database',
id: 'datasource-new',
text: 'Add data source',
href: 'datasources/new',
subTitle: 'Choose a data source type',
};
return {
main: main,
node: main,
};
}
export default connector(NewDataSourcePage);

View File

@ -1,48 +1,100 @@
import { DataSourceSettings } from '@grafana/data';
import { merge } from 'lodash';
export const getMockDataSources = (amount: number) => {
const dataSources = [];
import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data';
import { DataSourceSettingsState, PluginDashboard } from 'app/types';
for (let i = 0; i < amount; i++) {
dataSources.push({
export const getMockDashboard = (override?: Partial<PluginDashboard>) => ({
uid: 'G1btqkgkK',
pluginId: 'grafana-timestream-datasource',
title: 'Sample (DevOps)',
imported: true,
importedUri: 'db/sample-devops',
importedUrl: '/d/G1btqkgkK/sample-devops',
slug: '',
dashboardId: 12,
folderId: 0,
importedRevision: 1,
revision: 1,
description: '',
path: 'dashboards/sample.json',
removed: false,
...override,
});
export const getMockDataSources = (amount: number, overrides?: Partial<DataSourceSettings>): DataSourceSettings[] =>
[...Array(amount)].map((_, i) =>
getMockDataSource({
...overrides,
id: i,
uid: `uid-${i}`,
database: overrides?.database ? `${overrides.database}-${i}` : `database-${i}`,
name: overrides?.name ? `${overrides.name}-${i}` : `dataSource-${i}`,
})
);
export const getMockDataSource = <T>(overrides?: Partial<DataSourceSettings<T>>): DataSourceSettings<T> =>
merge(
{
access: '',
basicAuth: false,
database: `database-${i}`,
id: i,
basicAuthUser: '',
withCredentials: false,
database: '',
id: 13,
uid: 'x',
isDefault: false,
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
name: `dataSource-${i}`,
name: 'gdev-cloudwatch',
typeName: 'Cloudwatch',
orgId: 1,
readOnly: false,
type: 'cloudwatch',
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
url: '',
user: '',
});
}
secureJsonFields: {},
},
overrides
);
return dataSources as DataSourceSettings[];
};
export const getMockDataSourceMeta = (overrides?: Partial<DataSourcePluginMeta>): DataSourcePluginMeta =>
merge(
{
id: 0,
name: 'datasource-test',
type: 'datasource',
info: {
author: {
name: 'Sample Author',
url: 'https://sample-author.com',
},
description: 'Some sample description.',
links: [{ name: 'Website', url: 'https://sample-author.com' }],
logos: {
large: 'large-logo',
small: 'small-logo',
},
screenshots: [],
updated: '2022-07-01',
version: '1.5.0',
},
export const getMockDataSource = (): DataSourceSettings => {
return {
access: '',
basicAuth: false,
basicAuthUser: '',
withCredentials: false,
database: '',
id: 13,
uid: 'x',
isDefault: false,
jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
name: 'gdev-cloudwatch',
typeName: 'Cloudwatch',
orgId: 1,
readOnly: false,
type: 'cloudwatch',
typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png',
url: '',
user: '',
secureJsonFields: {},
};
};
module: 'plugins/datasource-test/module',
baseUrl: 'public/plugins/datasource-test',
},
overrides
);
export const getMockDataSourceSettingsState = (overrides?: Partial<DataSourceSettingsState>): DataSourceSettingsState =>
merge(
{
plugin: {
meta: getMockDataSourceMeta(),
components: {},
},
testingStatus: {},
loadError: null,
loading: false,
},
overrides
);

View File

@ -0,0 +1,2 @@
export * from './dataSourcesMocks';
export * from './store.navIndex.mock';

View File

@ -1,6 +1,6 @@
import { NavSection } from '@grafana/data';
import { NavSection, NavIndex } from '@grafana/data';
export default {
export const navIndex: NavIndex = {
dashboards: {
id: 'dashboards',
text: 'Dashboards',

View File

@ -0,0 +1,37 @@
import { of } from 'rxjs';
import { BackendSrvRequest, FetchResponse } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getDataSourceByIdOrUid } from './api';
jest.mock('app/core/services/backend_srv');
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getBackendSrv: jest.fn(),
}));
const mockResponse = (response: Partial<FetchResponse>) => {
(getBackendSrv as jest.Mock).mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(response as FetchResponse);
},
});
};
describe('Datasources / API', () => {
describe('getDataSourceByIdOrUid()', () => {
it('should resolve to the datasource object in case it is fetched using a UID', async () => {
const response = {
ok: true,
data: {
id: 111,
uid: 'abcdefg',
},
};
mockResponse(response);
expect(await getDataSourceByIdOrUid(response.data.uid)).toBe(response.data);
});
});
});

View File

@ -0,0 +1,74 @@
import { lastValueFrom } from 'rxjs';
import { DataSourceSettings } from '@grafana/data';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
export const getDataSources = async (): Promise<DataSourceSettings[]> => {
return await getBackendSrv().get('/api/datasources');
};
/**
* @deprecated Use `getDataSourceByUid` instead.
*/
export const getDataSourceById = async (id: string) => {
const response = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettings>({
method: 'GET',
url: `/api/datasources/${id}`,
params: accessControlQueryParam(),
showErrorAlert: false,
})
);
if (response.ok) {
return response.data;
}
throw Error(`Could not find data source by ID: "${id}"`);
};
export const getDataSourceByUid = async (uid: string) => {
const response = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettings>({
method: 'GET',
url: `/api/datasources/uid/${uid}`,
params: accessControlQueryParam(),
showErrorAlert: false,
})
);
if (response.ok) {
return response.data;
}
throw Error(`Could not find data source by UID: "${uid}"`);
};
export const getDataSourceByIdOrUid = async (idOrUid: string) => {
// Try with UID first, as we are trying to migrate to that
try {
return await getDataSourceByUid(idOrUid);
} catch (err) {
console.log(`Failed to lookup data source using UID "${idOrUid}"`);
}
// Try using ID
try {
return await getDataSourceById(idOrUid);
} catch (err) {
console.log(`Failed to lookup data source using ID "${idOrUid}"`);
}
throw Error('Could not find data source');
};
export const createDataSource = (dataSource: Partial<DataSourceSettings>) =>
getBackendSrv().post('/api/datasources', dataSource);
export const getDataSourcePlugins = () => getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
export const updateDataSource = (dataSource: DataSourceSettings) =>
getBackendSrv().put(`/api/datasources/uid/${dataSource.uid}`, dataSource);
export const deleteDataSource = (uid: string) => getBackendSrv().delete(`/api/datasources/uid/${uid}`);

View File

@ -3,7 +3,7 @@ import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import BasicSettings, { Props } from './BasicSettings';
import { BasicSettings, Props } from './BasicSettings';
const setup = () => {
const props: Props = {
@ -16,7 +16,7 @@ const setup = () => {
return render(<BasicSettings {...props} />);
};
describe('Basic Settings', () => {
describe('<BasicSettings>', () => {
it('should render component', () => {
setup();

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { InlineField, InlineSwitch, Input } from '@grafana/ui';
@ -10,10 +10,11 @@ export interface Props {
onDefaultChange: (value: boolean) => void;
}
const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => {
export function BasicSettings({ dataSourceName, isDefault, onDefaultChange, onNameChange }: Props) {
return (
<div className="gf-form-group" aria-label="Datasource settings page basic settings">
<div className="gf-form-inline">
{/* Name */}
<div className="gf-form max-width-30">
<InlineField
label="Name"
@ -33,6 +34,7 @@ const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange,
</InlineField>
</div>
{/* Is Default */}
<InlineField label="Default" labelWidth={8}>
<InlineSwitch
id="basic-settings-default"
@ -45,6 +47,4 @@ const BasicSettings: FC<Props> = ({ dataSourceName, isDefault, onDefaultChange,
</div>
</div>
);
};
export default BasicSettings;
}

View File

@ -3,15 +3,7 @@ import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import ButtonRow, { Props } from './ButtonRow';
jest.mock('app/core/core', () => {
return {
contextSrv: {
hasPermission: () => true,
},
};
});
import { ButtonRow, Props } from './ButtonRow';
const setup = (propOverrides?: object) => {
const props: Props = {
@ -28,7 +20,7 @@ const setup = (propOverrides?: object) => {
return render(<ButtonRow {...props} />);
};
describe('Button Row', () => {
describe('<ButtonRow>', () => {
it('should render component', () => {
setup();

View File

@ -1,9 +1,9 @@
import React, { FC } from 'react';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Button, LinkButton } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types/';
import { AccessControlAction } from 'app/types';
export interface Props {
exploreUrl: string;
@ -14,7 +14,7 @@ export interface Props {
onTest: (event: any) => void;
}
const ButtonRow: FC<Props> = ({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }) => {
export function ButtonRow({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }: Props) {
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
return (
@ -46,12 +46,10 @@ const ButtonRow: FC<Props> = ({ canSave, canDelete, onDelete, onSubmit, onTest,
</Button>
)}
{!canSave && (
<Button type="submit" variant="primary" onClick={onTest}>
<Button variant="primary" onClick={onTest}>
Test
</Button>
)}
</div>
);
};
export default ButtonRow;
}

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { GrafanaEdition } from '@grafana/data/src/types/config';
@ -12,7 +12,7 @@ export interface Props {
dataSource: DataSourceSettings;
}
export const CloudInfoBox: FC<Props> = ({ dataSource }) => {
export function CloudInfoBox({ dataSource }: Props) {
let mainDS = '';
let extraDS = '';
@ -71,4 +71,4 @@ export const CloudInfoBox: FC<Props> = ({ dataSource }) => {
}}
</LocalStorageValueProvider>
);
};
}

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PluginDashboard } from '../../types';
import { PluginDashboard } from 'app/types';
import DashboardsTable, { Props } from './DashboardsTable';

View File

@ -1,16 +1,18 @@
import React, { FC } from 'react';
import React from 'react';
import { Button, Icon } from '@grafana/ui';
import { PluginDashboard } from '../../types';
import { PluginDashboard } from 'app/types';
export interface Props {
// List of plugin dashboards to show in the table
dashboards: PluginDashboard[];
// Callback used when the user clicks on importing a dashboard
onImport: (dashboard: PluginDashboard, overwrite: boolean) => void;
// Callback used when the user clicks on removing a dashboard
onRemove: (dashboard: PluginDashboard) => void;
}
const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {
export function DashboardsTable({ dashboards, onImport, onRemove }: Props) {
function buttonText(dashboard: PluginDashboard) {
return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
}
@ -57,6 +59,6 @@ const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {
</tbody>
</table>
);
};
}
export default DashboardsTable;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { DataSourcePluginMeta } from '@grafana/data';
import { LinkButton } from '@grafana/ui';
import { DataSourcePluginCategory } from 'app/types';
import { DataSourceTypeCardList } from './DataSourceTypeCardList';
export type Props = {
// The list of data-source plugin categories to display
categories: DataSourcePluginCategory[];
// Called when a data-source plugin is clicked on in the list
onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void;
};
export function DataSourceCategories({ categories, onClickDataSourceType }: Props) {
return (
<>
{/* Categories */}
{categories.map(({ id, title, plugins }) => (
<div className="add-data-source-category" key={id}>
<div className="add-data-source-category__header" id={id}>
{title}
</div>
<DataSourceTypeCardList dataSourcePlugins={plugins} onClickDataSourceType={onClickDataSourceType} />
</div>
))}
{/* Find more */}
<div className="add-data-source-more">
<LinkButton
variant="secondary"
href="https://grafana.com/plugins?type=datasource&utm_source=grafana_add_ds"
target="_blank"
rel="noopener"
>
Find more data source plugins on grafana.com
</LinkButton>
</div>
</>
);
}

View File

@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import { getMockDashboard } from '../__mocks__';
import { DataSourceDashboardsView, ViewProps } from './DataSourceDashboards';
const setup = ({
dashboards = [],
isLoading = false,
onImportDashboard = jest.fn(),
onRemoveDashboard = jest.fn(),
}: Partial<ViewProps>) => {
const store = configureStore();
return render(
<Provider store={store}>
<DataSourceDashboardsView
isLoading={isLoading}
dashboards={dashboards}
onImportDashboard={onImportDashboard}
onRemoveDashboard={onRemoveDashboard}
/>
</Provider>
);
};
describe('<DataSourceDashboards>', () => {
it('should show a loading indicator while loading', () => {
setup({ isLoading: true });
expect(screen.queryByText(/loading/i)).toBeVisible();
});
it('should not show a loading indicator when loaded', () => {
setup({ isLoading: false });
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it('should show a list of dashboards once loaded', () => {
setup({
dashboards: [getMockDashboard({ title: 'My Dashboard 1' }), getMockDashboard({ title: 'My Dashboard 2' })],
});
expect(screen.queryByText('My Dashboard 1')).toBeVisible();
expect(screen.queryByText('My Dashboard 2')).toBeVisible();
});
});

View File

@ -0,0 +1,85 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { importDashboard, removeDashboard } from 'app/features/dashboard/state/actions';
import { loadPluginDashboards } from 'app/features/plugins/admin/state/actions';
import { PluginDashboard, StoreState } from 'app/types';
import DashboardTable from '../components/DashboardsTable';
import { useLoadDataSource } from '../state';
export type Props = {
// The UID of the data source
uid: string;
};
export function DataSourceDashboards({ uid }: Props) {
useLoadDataSource(uid);
const dispatch = useDispatch();
const dataSource = useSelector((s: StoreState) => s.dataSources.dataSource);
const dashboards = useSelector((s: StoreState) => s.plugins.dashboards);
const isLoading = useSelector((s: StoreState) => s.plugins.isLoadingPluginDashboards);
useEffect(() => {
// Load plugin dashboards only when the datasource has loaded
if (dataSource.id > 0) {
dispatch(loadPluginDashboards());
}
}, [dispatch, dataSource]);
const onImportDashboard = (dashboard: PluginDashboard, overwrite: boolean) => {
dispatch(
importDashboard(
{
pluginId: dashboard.pluginId,
path: dashboard.path,
overwrite,
inputs: [
{
name: '*',
type: 'datasource',
pluginId: dataSource.type,
value: dataSource.name,
},
],
},
dashboard.title
)
);
};
const onRemoveDashboard = ({ uid }: PluginDashboard) => {
dispatch(removeDashboard(uid));
};
return (
<DataSourceDashboardsView
dashboards={dashboards}
isLoading={isLoading}
onImportDashboard={onImportDashboard}
onRemoveDashboard={onRemoveDashboard}
/>
);
}
export type ViewProps = {
isLoading: boolean;
dashboards: PluginDashboard[];
onImportDashboard: (dashboard: PluginDashboard, overwrite: boolean) => void;
onRemoveDashboard: (dashboard: PluginDashboard) => void;
};
export const DataSourceDashboardsView = ({
isLoading,
dashboards,
onImportDashboard,
onRemoveDashboard,
}: ViewProps) => {
if (isLoading) {
return <PageLoader />;
}
return <DashboardTable dashboards={dashboards} onImport={onImportDashboard} onRemove={onRemoveDashboard} />;
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Button } from '@grafana/ui';
import { DataSourceRights } from '../types';
import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage';
export type Props = {
dataSourceRights: DataSourceRights;
onDelete: () => void;
};
export function DataSourceLoadError({ dataSourceRights, onDelete }: Props) {
const { readOnly, hasDeleteRights } = dataSourceRights;
const canDelete = !readOnly && hasDeleteRights;
const navigateBack = () => history.back();
return (
<>
{readOnly && <DataSourceReadOnlyMessage />}
<div className="gf-form-button-row">
{canDelete && (
<Button type="submit" variant="destructive" onClick={onDelete}>
Delete
</Button>
)}
<Button variant="secondary" fill="outline" type="button" onClick={navigateBack}>
Back
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Alert } from '@grafana/ui';
export const missingRightsMessage =
'You are not allowed to modify this data source. Please contact your server admin to update this data source.';
export function DataSourceMissingRightsMessage() {
return (
<Alert severity="info" title="Missing rights">
{missingRightsMessage}
</Alert>
);
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import { GenericDataSourcePlugin } from '../types';
export type Props = {
plugin?: GenericDataSourcePlugin | null;
pageId: string;
};
export function DataSourcePluginConfigPage({ plugin, pageId }: Props) {
if (!plugin || !plugin.configPages) {
return null;
}
const page = plugin.configPages.find(({ id }) => id === pageId);
if (page) {
// TODO: Investigate if any plugins are using this? We should change this interface
return <page.body plugin={plugin} query={{}} />;
}
return <div>Page not found: {page}</div>;
}

View File

@ -1,17 +1,10 @@
import { cloneDeep } from 'lodash';
import React, { PureComponent } from 'react';
import {
DataQuery,
DataSourceApi,
DataSourceJsonData,
DataSourcePlugin,
DataSourcePluginMeta,
DataSourceSettings,
} from '@grafana/data';
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
export type GenericDataSourcePlugin = DataSourcePlugin<DataSourceApi<DataQuery, DataSourceJsonData>>;
import { GenericDataSourcePlugin } from '../types';
export interface Props {
plugin: GenericDataSourcePlugin;
@ -20,7 +13,7 @@ export interface Props {
onModelChange: (dataSource: DataSourceSettings) => void;
}
export class PluginSettings extends PureComponent<Props> {
export class DataSourcePluginSettings extends PureComponent<Props> {
element: HTMLDivElement | null = null;
component?: AngularComponent;
scopeProps: {
@ -92,5 +85,3 @@ export class PluginSettings extends PureComponent<Props> {
);
}
}
export default PluginSettings;

View File

@ -0,0 +1,19 @@
import React from 'react';
import { PluginState } from '@grafana/data';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
export type Props = {
state?: PluginState;
};
export function DataSourcePluginState({ state }: Props) {
return (
<div className="gf-form">
<label className="gf-form-label width-10">Plugin state</label>
<label className="gf-form-label gf-form-label--transparent">
<PluginStateInfo state={state} />
</label>
</div>
);
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Alert } from '@grafana/ui';
export const readOnlyMessage =
'This data source was added by config and cannot be modified using the UI. Please contact your server admin to update this data source.';
export function DataSourceReadOnlyMessage() {
return (
<Alert aria-label={e2eSelectors.pages.DataSource.readOnly} severity="info" title="Provisioned data source">
{readOnlyMessage}
</Alert>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Alert } from '@grafana/ui';
import { TestingStatus } from 'app/types';
export type Props = {
testingStatus?: TestingStatus;
};
export function DataSourceTestingStatus({ testingStatus }: Props) {
const isError = testingStatus?.status === 'error';
const message = testingStatus?.message;
const detailsMessage = testingStatus?.details?.message;
const detailsVerboseMessage = testingStatus?.details?.verboseMessage;
if (message) {
return (
<div className="gf-form-group p-t-2">
<Alert
severity={isError ? 'error' : 'success'}
title={message}
aria-label={e2eSelectors.pages.DataSource.alert}
>
{testingStatus?.details && (
<>
{detailsMessage}
{detailsVerboseMessage ? (
<details style={{ whiteSpace: 'pre-wrap' }}>{detailsVerboseMessage}</details>
) : null}
</>
)}
</Alert>
</div>
);
}
return null;
}

View File

@ -0,0 +1,109 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { DataSourcePluginMeta, GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Card, LinkButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui';
export type Props = {
dataSourcePlugin: DataSourcePluginMeta;
onClick: () => void;
};
export function DataSourceTypeCard({ onClick, dataSourcePlugin }: Props) {
const isPhantom = dataSourcePlugin.module === 'phantom';
const isClickable = !isPhantom && !dataSourcePlugin.unlicensed;
const learnMoreLink = dataSourcePlugin.info?.links?.length > 0 ? dataSourcePlugin.info.links[0] : null;
const styles = useStyles2(getStyles);
return (
<Card className={cx(styles.card, 'card-parent')} onClick={isClickable ? onClick : () => {}}>
{/* Name */}
<Card.Heading
className={styles.heading}
aria-label={e2eSelectors.pages.AddDataSource.dataSourcePluginsV2(dataSourcePlugin.name)}
>
{dataSourcePlugin.name}
</Card.Heading>
{/* Logo */}
<Card.Figure align="center" className={styles.figure}>
<img className={styles.logo} src={dataSourcePlugin.info.logos.small} alt="" />
</Card.Figure>
<Card.Description className={styles.description}>{dataSourcePlugin.info.description}</Card.Description>
{/* Signature */}
{!isPhantom && (
<Card.Meta className={styles.meta}>
<PluginSignatureBadge status={dataSourcePlugin.signature} />
</Card.Meta>
)}
{/* Learn more */}
<Card.Actions className={styles.actions}>
{learnMoreLink && (
<LinkButton
aria-label={`${dataSourcePlugin.name}, learn more.`}
href={`${learnMoreLink.url}?utm_source=grafana_add_ds`}
icon="external-link-alt"
onClick={(e) => e.stopPropagation()}
rel="noopener"
target="_blank"
variant="secondary"
>
{learnMoreLink.name}
</LinkButton>
)}
</Card.Actions>
</Card>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontSize: theme.v1.typography.heading.h5,
fontWeight: 'inherit',
}),
figure: css({
width: 'inherit',
marginRight: '0px',
'> img': {
width: theme.spacing(7),
},
}),
meta: css({
marginTop: '6px',
position: 'relative',
}),
description: css({
margin: '0px',
fontSize: theme.typography.size.sm,
}),
actions: css({
position: 'relative',
alignSelf: 'center',
marginTop: '0px',
opacity: 0,
'.card-parent:hover &, .card-parent:focus-within &': {
opacity: 1,
},
}),
card: css({
gridTemplateAreas: `
"Figure Heading Actions"
"Figure Description Actions"
"Figure Meta Actions"
"Figure - Actions"`,
}),
logo: css({
marginRight: theme.v1.spacing.lg,
marginLeft: theme.v1.spacing.sm,
width: theme.spacing(7),
maxHeight: theme.spacing(7),
}),
};
}

View File

@ -0,0 +1,33 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataSourcePluginMeta } from '@grafana/data';
import { List } from '@grafana/ui';
import { DataSourceTypeCard } from './DataSourceTypeCard';
export type Props = {
// The list of data-source plugins to display
dataSourcePlugins: DataSourcePluginMeta[];
// Called when a data-source plugin is clicked on in the list
onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void;
};
export function DataSourceTypeCardList({ dataSourcePlugins, onClickDataSourceType }: Props) {
if (!dataSourcePlugins || !dataSourcePlugins.length) {
return null;
}
return (
<List
items={dataSourcePlugins}
getItemKey={(item) => item.id.toString()}
renderItem={(item) => <DataSourceTypeCard dataSourcePlugin={item} onClick={() => onClickDataSourceType(item)} />}
className={css`
> li {
margin-bottom: 2px;
}
`}
/>
);
}

View File

@ -2,40 +2,38 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { LayoutModes } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import { DataSourcesState } from 'app/types';
import DataSourcesList from './DataSourcesList';
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
import { initialState } from './state/reducers';
import { getMockDataSources } from '../__mocks__';
const setup = (stateOverride?: Partial<DataSourcesState>) => {
const store = configureStore({
dataSources: {
...initialState,
dataSources: getMockDataSources(3),
layoutMode: LayoutModes.Grid,
...stateOverride,
},
});
import { DataSourcesListView } from './DataSourcesList';
const setup = () => {
const store = configureStore();
return render(
<Provider store={store}>
<DataSourcesList dataSources={getMockDataSources(3)} />
<DataSourcesListView
dataSources={getMockDataSources(3)}
dataSourcesCount={3}
isLoading={false}
hasCreateRights={true}
/>
</Provider>
);
};
describe('DataSourcesList', () => {
describe('<DataSourcesList>', () => {
it('should render list of datasources', () => {
setup();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(screen.getAllByRole('heading')).toHaveLength(3);
});
it('should render all elements in the list item', () => {
setup();
expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument();
});

View File

@ -0,0 +1,106 @@
import { css } from '@emotion/css';
import React from 'react';
import { useSelector } from 'react-redux';
import { DataSourceSettings } from '@grafana/data';
import { Card, Tag, useStyles } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { contextSrv } from 'app/core/core';
import { StoreState, AccessControlAction } from 'app/types';
import { getDataSources, getDataSourcesCount, useLoadDataSources } from '../state';
import { DataSourcesListHeader } from './DataSourcesListHeader';
export function DataSourcesList() {
useLoadDataSources();
const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources));
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched);
const hasCreateRights = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
return (
<DataSourcesListView
dataSources={dataSources}
dataSourcesCount={dataSourcesCount}
isLoading={!hasFetched}
hasCreateRights={hasCreateRights}
/>
);
}
export type ViewProps = {
dataSources: DataSourceSettings[];
dataSourcesCount: number;
isLoading: boolean;
hasCreateRights: boolean;
};
export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) {
const styles = useStyles(getStyles);
if (isLoading) {
return <PageLoader />;
}
if (dataSourcesCount === 0) {
return (
<EmptyListCTA
buttonDisabled={!hasCreateRights}
title="No data sources defined"
buttonIcon="database"
buttonLink="datasources/new"
buttonTitle="Add data source"
proTip="You can also define data sources through configuration files."
proTipLink="http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list"
proTipLinkTitle="Learn more"
proTipTarget="_blank"
/>
);
}
return (
<>
{/* List Header */}
<DataSourcesListHeader />
{/* List */}
<ul className={styles.list}>
{dataSources.map((dataSource) => {
return (
<li key={dataSource.uid}>
<Card href={`datasources/edit/${dataSource.uid}`}>
<Card.Heading>{dataSource.name}</Card.Heading>
<Card.Figure>
<img src={dataSource.typeLogoUrl} alt="" height="40px" width="40px" className={styles.logo} />
</Card.Figure>
<Card.Meta>
{[
dataSource.typeName,
dataSource.url,
dataSource.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />,
]}
</Card.Meta>
</Card>
</li>
);
})}
</ul>
</>
);
}
const getStyles = () => {
return {
list: css({
listStyle: 'none',
display: 'grid',
// gap: '8px', Add back when legacy support for old Card interface is dropped
}),
logo: css({
objectFit: 'contain',
}),
};
};

View File

@ -1,19 +1,35 @@
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { AnyAction } from 'redux';
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, StoreState } from 'app/types';
import { setDataSourcesSearchQuery } from './state/reducers';
import { getDataSourcesSearchQuery } from './state/selectors';
import { getDataSourcesSearchQuery, setDataSourcesSearchQuery } from '../state';
export const DataSourcesListHeader = () => {
export function DataSourcesListHeader() {
const dispatch = useDispatch();
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
return (
<DataSourcesListHeaderView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
canCreateDataSource={canCreateDataSource}
/>
);
}
export type ViewProps = {
searchQuery: string;
setSearchQuery: (q: string) => AnyAction;
canCreateDataSource: boolean;
};
export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) {
const linkButton = {
href: 'datasources/new',
title: 'Add data source',
@ -23,4 +39,4 @@ export const DataSourcesListHeader = () => {
return (
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} linkButton={linkButton} key="action-bar" />
);
};
}

View File

@ -0,0 +1,251 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { PluginState } from '@grafana/data';
import { setAngularLoader } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
import { missingRightsMessage } from './DataSourceMissingRightsMessage';
import { readOnlyMessage } from './DataSourceReadOnlyMessage';
import { EditDataSourceView, ViewProps } from './EditDataSource';
const setup = (props?: Partial<ViewProps>) => {
const store = configureStore();
return render(
<Provider store={store}>
<EditDataSourceView
dataSource={getMockDataSource()}
dataSourceMeta={getMockDataSourceMeta()}
dataSourceSettings={getMockDataSourceSettingsState()}
dataSourceRights={{ readOnly: false, hasWriteRights: true, hasDeleteRights: true }}
exploreUrl={'/explore'}
onDelete={jest.fn()}
onDefaultChange={jest.fn()}
onNameChange={jest.fn()}
onOptionsChange={jest.fn()}
onTest={jest.fn()}
onUpdate={jest.fn()}
{...props}
/>
</Provider>
);
};
describe('<EditDataSource>', () => {
beforeAll(() => {
setAngularLoader({
load: () => ({
destroy: jest.fn(),
digest: jest.fn(),
getScope: () => ({ $watch: () => {} }),
}),
});
});
describe('On loading errors', () => {
it('should render a Back button', () => {
setup({
dataSource: getMockDataSource({ name: 'My Datasource' }),
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
});
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
expect(screen.queryByText('My Datasource')).not.toBeInTheDocument();
expect(screen.queryByText('Back')).toBeVisible();
});
it('should render a Delete button if the user has rights delete the datasource', () => {
setup({
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
dataSourceRights: {
readOnly: false,
hasDeleteRights: true,
hasWriteRights: true,
},
});
expect(screen.queryByText('Delete')).toBeVisible();
});
it('should not render a Delete button if the user has no rights to delete the datasource', () => {
setup({
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
dataSourceRights: {
readOnly: false,
hasDeleteRights: false,
hasWriteRights: true,
},
});
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
it('should render a message if the datasource is read-only', () => {
setup({
dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }),
dataSourceRights: {
readOnly: true,
hasDeleteRights: false,
hasWriteRights: true,
},
});
expect(screen.queryByText(readOnlyMessage)).toBeVisible();
});
});
describe('On loading', () => {
it('should render a loading indicator while the data is being fetched', () => {
setup({
dataSource: getMockDataSource({ name: 'My Datasource' }),
dataSourceSettings: getMockDataSourceSettingsState({ loading: true }),
});
expect(screen.queryByText('Loading ...')).toBeVisible();
expect(screen.queryByText('My Datasource')).not.toBeInTheDocument();
});
it('should not render loading when data is already available', () => {
setup();
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
});
});
describe('On editing', () => {
it('should render no messages if the user has write access and if the data-source is not read-only', () => {
setup({
dataSourceRights: {
readOnly: false,
hasDeleteRights: true,
hasWriteRights: true,
},
});
expect(screen.queryByText(readOnlyMessage)).not.toBeInTheDocument();
expect(screen.queryByText(missingRightsMessage)).not.toBeInTheDocument();
});
it('should render a message if the user has no write access', () => {
setup({
dataSourceRights: {
readOnly: false,
hasDeleteRights: false,
hasWriteRights: false,
},
});
expect(screen.queryByText(missingRightsMessage)).toBeVisible();
});
it('should render a message if the data-source is read-only', () => {
setup({
dataSourceRights: {
readOnly: true,
hasDeleteRights: false,
hasWriteRights: false,
},
});
expect(screen.queryByText(readOnlyMessage)).toBeVisible();
});
it('should render a beta info message if the plugin is still in Beta state', () => {
setup({
dataSourceMeta: getMockDataSourceMeta({
state: PluginState.beta,
}),
});
expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeVisible();
});
it('should render an alpha info message if the plugin is still in Alpha state', () => {
setup({
dataSourceMeta: getMockDataSourceMeta({
state: PluginState.alpha,
}),
});
expect(
screen.getByTitle('This feature is experimental and future updates might not be backward compatible')
).toBeVisible();
});
it('should render testing errors with a detailed error message', () => {
const message = 'message';
const detailsMessage = 'detailed message';
setup({
dataSourceSettings: getMockDataSourceSettingsState({
testingStatus: {
message,
status: 'error',
details: { message: detailsMessage },
},
}),
});
expect(screen.getByText(message)).toBeVisible();
expect(screen.getByText(detailsMessage)).toBeVisible();
});
it('should render testing errors with empty details', () => {
const message = 'message';
setup({
dataSourceSettings: getMockDataSourceSettingsState({
testingStatus: {
message,
status: 'error',
details: {},
},
}),
});
expect(screen.getByText(message)).toBeVisible();
});
it('should render testing errors with no details', () => {
const message = 'message';
setup({
dataSourceSettings: getMockDataSourceSettingsState({
testingStatus: {
message,
status: 'error',
},
}),
});
expect(screen.getByText(message)).toBeVisible();
});
it('should use the verboseMessage property in the error details whenever it is available', () => {
const message = 'message';
const detailsMessage = 'detailed message';
const detailsVerboseMessage = 'even more detailed...';
setup({
dataSourceSettings: getMockDataSourceSettingsState({
testingStatus: {
message,
status: 'error',
details: {
details: detailsMessage,
verboseMessage: detailsVerboseMessage,
},
},
}),
});
expect(screen.queryByText(message)).toBeVisible();
expect(screen.queryByText(detailsMessage)).not.toBeInTheDocument();
expect(screen.queryByText(detailsVerboseMessage)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,171 @@
import { AnyAction } from '@reduxjs/toolkit';
import React from 'react';
import { useDispatch } from 'react-redux';
import { DataSourcePluginMeta, DataSourceSettings as DataSourceSettingsType } from '@grafana/data';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { DataSourceSettingsState, ThunkResult } from 'app/types';
import {
dataSourceLoaded,
setDataSourceName,
setIsDefault,
useDataSource,
useDataSourceExploreUrl,
useDataSourceMeta,
useDataSourceRights,
useDataSourceSettings,
useDeleteLoadedDataSource,
useInitDataSourceSettings,
useTestDataSource,
useUpdateDatasource,
} from '../state';
import { DataSourceRights } from '../types';
import { BasicSettings } from './BasicSettings';
import { ButtonRow } from './ButtonRow';
import { CloudInfoBox } from './CloudInfoBox';
import { DataSourceLoadError } from './DataSourceLoadError';
import { DataSourceMissingRightsMessage } from './DataSourceMissingRightsMessage';
import { DataSourcePluginConfigPage } from './DataSourcePluginConfigPage';
import { DataSourcePluginSettings } from './DataSourcePluginSettings';
import { DataSourcePluginState } from './DataSourcePluginState';
import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage';
import { DataSourceTestingStatus } from './DataSourceTestingStatus';
export type Props = {
// The ID of the data source
uid: string;
// The ID of the custom datasource setting page
pageId?: string | null;
};
export function EditDataSource({ uid, pageId }: Props) {
useInitDataSourceSettings(uid);
const dispatch = useDispatch();
const dataSource = useDataSource(uid);
const dataSourceMeta = useDataSourceMeta(uid);
const dataSourceSettings = useDataSourceSettings();
const dataSourceRights = useDataSourceRights(uid);
const exploreUrl = useDataSourceExploreUrl(uid);
const onDelete = useDeleteLoadedDataSource();
const onTest = useTestDataSource(uid);
const onUpdate = useUpdateDatasource();
const onDefaultChange = (value: boolean) => dispatch(setIsDefault(value));
const onNameChange = (name: string) => dispatch(setDataSourceName(name));
const onOptionsChange = (ds: DataSourceSettingsType) => dispatch(dataSourceLoaded(ds));
return (
<EditDataSourceView
pageId={pageId}
dataSource={dataSource}
dataSourceMeta={dataSourceMeta}
dataSourceSettings={dataSourceSettings}
dataSourceRights={dataSourceRights}
exploreUrl={exploreUrl}
onDelete={onDelete}
onDefaultChange={onDefaultChange}
onNameChange={onNameChange}
onOptionsChange={onOptionsChange}
onTest={onTest}
onUpdate={onUpdate}
/>
);
}
export type ViewProps = {
pageId?: string | null;
dataSource: DataSourceSettingsType;
dataSourceMeta: DataSourcePluginMeta;
dataSourceSettings: DataSourceSettingsState;
dataSourceRights: DataSourceRights;
exploreUrl: string;
onDelete: () => void;
onDefaultChange: (isDefault: boolean) => AnyAction;
onNameChange: (name: string) => AnyAction;
onOptionsChange: (dataSource: DataSourceSettingsType) => AnyAction;
onTest: () => ThunkResult<void>;
onUpdate: (dataSource: DataSourceSettingsType) => ThunkResult<void>;
};
export function EditDataSourceView({
pageId,
dataSource,
dataSourceMeta,
dataSourceSettings,
dataSourceRights,
exploreUrl,
onDelete,
onDefaultChange,
onNameChange,
onOptionsChange,
onTest,
onUpdate,
}: ViewProps) {
const { plugin, loadError, testingStatus, loading } = dataSourceSettings;
const { readOnly, hasWriteRights, hasDeleteRights } = dataSourceRights;
const hasDataSource = dataSource.id > 0;
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await onUpdate({ ...dataSource });
onTest();
};
if (loadError) {
return <DataSourceLoadError dataSourceRights={dataSourceRights} onDelete={onDelete} />;
}
if (loading) {
return <PageLoader />;
}
// TODO - is this needed?
if (!hasDataSource) {
return null;
}
if (pageId) {
return <DataSourcePluginConfigPage pageId={pageId} plugin={plugin} />;
}
return (
<form onSubmit={onSubmit}>
{!hasWriteRights && <DataSourceMissingRightsMessage />}
{readOnly && <DataSourceReadOnlyMessage />}
{dataSourceMeta.state && <DataSourcePluginState state={dataSourceMeta.state} />}
<CloudInfoBox dataSource={dataSource} />
<BasicSettings
dataSourceName={dataSource.name}
isDefault={dataSource.isDefault}
onDefaultChange={onDefaultChange}
onNameChange={onNameChange}
/>
{plugin && (
<DataSourcePluginSettings
plugin={plugin}
dataSource={dataSource}
dataSourceMeta={dataSourceMeta}
onModelChange={onOptionsChange}
/>
)}
<DataSourceTestingStatus testingStatus={testingStatus} />
<ButtonRow
onSubmit={onSubmit}
onDelete={onDelete}
onTest={onTest}
exploreUrl={exploreUrl}
canSave={!readOnly && hasWriteRights}
canDelete={!readOnly && hasDeleteRights}
/>
</form>
);
}

View File

@ -0,0 +1,89 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AnyAction } from 'redux';
import { DataSourcePluginMeta } from '@grafana/data';
import { LinkButton, FilterInput } from '@grafana/ui';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { PluginsErrorsInfo } from 'app/features/plugins/components/PluginsErrorsInfo';
import { DataSourcePluginCategory, StoreState } from 'app/types';
import { DataSourceCategories } from '../components/DataSourceCategories';
import { DataSourceTypeCardList } from '../components/DataSourceTypeCardList';
import {
useAddDatasource,
useLoadDataSourcePlugins,
getFilteredDataSourcePlugins,
setDataSourceTypeSearchQuery,
} from '../state';
export function NewDataSource() {
useLoadDataSourcePlugins();
const dispatch = useDispatch();
const filteredDataSources = useSelector((s: StoreState) => getFilteredDataSourcePlugins(s.dataSources));
const searchQuery = useSelector((s: StoreState) => s.dataSources.dataSourceTypeSearchQuery);
const isLoading = useSelector((s: StoreState) => s.dataSources.isLoadingDataSources);
const dataSourceCategories = useSelector((s: StoreState) => s.dataSources.categories);
const onAddDataSource = useAddDatasource();
const onSetSearchQuery = (q: string) => dispatch(setDataSourceTypeSearchQuery(q));
return (
<NewDataSourceView
dataSources={filteredDataSources}
dataSourceCategories={dataSourceCategories}
searchQuery={searchQuery}
isLoading={isLoading}
onAddDataSource={onAddDataSource}
onSetSearchQuery={onSetSearchQuery}
/>
);
}
export type ViewProps = {
dataSources: DataSourcePluginMeta[];
dataSourceCategories: DataSourcePluginCategory[];
searchQuery: string;
isLoading: boolean;
onAddDataSource: (dataSource: DataSourcePluginMeta) => void;
onSetSearchQuery: (q: string) => AnyAction;
};
export function NewDataSourceView({
dataSources,
dataSourceCategories,
searchQuery,
isLoading,
onAddDataSource,
onSetSearchQuery,
}: ViewProps) {
if (isLoading) {
return <PageLoader />;
}
return (
<>
{/* Search */}
<div className="page-action-bar">
<FilterInput value={searchQuery} onChange={onSetSearchQuery} placeholder="Filter by name or type" />
<div className="page-action-bar__spacer" />
<LinkButton href="datasources" fill="outline" variant="secondary" icon="arrow-left">
Cancel
</LinkButton>
</div>
{/* Show any plugin errors while not searching for anything specific */}
{!searchQuery && <PluginsErrorsInfo />}
{/* Search results */}
<div>
{searchQuery && (
<DataSourceTypeCardList dataSourcePlugins={dataSources} onClickDataSourceType={onAddDataSource} />
)}
{!searchQuery && (
<DataSourceCategories categories={dataSourceCategories} onClickDataSourceType={onAddDataSource} />
)}
</div>
</>
);
}

View File

@ -1,24 +0,0 @@
import { DataSourceSettings } from '@grafana/data';
export function createDatasourceSettings<T>(jsonData: T): DataSourceSettings<T> {
return {
id: 0,
uid: 'x',
orgId: 0,
name: 'datasource-test',
typeLogoUrl: '',
type: 'datasource',
typeName: 'Datasource',
access: 'server',
url: 'http://localhost',
user: '',
database: '',
basicAuth: false,
basicAuthUser: '',
isDefault: false,
jsonData,
readOnly: false,
withCredentials: false,
secureJsonFields: {},
};
}

View File

@ -0,0 +1,82 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { setAngularLoader } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
import { navIndex, getMockDataSource } from '../__mocks__';
import * as api from '../api';
import { initialState as dataSourcesInitialState } from '../state';
import DataSourceDashboardsPage from './DataSourceDashboardsPage';
jest.mock('../api');
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
hasPermission: () => true,
hasPermissionInMetadata: () => true,
},
}));
const setup = (uid: string, store: Store) =>
render(
<Provider store={store}>
<DataSourceDashboardsPage
{...getRouteComponentProps({
// @ts-ignore
match: {
params: {
uid,
},
},
})}
/>
</Provider>
);
describe('<DataSourceDashboardsPage>', () => {
const uid = 'foo';
const dataSourceName = 'My DataSource';
const dataSource = getMockDataSource<{}>({ uid, name: dataSourceName });
let store: Store;
beforeAll(() => {
setAngularLoader({
load: () => ({
destroy: jest.fn(),
digest: jest.fn(),
getScope: () => ({ $watch: () => {} }),
}),
});
});
beforeEach(() => {
// @ts-ignore
api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource);
store = configureStore({
dataSources: {
...dataSourcesInitialState,
dataSource: dataSource,
},
navIndex: {
...navIndex,
[`datasource-dashboards-${uid}`]: {
id: `datasource-dashboards-${uid}`,
text: dataSourceName,
icon: 'list-ul',
url: `/datasources/edit/${uid}/dashboards`,
},
},
});
});
it('should render the dashboards page without an issue', () => {
setup(uid, store);
expect(screen.queryByText(dataSourceName)).toBeVisible();
});
});

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DataSourceDashboards } from '../components/DataSourceDashboards';
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
export function DataSourceDashboardsPage(props: Props) {
const uid = props.match.params.uid;
return (
<Page navId={`datasource-dashboards-${uid}`}>
<Page.Contents>
<DataSourceDashboards uid={uid} />
</Page.Contents>
</Page>
);
}
export default DataSourceDashboardsPage;

View File

@ -6,24 +6,14 @@ import { DataSourceSettings, LayoutModes } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import { DataSourcesState } from 'app/types';
import { navIndex, getMockDataSources } from '../__mocks__';
import { initialState } from '../state';
import { DataSourcesListPage } from './DataSourcesListPage';
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
import navIndex from './__mocks__/store.navIndex.mock';
import { initialState } from './state/reducers';
jest.mock('app/core/core', () => {
return {
contextSrv: {
hasPermission: () => true,
},
};
});
const getMock = jest.fn().mockResolvedValue([]);
jest.mock('app/core/services/backend_srv', () => ({
...jest.requireActual('app/core/services/backend_srv'),
getBackendSrv: () => ({ get: getMock }),
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }),
}));
const setup = (stateOverride?: Partial<DataSourcesState>) => {

View File

@ -0,0 +1,17 @@
import React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { DataSourcesList } from '../components/DataSourcesList';
export function DataSourcesListPage() {
return (
<Page navId="datasources">
<Page.Contents>
<DataSourcesList />
</Page.Contents>
</Page>
);
}
export default DataSourcesListPage;

View File

@ -0,0 +1,99 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { LayoutModes } from '@grafana/data';
import { setAngularLoader } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
import { navIndex, getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
import * as api from '../api';
import { initialState } from '../state';
import { EditDataSourcePage } from './EditDataSourcePage';
jest.mock('../api');
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
hasPermission: () => true,
hasPermissionInMetadata: () => true,
},
}));
const setup = (uid: string, store: Store) =>
render(
<Provider store={store}>
<EditDataSourcePage
{...getRouteComponentProps({
// @ts-ignore
match: {
params: {
uid,
},
},
})}
/>
</Provider>
);
describe('<EditDataSourcePage>', () => {
const uid = 'foo';
const name = 'My DataSource';
const dataSource = getMockDataSource<{}>({ uid, name });
const dataSourceMeta = getMockDataSourceMeta();
const dataSourceSettings = getMockDataSourceSettingsState();
let store: Store;
beforeAll(() => {
setAngularLoader({
load: () => ({
destroy: jest.fn(),
digest: jest.fn(),
getScope: () => ({ $watch: () => {} }),
}),
});
});
beforeEach(() => {
// @ts-ignore
api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource);
store = configureStore({
dataSourceSettings,
dataSources: {
...initialState,
dataSources: [dataSource],
dataSource: dataSource,
dataSourceMeta: dataSourceMeta,
layoutMode: LayoutModes.Grid,
hasFetched: true,
},
navIndex: {
...navIndex,
[`datasource-settings-${uid}`]: {
id: `datasource-settings-${uid}`,
text: name,
icon: 'list-ul',
url: `/datasources/edit/${uid}`,
},
},
});
});
it('should render the edit page without an issue', () => {
setup(uid, store);
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
// Title
expect(screen.queryByText(name)).toBeVisible();
// Buttons
expect(screen.queryByRole('button', { name: /Back/i })).toBeVisible();
expect(screen.queryByRole('button', { name: /Delete/i })).toBeVisible();
expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible();
expect(screen.queryByText('Explore')).toBeVisible();
});
});

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { EditDataSource } from '../components/EditDataSource';
import { useDataSourceSettingsNav } from '../state';
export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {}
export function EditDataSourcePage(props: Props) {
const uid = props.match.params.uid;
const params = new URLSearchParams(props.location.search);
const pageId = params.get('page');
const nav = useDataSourceSettingsNav(uid, pageId);
return (
<Page navModel={nav}>
<Page.Contents>
<EditDataSource uid={uid} pageId={pageId} />
</Page.Contents>
</Page>
);
}
export default EditDataSourcePage;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { NavModel } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
import { NewDataSource } from '../components/NewDataSource';
const navModel = getNavModel();
export function NewDataSourcePage() {
return (
<Page navModel={navModel}>
<Page.Contents>
<NewDataSource />
</Page.Contents>
</Page>
);
}
export function getNavModel(): NavModel {
const main = {
icon: 'database',
id: 'datasource-new',
text: 'Add data source',
href: 'datasources/new',
subTitle: 'Choose a data source type',
};
return {
main: main,
node: main,
};
}
export default NewDataSourcePage;

View File

@ -1,169 +0,0 @@
import { screen, render } from '@testing-library/react';
import React from 'react';
import { PluginState } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { cleanUpAction } from 'app/core/actions/cleanUp';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage';
jest.mock('app/core/core', () => {
return {
contextSrv: {
hasPermission: () => true,
hasPermissionInMetadata: () => true,
},
};
});
const getMockNode = () => ({
text: 'text',
subTitle: 'subtitle',
icon: 'icon',
});
const getProps = (): Props => ({
...getRouteComponentProps(),
navModel: {
node: getMockNode(),
main: getMockNode(),
},
dataSource: getMockDataSource(),
dataSourceMeta: getMockPlugin(),
dataSourceId: 'x',
deleteDataSource: jest.fn(),
loadDataSource: jest.fn(),
setDataSourceName,
updateDataSource: jest.fn(),
initDataSourceSettings: jest.fn(),
testDataSource: jest.fn(),
setIsDefault,
dataSourceLoaded,
cleanUpAction,
page: null,
plugin: null,
loadError: null,
loading: false,
testingStatus: {},
});
describe('Render', () => {
it('should not render loading when props are ready', () => {
render(<DataSourceSettingsPage {...getProps()} />);
expect(screen.queryByText('Loading ...')).not.toBeInTheDocument();
});
it('should render loading if datasource is not ready', () => {
const mockProps = getProps();
mockProps.dataSource.id = 0;
mockProps.loading = true;
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByText('Loading ...')).toBeInTheDocument();
});
it('should render beta info text if plugin state is beta', () => {
const mockProps = getProps();
mockProps.dataSourceMeta.state = PluginState.beta;
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeInTheDocument();
});
it('should render alpha info text if plugin state is alpha', () => {
const mockProps = getProps();
mockProps.dataSourceMeta.state = PluginState.alpha;
render(<DataSourceSettingsPage {...mockProps} />);
expect(
screen.getByTitle('This feature is experimental and future updates might not be backward compatible')
).toBeInTheDocument();
});
it('should not render is ready only message is readOnly is false', () => {
const mockProps = getProps();
mockProps.dataSource.readOnly = false;
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.queryByLabelText(selectors.pages.DataSource.readOnly)).not.toBeInTheDocument();
});
it('should render is ready only message is readOnly is true', () => {
const mockProps = getProps();
mockProps.dataSource.readOnly = true;
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByLabelText(selectors.pages.DataSource.readOnly)).toBeInTheDocument();
});
it('should render error message with detailed message', () => {
const mockProps = {
...getProps(),
testingStatus: {
message: 'message',
status: 'error',
details: { message: 'detailed message' },
},
};
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
expect(screen.getByText(mockProps.testingStatus.details.message)).toBeInTheDocument();
});
it('should render error message with empty details', () => {
const mockProps = {
...getProps(),
testingStatus: {
message: 'message',
status: 'error',
details: {},
},
};
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
});
it('should render error message without details', () => {
const mockProps = {
...getProps(),
testingStatus: {
message: 'message',
status: 'error',
},
};
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument();
});
it('should render verbose error message with detailed verbose error message', () => {
const mockProps = {
...getProps(),
testingStatus: {
message: 'message',
status: 'error',
details: { message: 'detailed message', verboseMessage: 'verbose message' },
},
};
render(<DataSourceSettingsPage {...mockProps} />);
expect(screen.getByText(mockProps.testingStatus.details.verboseMessage)).toBeInTheDocument();
});
});

View File

@ -1,306 +0,0 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { DataSourceSettings, urlUtil } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Alert, Button } from '@grafana/ui';
import { cleanUpAction } from 'app/core/actions/cleanUp';
import appEvents from 'app/core/app_events';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { StoreState, AccessControlAction } from 'app/types/';
import { ShowConfirmModalEvent } from '../../../types/events';
import {
deleteDataSource,
initDataSourceSettings,
loadDataSource,
testDataSource,
updateDataSource,
} from '../state/actions';
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel';
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
import { getDataSource, getDataSourceMeta } from '../state/selectors';
import BasicSettings from './BasicSettings';
import ButtonRow from './ButtonRow';
import { CloudInfoBox } from './CloudInfoBox';
import { PluginSettings } from './PluginSettings';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
function mapStateToProps(state: StoreState, props: OwnProps) {
const dataSourceId = props.match.params.uid;
const params = new URLSearchParams(props.location.search);
const dataSource = getDataSource(state.dataSources, dataSourceId);
const { plugin, loadError, loading, testingStatus } = state.dataSourceSettings;
const page = params.get('page');
const nav = plugin
? getDataSourceNav(buildNavModel(dataSource, plugin), page || 'settings')
: getDataSourceLoadingNav('settings');
const navModel = getNavModel(
state.navIndex,
page ? `datasource-page-${page}` : `datasource-settings-${dataSourceId}`,
nav
);
return {
dataSource: getDataSource(state.dataSources, dataSourceId),
dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type),
dataSourceId: dataSourceId,
page,
plugin,
loadError,
loading,
testingStatus,
navModel,
};
}
const mapDispatchToProps = {
deleteDataSource,
loadDataSource,
setDataSourceName,
updateDataSource,
setIsDefault,
dataSourceLoaded,
initDataSourceSettings,
testDataSource,
cleanUpAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export class DataSourceSettingsPage extends PureComponent<Props> {
componentDidMount() {
const { initDataSourceSettings, dataSourceId } = this.props;
initDataSourceSettings(dataSourceId);
}
componentWillUnmount() {
this.props.cleanUpAction({
stateSelector: (state) => state.dataSourceSettings,
});
}
onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
await this.props.updateDataSource({ ...this.props.dataSource });
this.testDataSource();
};
onTest = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
this.testDataSource();
};
onDelete = () => {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete',
text: `Are you sure you want to delete the "${this.props.dataSource.name}" data source?`,
yesText: 'Delete',
icon: 'trash-alt',
onConfirm: () => {
this.confirmDelete();
},
})
);
};
confirmDelete = () => {
this.props.deleteDataSource();
};
onModelChange = (dataSource: DataSourceSettings) => {
this.props.dataSourceLoaded(dataSource);
};
isReadOnly() {
return this.props.dataSource.readOnly === true;
}
renderIsReadOnlyMessage() {
return (
<Alert aria-label={selectors.pages.DataSource.readOnly} severity="info" title="Provisioned data source">
This data source was added by config and cannot be modified using the UI. Please contact your server admin to
update this data source.
</Alert>
);
}
renderMissingEditRightsMessage() {
return (
<Alert severity="info" title="Missing rights">
You are not allowed to modify this data source. Please contact your server admin to update this data source.
</Alert>
);
}
testDataSource() {
const { dataSource, testDataSource } = this.props;
testDataSource(dataSource.name);
}
get hasDataSource() {
return this.props.dataSource.id > 0;
}
onNavigateToExplore() {
const { dataSource } = this.props;
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
const url = urlUtil.renderUrl('/explore', { left: exploreState });
return url;
}
renderLoadError() {
const { loadError, dataSource } = this.props;
const canDeleteDataSource =
!this.isReadOnly() && contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
const node = {
text: loadError!,
subTitle: 'Data Source Error',
icon: 'exclamation-triangle',
};
const nav = {
node: node,
main: node,
};
return (
<Page navModel={nav}>
<Page.Contents isLoading={this.props.loading}>
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
<div className="gf-form-button-row">
{canDeleteDataSource && (
<Button type="submit" variant="destructive" onClick={this.onDelete}>
Delete
</Button>
)}
<Button variant="secondary" fill="outline" type="button" onClick={() => history.back()}>
Back
</Button>
</div>
</Page.Contents>
</Page>
);
}
renderConfigPageBody(page: string) {
const { plugin } = this.props;
if (!plugin || !plugin.configPages) {
return null; // still loading
}
for (const p of plugin.configPages) {
if (p.id === page) {
// Investigate is any plugins using this? We should change this interface
return <p.body plugin={plugin} query={{}} />;
}
}
return <div>Page not found: {page}</div>;
}
renderAlertDetails() {
const { testingStatus } = this.props;
return (
<>
{testingStatus?.details?.message}
{testingStatus?.details?.verboseMessage ? (
<details style={{ whiteSpace: 'pre-wrap' }}>{testingStatus?.details?.verboseMessage}</details>
) : null}
</>
);
}
renderSettings() {
const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props;
const canWriteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource);
const canDeleteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
return (
<form onSubmit={this.onSubmit}>
{!canWriteDataSource && this.renderMissingEditRightsMessage()}
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
{dataSourceMeta.state && (
<div className="gf-form">
<label className="gf-form-label width-10">Plugin state</label>
<label className="gf-form-label gf-form-label--transparent">
<PluginStateInfo state={dataSourceMeta.state} />
</label>
</div>
)}
<CloudInfoBox dataSource={dataSource} />
<BasicSettings
dataSourceName={dataSource.name}
isDefault={dataSource.isDefault}
onDefaultChange={(state) => setIsDefault(state)}
onNameChange={(name) => setDataSourceName(name)}
/>
{plugin && (
<PluginSettings
plugin={plugin}
dataSource={dataSource}
dataSourceMeta={dataSourceMeta}
onModelChange={this.onModelChange}
/>
)}
{testingStatus?.message && (
<div className="gf-form-group p-t-2">
<Alert
severity={testingStatus.status === 'error' ? 'error' : 'success'}
title={testingStatus.message}
aria-label={selectors.pages.DataSource.alert}
>
{testingStatus.details && this.renderAlertDetails()}
</Alert>
</div>
)}
<ButtonRow
onSubmit={(event) => this.onSubmit(event)}
canSave={!this.isReadOnly() && canWriteDataSource}
canDelete={!this.isReadOnly() && canDeleteDataSource}
onDelete={this.onDelete}
onTest={(event) => this.onTest(event)}
exploreUrl={this.onNavigateToExplore()}
/>
</form>
);
}
render() {
const { navModel, page, loadError, loading } = this.props;
if (loadError) {
return this.renderLoadError();
}
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
{this.hasDataSource ? <div>{page ? this.renderConfigPageBody(page) : this.renderSettings()}</div> : null}
</Page.Contents>
</Page>
);
}
}
export default connector(DataSourceSettingsPage);

View File

@ -1,21 +1,19 @@
import { of } from 'rxjs';
import { thunkTester } from 'test/core/thunk/thunkTester';
import { BackendSrvRequest, FetchError, FetchResponse } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DataSourceSettings } from '@grafana/data';
import { FetchError } from '@grafana/runtime';
import { ThunkResult, ThunkDispatch } from 'app/types';
import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
import { initDataSourceSettings } from '../state/actions';
import { getMockDataSource } from '../__mocks__';
import * as api from '../api';
import { GenericDataSourcePlugin } from '../types';
import {
findNewName,
nameExits,
InitDataSourceSettingDependencies,
testDataSource,
TestDataSourceDependencies,
getDataSourceUsingUidOrId,
initDataSourceSettings,
loadDataSource,
} from './actions';
import {
initDataSourceSettingsSucceeded,
@ -23,8 +21,10 @@ import {
testDataSourceStarting,
testDataSourceSucceeded,
testDataSourceFailed,
dataSourceLoaded,
} from './reducers';
jest.mock('../api');
jest.mock('app/core/services/backend_srv');
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
@ -67,118 +67,59 @@ const failDataSourceTest = async (error: object) => {
return dispatchedActions;
};
describe('getDataSourceUsingUidOrId', () => {
const uidResponse = {
ok: true,
data: {
id: 111,
uid: 'abcdefg',
},
};
describe('loadDataSource()', () => {
it('should resolve to a data-source if a UID was used for fetching', async () => {
const dataSourceMock = getMockDataSource();
const dispatch = jest.fn();
const getState = jest.fn();
const idResponse = {
ok: true,
data: {
id: 222,
uid: 'xyz',
},
};
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
it('should return UID response data', async () => {
(getBackendSrv as jest.Mock).mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(uidResponse as FetchResponse);
},
});
const dataSource = await loadDataSource(dataSourceMock.uid)(dispatch, getState, undefined);
expect(await getDataSourceUsingUidOrId('abcdefg')).toBe(uidResponse.data);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded(dataSource));
expect(dataSource).toBe(dataSourceMock);
});
it('should return ID response data', async () => {
const uidResponse = {
ok: false,
};
it('should resolve to an empty data-source if an ID (deprecated) was used for fetching', async () => {
const id = 123;
const uid = 'uid';
const dataSourceMock = getMockDataSource({ id, uid });
const dispatch = jest.fn();
const getState = jest.fn();
(getBackendSrv as jest.Mock)
.mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(uidResponse as FetchResponse);
},
})
.mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(idResponse as FetchResponse);
},
});
expect(await getDataSourceUsingUidOrId(222)).toBe(idResponse.data);
});
it('should return empty response data', async () => {
// @ts-ignore
delete window.location;
window.location = {} as Location;
const uidResponse = {
ok: false,
};
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
(getBackendSrv as jest.Mock)
.mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(uidResponse as FetchResponse);
},
})
.mockReturnValueOnce({
fetch: (options: BackendSrvRequest) => {
return of(idResponse as FetchResponse);
},
});
// Fetch the datasource by ID
const dataSource = await loadDataSource(id.toString())(dispatch, getState, undefined);
expect(await getDataSourceUsingUidOrId('222')).toStrictEqual({});
expect(window.location.href).toBe('/datasources/edit/xyz');
});
});
describe('Name exists', () => {
const plugins = getMockPlugins(5);
it('should be true', () => {
const name = 'pretty cool plugin-1';
expect(nameExits(plugins, name)).toEqual(true);
expect(dataSource).toEqual({});
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded({} as DataSourceSettings));
});
it('should be false', () => {
const name = 'pretty cool plugin-6';
it('should redirect to a URL which uses the UID if an ID (deprecated) was used for fetching', async () => {
const id = 123;
const uid = 'uid';
const dataSourceMock = getMockDataSource({ id, uid });
const dispatch = jest.fn();
const getState = jest.fn();
expect(nameExits(plugins, name));
});
});
// @ts-ignore
delete window.location;
window.location = {} as Location;
describe('Find new name', () => {
it('should create a new name', () => {
const plugins = getMockPlugins(5);
const name = 'pretty cool plugin-1';
(api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock);
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
});
// Fetch the datasource by ID
await loadDataSource(id.toString())(dispatch, getState, undefined);
it('should create new name without suffix', () => {
const plugin = getMockPlugin();
plugin.name = 'prometheus';
const plugins = [plugin];
const name = 'prometheus';
expect(findNewName(plugins, name)).toEqual('prometheus-1');
});
it('should handle names that end with -', () => {
const plugin = getMockPlugin();
const plugins = [plugin];
const name = 'pretty cool plugin-';
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
expect(window.location.href).toBe(`/datasources/edit/${uid}`);
});
});
@ -187,7 +128,7 @@ describe('initDataSourceSettings', () => {
it('then initDataSourceSettingsFailed should be dispatched', async () => {
const dispatchedActions = await thunkTester({}).givenThunk(initDataSourceSettings).whenThunkIsDispatched('');
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]);
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid UID'))]);
});
});

View File

@ -1,5 +1,3 @@
import { lastValueFrom } from 'rxjs';
import { DataSourcePluginMeta, DataSourceSettings, locationUtil } from '@grafana/data';
import {
DataSourceWithBackend,
@ -10,14 +8,15 @@ import {
locationService,
} from '@grafana/runtime';
import { updateNavIndex } from 'app/core/actions';
import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types';
import { contextSrv } from '../../../core/services/context_srv';
import * as api from '../api';
import { nameExits, findNewName } from '../utils';
import { buildCategories } from './buildCategories';
import { buildNavModel } from './navModel';
@ -54,7 +53,7 @@ export interface TestDataSourceDependencies {
}
export const initDataSourceSettings = (
pageId: string,
uid: string,
dependencies: InitDataSourceSettingDependencies = {
loadDataSource,
loadDataSourceMeta,
@ -64,21 +63,16 @@ export const initDataSourceSettings = (
}
): ThunkResult<void> => {
return async (dispatch, getState) => {
if (!pageId) {
dispatch(initDataSourceSettingsFailed(new Error('Invalid ID')));
if (!uid) {
dispatch(initDataSourceSettingsFailed(new Error('Invalid UID')));
return;
}
try {
const loadedDataSource = await dispatch(dependencies.loadDataSource(pageId));
const loadedDataSource = await dispatch(dependencies.loadDataSource(uid));
await dispatch(dependencies.loadDataSourceMeta(loadedDataSource));
// have we already loaded the plugin then we can skip the steps below?
if (getState().dataSourceSettings.plugin) {
return;
}
const dataSource = dependencies.getDataSource(getState().dataSources, pageId);
const dataSource = dependencies.getDataSource(getState().dataSources, uid);
const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource!.type);
const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta);
@ -133,16 +127,32 @@ export const testDataSource = (
export function loadDataSources(): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().get('/api/datasources');
const response = await api.getDataSources();
dispatch(dataSourcesLoaded(response));
};
}
export function loadDataSource(uid: string): ThunkResult<Promise<DataSourceSettings>> {
return async (dispatch) => {
const dataSource = await getDataSourceUsingUidOrId(uid);
let dataSource = await api.getDataSourceByIdOrUid(uid);
// Reload route to use UID instead
// -------------------------------
// In case we were trying to fetch and reference a data-source with an old numeric ID
// (which can happen by referencing it with a "old" URL), we would like to automatically redirect
// to the new URL format using the UID.
// [Please revalidate the following]: Unfortunately we can update the location using react router, but need to fully reload the
// route as the nav model page index is not matching with the url in that case.
// And react router has no way to unmount remount a route.
if (uid !== dataSource.uid) {
window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${dataSource.uid}`);
// Avoid a flashing error while the reload happens
dataSource = {} as DataSourceSettings;
}
dispatch(dataSourceLoaded(dataSource));
return dataSource;
};
}
@ -164,80 +174,26 @@ export function loadDataSourceMeta(dataSource: DataSourceSettings): ThunkResult<
};
}
/**
* Get data source by uid or id, if old id detected handles redirect
*/
export async function getDataSourceUsingUidOrId(uid: string | number): Promise<DataSourceSettings> {
// Try first with uid api
try {
const byUid = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettings>({
method: 'GET',
url: `/api/datasources/uid/${uid}`,
params: accessControlQueryParam(),
showErrorAlert: false,
})
);
if (byUid.ok) {
return byUid.data;
}
} catch (err) {
console.log('Failed to lookup data source by uid', err);
}
// try lookup by old db id
const id = typeof uid === 'string' ? parseInt(uid, 10) : uid;
if (!Number.isNaN(id)) {
const response = await lastValueFrom(
getBackendSrv().fetch<DataSourceSettings>({
method: 'GET',
url: `/api/datasources/${id}`,
params: accessControlQueryParam(),
showErrorAlert: false,
})
);
// If the uid is a number, then this is a refresh on one of the settings tabs
// and we can return the response data
if (response.ok && typeof uid === 'number' && response.data.id === uid) {
return response.data;
}
// Not ideal to do a full page reload here but so tricky to handle this
// otherwise We can update the location using react router, but need to
// fully reload the route as the nav model page index is not matching with
// the url in that case. And react router has no way to unmount remount a
// route
if (response.ok && response.data.id.toString() === uid) {
window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${response.data.uid}`);
return {} as DataSourceSettings; // avoids flashing an error
}
}
throw Error('Could not find data source');
}
export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
return async (dispatch, getStore) => {
await dispatch(loadDataSources());
const dataSources = getStore().dataSources.dataSources;
const isFirstDataSource = dataSources.length === 0;
const newInstance = {
name: plugin.name,
type: plugin.id,
access: 'proxy',
isDefault: dataSources.length === 0,
isDefault: isFirstDataSource,
};
if (nameExits(dataSources, newInstance.name)) {
newInstance.name = findNewName(dataSources, newInstance.name);
}
const result = await getBackendSrv().post('/api/datasources', newInstance);
await getDatasourceSrv().reload();
const result = await api.createDataSource(newInstance);
await getDatasourceSrv().reload();
await contextSrv.fetchUserPermissions();
locationService.push(`/datasources/edit/${result.datasource.uid}`);
@ -247,7 +203,7 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult<void> {
export function loadDataSourcePlugins(): ThunkResult<void> {
return async (dispatch) => {
dispatch(dataSourcePluginsLoad());
const plugins = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' });
const plugins = await api.getDataSourcePlugins();
const categories = buildCategories(plugins);
dispatch(dataSourcePluginsLoaded({ plugins, categories }));
};
@ -255,67 +211,19 @@ export function loadDataSourcePlugins(): ThunkResult<void> {
export function updateDataSource(dataSource: DataSourceSettings): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); // by UID not yet supported
await api.updateDataSource(dataSource);
await getDatasourceSrv().reload();
return dispatch(loadDataSource(dataSource.uid));
};
}
export function deleteDataSource(): ThunkResult<void> {
export function deleteLoadedDataSource(): ThunkResult<void> {
return async (dispatch, getStore) => {
const dataSource = getStore().dataSources.dataSource;
const { uid } = getStore().dataSources.dataSource;
await getBackendSrv().delete(`/api/datasources/${dataSource.id}`);
await api.deleteDataSource(uid);
await getDatasourceSrv().reload();
locationService.push('/datasources');
};
}
interface ItemWithName {
name: string;
}
export function nameExits(dataSources: ItemWithName[], name: string) {
return (
dataSources.filter((dataSource) => {
return dataSource.name.toLowerCase() === name.toLowerCase();
}).length > 0
);
}
export function findNewName(dataSources: ItemWithName[], name: string) {
// Need to loop through current data sources to make sure
// the name doesn't exist
while (nameExits(dataSources, name)) {
// If there's a duplicate name that doesn't end with '-x'
// we can add -1 to the name and be done.
if (!nameHasSuffix(name)) {
name = `${name}-1`;
} else {
// if there's a duplicate name that ends with '-x'
// we can try to increment the last digit until the name is unique
// remove the 'x' part and replace it with the new number
name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`;
}
}
return name;
}
function nameHasSuffix(name: string) {
return name.endsWith('-', name.length - 1);
}
function getLastDigit(name: string) {
return parseInt(name.slice(-1), 10);
}
function incrementLastDigit(digit: number) {
return isNaN(digit) ? 1 : digit + 1;
}
function getNewName(name: string) {
return name.slice(0, name.length - 1);
}

View File

@ -1,6 +1,5 @@
import { DataSourcePluginMeta } from '@grafana/data';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
import { getMockPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
import { buildCategories } from './buildCategories';

View File

@ -0,0 +1,161 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DataSourcePluginMeta, DataSourceSettings, urlUtil } from '@grafana/data';
import { cleanUpAction } from 'app/core/actions/cleanUp';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { getNavModel } from 'app/core/selectors/navModel';
import { AccessControlAction, StoreState } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { DataSourceRights } from '../types';
import {
initDataSourceSettings,
testDataSource,
loadDataSource,
loadDataSources,
loadDataSourcePlugins,
addDataSource,
updateDataSource,
deleteLoadedDataSource,
} from './actions';
import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from './navModel';
import { getDataSource, getDataSourceMeta } from './selectors';
export const useInitDataSourceSettings = (uid: string) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(initDataSourceSettings(uid));
return function cleanUp() {
dispatch(
cleanUpAction({
stateSelector: (state) => state.dataSourceSettings,
})
);
};
}, [uid, dispatch]);
};
export const useTestDataSource = (uid: string) => {
const dispatch = useDispatch();
return () => dispatch(testDataSource(uid));
};
export const useLoadDataSources = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadDataSources());
}, [dispatch]);
};
export const useLoadDataSource = (uid: string) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadDataSource(uid));
}, [dispatch, uid]);
};
export const useLoadDataSourcePlugins = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadDataSourcePlugins());
}, [dispatch]);
};
export const useAddDatasource = () => {
const dispatch = useDispatch();
return (plugin: DataSourcePluginMeta) => {
dispatch(addDataSource(plugin));
};
};
export const useUpdateDatasource = () => {
const dispatch = useDispatch();
return (dataSource: DataSourceSettings) => dispatch(updateDataSource(dataSource));
};
export const useDeleteLoadedDataSource = () => {
const dispatch = useDispatch();
const { name } = useSelector((state: StoreState) => state.dataSources.dataSource);
return () => {
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Delete',
text: `Are you sure you want to delete the "${name}" data source?`,
yesText: 'Delete',
icon: 'trash-alt',
onConfirm: () => dispatch(deleteLoadedDataSource()),
})
);
};
};
export const useDataSource = (uid: string) => {
return useSelector((state: StoreState) => getDataSource(state.dataSources, uid));
};
export const useDataSourceExploreUrl = (uid: string) => {
const dataSource = useDataSource(uid);
const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' });
const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState });
return exploreUrl;
};
export const useDataSourceMeta = (uid: string): DataSourcePluginMeta => {
return useSelector((state: StoreState) => getDataSourceMeta(state.dataSources, uid));
};
export const useDataSourceSettings = () => {
return useSelector((state: StoreState) => state.dataSourceSettings);
};
export const useDataSourceSettingsNav = (dataSourceId: string, pageId: string | null) => {
const dataSource = useDataSource(dataSourceId);
const { plugin, loadError, loading } = useDataSourceSettings();
const navIndex = useSelector((state: StoreState) => state.navIndex);
const navIndexId = pageId ? `datasource-page-${pageId}` : `datasource-settings-${dataSourceId}`;
if (loadError) {
const node = {
text: loadError,
subTitle: 'Data Source Error',
icon: 'exclamation-triangle',
};
return {
node: node,
main: node,
};
}
if (loading || !plugin) {
return getNavModel(navIndex, navIndexId, getDataSourceLoadingNav('settings'));
}
return getNavModel(navIndex, navIndexId, getDataSourceNav(buildNavModel(dataSource, plugin), pageId || 'settings'));
};
export const useDataSourceRights = (uid: string): DataSourceRights => {
const dataSource = useDataSource(uid);
const readOnly = dataSource.readOnly === true;
const hasWriteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource);
const hasDeleteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource);
return {
readOnly,
hasWriteRights,
hasDeleteRights,
};
};

View File

@ -0,0 +1,6 @@
export * from './actions';
export * from './buildCategories';
export * from './hooks';
export * from './navModel';
export * from './reducers';
export * from './selectors';

View File

@ -3,10 +3,10 @@ import { featureEnabled } from '@grafana/runtime';
import { ProBadge } from 'app/core/components/Upgrade/ProBadge';
import config from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { highlightTrial } from 'app/features/admin/utils';
import { AccessControlAction } from 'app/types';
import { highlightTrial } from '../../admin/utils';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
import { GenericDataSourcePlugin } from '../types';
const loadingDSType = 'Loading';

View File

@ -3,8 +3,8 @@ import { reducerTester } from 'test/core/redux/reducerTester';
import { PluginMeta, PluginMetaInfo, PluginType, LayoutModes } from '@grafana/data';
import { DataSourceSettingsState, DataSourcesState } from 'app/types';
import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
import { getMockDataSource, getMockDataSources } from '../__mocks__';
import { GenericDataSourcePlugin } from '../types';
import {
dataSourceLoaded,
@ -53,7 +53,7 @@ describe('dataSourcesReducer', () => {
describe('when dataSourceLoaded is dispatched', () => {
it('then state should be correct', () => {
const dataSource = getMockDataSource();
const dataSource = getMockDataSource<{}>();
reducerTester<DataSourcesState>()
.givenReducer(dataSourcesReducer, initialState)

View File

@ -3,7 +3,7 @@ import { AnyAction, createAction } from '@reduxjs/toolkit';
import { DataSourcePluginMeta, DataSourceSettings, LayoutMode, LayoutModes } from '@grafana/data';
import { DataSourcesState, DataSourceSettingsState, TestingStatus } from 'app/types';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
import { GenericDataSourcePlugin } from '../types';
import { DataSourceTypesLoadedPayload } from './actions';

View File

@ -1,6 +1,5 @@
import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data';
import { DataSourcesState } from '../../../types/datasources';
import { DataSourcesState } from 'app/types/datasources';
export const getDataSources = (state: DataSourcesState) => {
const regex = new RegExp(state.searchQuery, 'i');
@ -10,7 +9,7 @@ export const getDataSources = (state: DataSourcesState) => {
});
};
export const getDataSourcePlugins = (state: DataSourcesState) => {
export const getFilteredDataSourcePlugins = (state: DataSourcesState) => {
const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i');
return state.plugins.filter((type: DataSourcePluginMeta) => {

View File

@ -0,0 +1,9 @@
import { DataQuery, DataSourceApi, DataSourceJsonData, DataSourcePlugin } from '@grafana/data';
export type GenericDataSourcePlugin = DataSourcePlugin<DataSourceApi<DataQuery, DataSourceJsonData>>;
export type DataSourceRights = {
readOnly: boolean;
hasWriteRights: boolean;
hasDeleteRights: boolean;
};

View File

@ -0,0 +1,41 @@
import { getMockPlugin, getMockPlugins } from 'app/features/plugins/__mocks__/pluginMocks';
import { nameExits, findNewName } from './utils';
describe('Datasources / Utils', () => {
describe('nameExists()', () => {
const plugins = getMockPlugins(5);
it('should return TRUE if an existing plugin already has the same name', () => {
expect(nameExits(plugins, plugins[1].name)).toEqual(true);
});
it('should return FALSE if no plugin has the same name yet', () => {
expect(nameExits(plugins, 'unknown-plugin'));
});
});
describe('findNewName()', () => {
it('should return with a new name in case an existing plugin already has the same name', () => {
const plugins = getMockPlugins(5);
const name = 'pretty cool plugin-1';
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6');
});
it('should handle names without suffixes when name already exists', () => {
const name = 'prometheus';
const plugin = getMockPlugin({ name });
expect(findNewName([plugin], name)).toEqual('prometheus-1');
});
it('should handle names that end with a "-" when name does not exist yet', () => {
const plugin = getMockPlugin();
const plugins = [plugin];
const name = 'pretty cool plugin-';
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
});
});
});

View File

@ -0,0 +1,47 @@
interface ItemWithName {
name: string;
}
export function nameExits(dataSources: ItemWithName[], name: string) {
return (
dataSources.filter((dataSource) => {
return dataSource.name.toLowerCase() === name.toLowerCase();
}).length > 0
);
}
export function findNewName(dataSources: ItemWithName[], name: string) {
// Need to loop through current data sources to make sure
// the name doesn't exist
while (nameExits(dataSources, name)) {
// If there's a duplicate name that doesn't end with '-x'
// we can add -1 to the name and be done.
if (!nameHasSuffix(name)) {
name = `${name}-1`;
} else {
// if there's a duplicate name that ends with '-x'
// we can try to increment the last digit until the name is unique
// remove the 'x' part and replace it with the new number
name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`;
}
}
return name;
}
function nameHasSuffix(name: string) {
return name.endsWith('-', name.length - 1);
}
function getLastDigit(name: string) {
return parseInt(name.slice(-1), 10);
}
function incrementLastDigit(digit: number) {
return isNaN(digit) ? 1 : digit + 1;
}
function getNewName(name: string) {
return name.slice(0, name.length - 1);
}

View File

@ -4,7 +4,7 @@ import React, { PureComponent } from 'react';
import { AppEvents, PluginMeta, DataSourceApi } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
import DashboardsTable from 'app/features/datasources/DashboardsTable';
import DashboardsTable from 'app/features/datasources/components/DashboardsTable';
import { PluginDashboard } from 'app/types';
interface Props {

View File

@ -22,25 +22,27 @@ const getOriginalActionType = (type: string) => {
return type.substring(0, separator);
};
export const initialState: ReducerState = {
items: pluginsAdapter.getInitialState(),
requests: {},
settings: {
displayMode: PluginListDisplayMode.Grid,
},
// Backwards compatibility
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
plugins: [],
errors: [],
searchQuery: '',
hasFetched: false,
dashboards: [],
isLoadingPluginDashboards: false,
panels: {},
};
const slice = createSlice({
name: 'plugins',
initialState: {
items: pluginsAdapter.getInitialState(),
requests: {},
settings: {
displayMode: PluginListDisplayMode.Grid,
},
// Backwards compatibility
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
plugins: [],
errors: [],
searchQuery: '',
hasFetched: false,
dashboards: [],
isLoadingPluginDashboards: false,
panels: {},
} as ReducerState,
initialState,
reducers: {
setDisplayMode(state, action: PayloadAction<PluginListDisplayMode>) {
state.settings.displayMode = action.payload;

View File

@ -28,7 +28,7 @@ import * as flatten from 'app/core/utils/flatten';
import kbn from 'app/core/utils/kbn';
import * as ticks from 'app/core/utils/ticks';
import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
import { locateWithCache, registerPluginInCache } from './pluginCacheBuster';

View File

@ -11,11 +11,10 @@ import { Props, TeamList } from './TeamList';
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
import { setSearchQuery, setTeamsSearchPage } from './state/reducers';
jest.mock('app/core/config', () => {
return {
featureToggles: { accesscontrol: false },
};
});
jest.mock('app/core/config', () => ({
...jest.requireActual('app/core/config'),
featureToggles: { accesscontrol: false },
}));
const setup = (propOverrides?: object) => {
const props: Props = {

View File

@ -1,19 +1,21 @@
import { DataSourceSettings } from '@grafana/data';
import { getMockDataSource } from 'app/features/datasources/__mocks__';
import { createDatasourceSettings } from '../../../../features/datasources/mocks';
import { ElasticsearchOptions } from '../types';
export function createDefaultConfigOptions(
options?: Partial<ElasticsearchOptions>
): DataSourceSettings<ElasticsearchOptions> {
return createDatasourceSettings<ElasticsearchOptions>({
timeField: '@time',
esVersion: '7.0.0',
interval: 'Hourly',
timeInterval: '10s',
maxConcurrentShardRequests: 300,
logMessageField: 'test.message',
logLevelField: 'test.level',
...options,
return getMockDataSource<ElasticsearchOptions>({
jsonData: {
timeField: '@time',
esVersion: '7.0.0',
interval: 'Hourly',
timeInterval: '10s',
maxConcurrentShardRequests: 300,
logMessageField: 'test.message',
logLevelField: 'test.level',
...options,
},
});
}

View File

@ -1,6 +1,6 @@
import { DataSourceSettings } from '@grafana/data';
import { createDatasourceSettings } from '../../../features/datasources/mocks';
import { getMockDataSource } from '../../../features/datasources/__mocks__';
import { LokiDatasource } from './datasource';
import { LokiOptions } from './types';
@ -51,7 +51,7 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF
}
export function createDefaultConfigOptions(): DataSourceSettings<LokiOptions> {
return createDatasourceSettings<LokiOptions>({
maxLines: '531',
return getMockDataSource<LokiOptions>({
jsonData: { maxLines: '531' },
});
}

View File

@ -1,13 +1,15 @@
import { DataSourceSettings } from '@grafana/data';
import { createDatasourceSettings } from '../../../../features/datasources/mocks';
import { getMockDataSource } from '../../../../features/datasources/__mocks__';
import { PromOptions } from '../types';
export function createDefaultConfigOptions(): DataSourceSettings<PromOptions> {
return createDatasourceSettings<PromOptions>({
timeInterval: '1m',
queryTimeout: '1m',
httpMethod: 'GET',
directUrl: 'url',
return getMockDataSource<PromOptions>({
jsonData: {
timeInterval: '1m',
queryTimeout: '1m',
httpMethod: 'GET',
directUrl: 'url',
},
});
}

View File

@ -91,28 +91,28 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/datasources',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage')
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/pages/DataSourcesListPage')
),
},
{
path: '/datasources/edit/:uid/',
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage'
)
() => import(/* webpackChunkName: "EditDataSourcePage"*/ '../features/datasources/pages/EditDataSourcePage')
),
},
{
path: '/datasources/edit/:uid/dashboards',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards')
() =>
import(
/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/pages/DataSourceDashboardsPage'
)
),
},
{
path: '/datasources/new',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage')
() => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/pages/NewDataSourcePage')
),
},
{

View File

@ -1,6 +1,6 @@
import { DataSourcePluginMeta, DataSourceSettings, LayoutMode } from '@grafana/data';
import { HealthCheckResultDetails } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { GenericDataSourcePlugin } from 'app/features/datasources/settings/PluginSettings';
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
export interface DataSourcesState {
dataSources: DataSourceSettings[];

View File

@ -40,7 +40,10 @@ angular.module('grafana.directives', []);
angular.module('grafana.filters', []);
angular.module('grafana.routes', ['ngRoute']);
jest.mock('../app/core/core', () => ({ appEvents: testAppEvents }));
jest.mock('../app/core/core', () => ({
...jest.requireActual('../app/core/core'),
appEvents: testAppEvents,
}));
jest.mock('../app/angular/partials', () => ({}));
jest.mock('../app/features/plugins/plugin_loader', () => ({}));