Commit b3d5e678 by Shavonn Brown Committed by GitHub

Make importDataSourcePlugin cancelable (#21430)

* make importDataSourcePlugin cancelable

* fix imported plugin assignment

* init datasource plugin to redux

* remove commented

* testDataSource to redux

* add err console log

* isTesting is never used

* tests, loadError type

* more tests, testingStatus obj
parent c0b839ef
......@@ -24,6 +24,8 @@ const setup = (propOverrides?: object) => {
loadDataSource: jest.fn(),
setDataSourceName,
updateDataSource: jest.fn(),
initDataSourceSettings: jest.fn(),
testDataSource: jest.fn(),
setIsDefault,
dataSourceLoaded,
query: {},
......
// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import isString from 'lodash/isString';
import { e2e } from '@grafana/e2e';
// Components
......@@ -11,11 +10,15 @@ import BasicSettings from './BasicSettings';
import ButtonRow from './ButtonRow';
// Services & Utils
import appEvents from 'app/core/app_events';
import { backendSrv } from 'app/core/services/backend_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Actions & selectors
import { getDataSource, getDataSourceMeta } from '../state/selectors';
import { deleteDataSource, loadDataSource, updateDataSource } from '../state/actions';
import {
deleteDataSource,
loadDataSource,
updateDataSource,
initDataSourceSettings,
testDataSource,
} from '../state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
// Types
......@@ -24,8 +27,8 @@ import { UrlQueryMap } from '@grafana/runtime';
import { DataSourcePluginMeta, DataSourceSettings, NavModel } from '@grafana/data';
import { getDataSourceLoadingNav } from '../state/navModel';
import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers';
import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp';
export interface Props {
navModel: NavModel;
......@@ -38,55 +41,22 @@ export interface Props {
updateDataSource: typeof updateDataSource;
setIsDefault: typeof setIsDefault;
dataSourceLoaded: typeof dataSourceLoaded;
initDataSourceSettings: typeof initDataSourceSettings;
testDataSource: typeof testDataSource;
plugin?: GenericDataSourcePlugin;
query: UrlQueryMap;
page?: string;
testingStatus?: {
message?: string;
status?: string;
};
loadError?: Error | string;
}
interface State {
plugin?: GenericDataSourcePlugin;
isTesting?: boolean;
testingMessage?: string;
testingStatus?: string;
loadError?: any;
}
export class DataSourceSettingsPage extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
plugin: props.plugin,
};
}
async loadPlugin(pluginId?: string) {
const { dataSourceMeta } = this.props;
let importedPlugin: GenericDataSourcePlugin;
try {
importedPlugin = await importDataSourcePlugin(dataSourceMeta);
} catch (e) {
console.log('Failed to import plugin module', e);
}
this.setState({ plugin: importedPlugin });
}
async componentDidMount() {
const { loadDataSource, pageId } = this.props;
if (isNaN(pageId)) {
this.setState({ loadError: 'Invalid ID' });
return;
}
try {
await loadDataSource(pageId);
if (!this.state.plugin) {
await this.loadPlugin();
}
} catch (err) {
this.setState({ loadError: err });
}
export class DataSourceSettingsPage extends PureComponent<Props> {
componentDidMount() {
const { initDataSourceSettings, pageId } = this.props;
initDataSourceSettings(pageId);
}
onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
......@@ -136,40 +106,9 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
);
}
async testDataSource() {
const dsApi = await getDatasourceSrv().get(this.props.dataSource.name);
if (!dsApi.testDatasource) {
return;
}
this.setState({ isTesting: true, testingMessage: 'Testing...', testingStatus: 'info' });
backendSrv.withNoBackendCache(async () => {
try {
const result = await dsApi.testDatasource();
this.setState({
isTesting: false,
testingStatus: result.status,
testingMessage: result.message,
});
} catch (err) {
let message = '';
if (err.statusText) {
message = 'HTTP Error ' + err.statusText;
} else {
message = err.message;
}
this.setState({
isTesting: false,
testingStatus: 'error',
testingMessage: message,
});
}
});
testDataSource() {
const { dataSource, testDataSource } = this.props;
testDataSource(dataSource.name);
}
get hasDataSource() {
......@@ -218,7 +157,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
}
renderConfigPageBody(page: string) {
const { plugin } = this.state;
const { plugin } = this.props;
if (!plugin || !plugin.configPages) {
return null; // still loading
}
......@@ -233,8 +172,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
}
renderSettings() {
const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource } = this.props;
const { testingMessage, testingStatus, plugin } = this.state;
const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, testingStatus, plugin } = this.props;
return (
<form onSubmit={this.onSubmit}>
......@@ -265,10 +203,10 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
)}
<div className="gf-form-group">
{testingMessage && (
<div className={`alert-${testingStatus} alert`} aria-label={e2e.pages.DataSource.selectors.alert}>
{testingStatus && testingStatus.message && (
<div className={`alert-${testingStatus.status} alert`} aria-label={e2e.pages.DataSource.selectors.alert}>
<div className="alert-icon">
{testingStatus === 'error' ? (
{testingStatus.status === 'error' ? (
<i className="fa fa-exclamation-triangle" />
) : (
<i className="fa fa-check" />
......@@ -276,7 +214,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
</div>
<div className="alert-body">
<div className="alert-title" aria-label={e2e.pages.DataSource.selectors.alertMessage}>
{testingMessage}
{testingStatus.message}
</div>
</div>
</div>
......@@ -294,8 +232,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
}
render() {
const { navModel, page } = this.props;
const { loadError } = this.state;
const { navModel, page, loadError } = this.props;
if (loadError) {
return this.renderLoadError(loadError);
......@@ -315,6 +252,7 @@ function mapStateToProps(state: StoreState) {
const pageId = getRouteParamsId(state.location);
const dataSource = getDataSource(state.dataSources, pageId);
const page = state.location.query.page as string;
const { plugin, loadError, testingStatus } = state.dataSourceSettings;
return {
navModel: getNavModel(
......@@ -327,6 +265,9 @@ function mapStateToProps(state: StoreState) {
pageId: pageId,
query: state.location.query,
page,
plugin,
loadError,
testingStatus,
};
}
......@@ -337,6 +278,10 @@ const mapDispatchToProps = {
updateDataSource,
setIsDefault,
dataSourceLoaded,
initDataSourceSettings,
testDataSource,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettingsPage));
export default hot(module)(
connectWithCleanUp(mapStateToProps, mapDispatchToProps, state => state.dataSourceSettings)(DataSourceSettingsPage)
);
import { findNewName, nameExits } from './actions';
import { findNewName, nameExits, InitDataSourceSettingDependencies, testDataSource } from './actions';
import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks';
import { thunkTester } from 'test/core/thunk/thunkTester';
import {
initDataSourceSettingsSucceeded,
initDataSourceSettingsFailed,
testDataSourceStarting,
testDataSourceSucceeded,
testDataSourceFailed,
} from './reducers';
import { initDataSourceSettings } from '../state/actions';
import { ThunkResult, ThunkDispatch } from 'app/types';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
import * as DatasourceSrv from 'app/features/plugins/datasource_srv';
jest.mock('app/features/plugins/datasource_srv');
const getDatasourceSrvMock = (DatasourceSrv.getDatasourceSrv as any) as jest.Mock<DatasourceSrv.DatasourceSrv>;
describe('Name exists', () => {
const plugins = getMockPlugins(5);
......@@ -42,3 +57,129 @@ describe('Find new name', () => {
expect(findNewName(plugins, name)).toEqual('pretty cool plugin-');
});
});
describe('initDataSourceSettings', () => {
describe('when pageId is not a number', () => {
it('then initDataSourceSettingsFailed should be dispatched', async () => {
const dispatchedActions = await thunkTester({})
.givenThunk(initDataSourceSettings)
.whenThunkIsDispatched('some page');
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]);
});
});
describe('when pageId is a number', () => {
it('then initDataSourceSettingsSucceeded should be dispatched', async () => {
const thunkMock = (): ThunkResult<void> => (dispatch: ThunkDispatch, getState) => {};
const dataSource = { type: 'app' };
const dataSourceMeta = { id: 'some id' };
const dependencies: InitDataSourceSettingDependencies = {
loadDataSource: jest.fn(thunkMock),
getDataSource: jest.fn().mockReturnValue(dataSource),
getDataSourceMeta: jest.fn().mockReturnValue(dataSourceMeta),
importDataSourcePlugin: jest.fn().mockReturnValue({} as GenericDataSourcePlugin),
};
const state = {
dataSourceSettings: {},
dataSources: {},
};
const dispatchedActions = await thunkTester(state)
.givenThunk(initDataSourceSettings)
.whenThunkIsDispatched(256, dependencies);
expect(dispatchedActions).toEqual([initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin)]);
expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1);
expect(dependencies.loadDataSource).toHaveBeenCalledWith(256);
expect(dependencies.getDataSource).toHaveBeenCalledTimes(1);
expect(dependencies.getDataSource).toHaveBeenCalledWith({}, 256);
expect(dependencies.getDataSourceMeta).toHaveBeenCalledTimes(1);
expect(dependencies.getDataSourceMeta).toHaveBeenCalledWith({}, 'app');
expect(dependencies.importDataSourcePlugin).toHaveBeenCalledTimes(1);
expect(dependencies.importDataSourcePlugin).toHaveBeenCalledWith(dataSourceMeta);
});
});
describe('when plugin loading fails', () => {
it('then initDataSourceSettingsFailed should be dispatched', async () => {
const dependencies: InitDataSourceSettingDependencies = {
loadDataSource: jest.fn().mockImplementation(() => {
throw new Error('Error loading plugin');
}),
getDataSource: jest.fn(),
getDataSourceMeta: jest.fn(),
importDataSourcePlugin: jest.fn(),
};
const state = {
dataSourceSettings: {},
dataSources: {},
};
const dispatchedActions = await thunkTester(state)
.givenThunk(initDataSourceSettings)
.whenThunkIsDispatched(301, dependencies);
expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Error loading plugin'))]);
expect(dependencies.loadDataSource).toHaveBeenCalledTimes(1);
expect(dependencies.loadDataSource).toHaveBeenCalledWith(301);
});
});
});
describe('testDataSource', () => {
describe('when a datasource is tested', () => {
it('then testDataSourceStarting and testDataSourceSucceeded should be dispatched', async () => {
getDatasourceSrvMock.mockImplementation(
() =>
({
get: jest.fn().mockReturnValue({
testDatasource: jest.fn().mockReturnValue({
status: '',
message: '',
}),
}),
} as any)
);
const state = {
testingStatus: {
status: '',
message: '',
},
};
const dispatchedActions = await thunkTester(state)
.givenThunk(testDataSource)
.whenThunkIsDispatched('Azure Monitor');
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceSucceeded(state.testingStatus)]);
});
it('then testDataSourceFailed should be dispatched', async () => {
getDatasourceSrvMock.mockImplementation(
() =>
({
get: jest.fn().mockReturnValue({
testDatasource: jest.fn().mockImplementation(() => {
throw new Error('Error testing datasource');
}),
}),
} as any)
);
const result = {
message: 'Error testing datasource',
};
const state = {
testingStatus: {
message: '',
status: '',
},
};
const dispatchedActions = await thunkTester(state)
.givenThunk(testDataSource)
.whenThunkIsDispatched('Azure Monitor');
expect(dispatchedActions).toEqual([testDataSourceStarting(), testDataSourceFailed(result)]);
});
});
});
import config from '../../../core/config';
import { getBackendSrv } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { updateLocation, updateNavIndex } from 'app/core/actions';
import { buildNavModel } from './navModel';
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
import { DataSourcePluginCategory, ThunkResult } from 'app/types';
import { DataSourcePluginCategory, ThunkResult, ThunkDispatch } from 'app/types';
import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache';
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
import {
......@@ -13,14 +13,90 @@ import {
dataSourcePluginsLoad,
dataSourcePluginsLoaded,
dataSourcesLoaded,
initDataSourceSettingsFailed,
initDataSourceSettingsSucceeded,
testDataSourceStarting,
testDataSourceSucceeded,
testDataSourceFailed,
} from './reducers';
import { buildCategories } from './buildCategories';
import { getDataSource, getDataSourceMeta } from './selectors';
export interface DataSourceTypesLoadedPayload {
plugins: DataSourcePluginMeta[];
categories: DataSourcePluginCategory[];
}
export interface InitDataSourceSettingDependencies {
loadDataSource: typeof loadDataSource;
getDataSource: typeof getDataSource;
getDataSourceMeta: typeof getDataSourceMeta;
importDataSourcePlugin: typeof importDataSourcePlugin;
}
export const initDataSourceSettings = (
pageId: number,
dependencies: InitDataSourceSettingDependencies = {
loadDataSource,
getDataSource,
getDataSourceMeta,
importDataSourcePlugin,
}
): ThunkResult<void> => {
return async (dispatch: ThunkDispatch, getState) => {
if (isNaN(pageId)) {
dispatch(initDataSourceSettingsFailed(new Error('Invalid ID')));
return;
}
try {
await dispatch(dependencies.loadDataSource(pageId));
if (getState().dataSourceSettings.plugin) {
return;
}
const dataSource = dependencies.getDataSource(getState().dataSources, pageId);
const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource.type);
const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta);
dispatch(initDataSourceSettingsSucceeded(importedPlugin));
} catch (err) {
console.log('Failed to import plugin module', err);
dispatch(initDataSourceSettingsFailed(err));
}
};
};
export const testDataSource = (dataSourceName: string): ThunkResult<void> => {
return async (dispatch: ThunkDispatch, getState) => {
const dsApi = await getDatasourceSrv().get(dataSourceName);
if (!dsApi.testDatasource) {
return;
}
dispatch(testDataSourceStarting());
getBackendSrv().withNoBackendCache(async () => {
try {
const result = await dsApi.testDatasource();
dispatch(testDataSourceSucceeded(result));
} catch (err) {
let message = '';
if (err.statusText) {
message = 'HTTP Error ' + err.statusText;
} else {
message = err.message;
}
dispatch(testDataSourceFailed({ message }));
}
});
};
};
export function loadDataSources(): ThunkResult<void> {
return async dispatch => {
const response = await getBackendSrv().get('/api/datasources');
......@@ -123,7 +199,7 @@ export function findNewName(dataSources: ItemWithName[], name: string) {
function updateFrontendSettings() {
return getBackendSrv()
.get('/api/frontend/settings')
.then(settings => {
.then((settings: any) => {
config.datasources = settings.datasources;
config.defaultDatasource = settings.defaultDatasource;
getDatasourceSrv().init();
......
......@@ -12,11 +12,16 @@ import {
setDataSourcesSearchQuery,
setDataSourceTypeSearchQuery,
setIsDefault,
dataSourceSettingsReducer,
initialDataSourceSettingsState,
initDataSourceSettingsSucceeded,
initDataSourceSettingsFailed,
} from './reducers';
import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks';
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
import { DataSourcesState } from 'app/types';
import { DataSourcesState, DataSourceSettingsState } from 'app/types';
import { PluginMeta, PluginMetaInfo, PluginType } from '@grafana/data';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
const mockPlugin = () =>
({
......@@ -136,3 +141,34 @@ describe('dataSourcesReducer', () => {
});
});
});
describe('dataSourceSettingsReducer', () => {
describe('when initDataSourceSettingsSucceeded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<DataSourceSettingsState>()
.givenReducer(dataSourceSettingsReducer, { ...initialDataSourceSettingsState })
.whenActionIsDispatched(initDataSourceSettingsSucceeded({} as GenericDataSourcePlugin))
.thenStateShouldEqual({
...initialDataSourceSettingsState,
plugin: {} as GenericDataSourcePlugin,
loadError: null,
});
});
});
describe('when initDataSourceSettingsFailed is dispatched', () => {
it('then state should be correct', () => {
reducerTester<DataSourceSettingsState>()
.givenReducer(dataSourceSettingsReducer, {
...initialDataSourceSettingsState,
plugin: {} as GenericDataSourcePlugin,
})
.whenActionIsDispatched(initDataSourceSettingsFailed(new Error('Some error')))
.thenStatePredicateShouldEqual(resultingState => {
expect(resultingState.plugin).toEqual(null);
expect(resultingState.loadError).toEqual('Some error');
return true;
});
});
});
});
import { AnyAction, createAction } from '@reduxjs/toolkit';
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
import { DataSourcesState } from 'app/types';
import { DataSourcesState, DataSourceSettingsState } from 'app/types';
import { LayoutMode, LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
import { DataSourceTypesLoadedPayload } from './actions';
import { GenericDataSourcePlugin } from '../settings/PluginSettings';
export const initialState: DataSourcesState = {
dataSources: [],
......@@ -94,6 +95,76 @@ export const dataSourcesReducer = (state: DataSourcesState = initialState, actio
return state;
};
export const initialDataSourceSettingsState: DataSourceSettingsState = {
testingStatus: {
status: null,
message: null,
},
loadError: null,
plugin: null,
};
export const initDataSourceSettingsSucceeded = createAction<GenericDataSourcePlugin>(
'dataSourceSettings/initDataSourceSettingsSucceeded'
);
export const initDataSourceSettingsFailed = createAction<Error>('dataSourceSettings/initDataSourceSettingsFailed');
export const testDataSourceStarting = createAction<undefined>('dataSourceSettings/testDataSourceStarting');
export const testDataSourceSucceeded = createAction<{
status: string;
message: string;
}>('dataSourceSettings/testDataSourceSucceeded');
export const testDataSourceFailed = createAction<{ message: string }>('dataSourceSettings/testDataSourceFailed');
export const dataSourceSettingsReducer = (
state: DataSourceSettingsState = initialDataSourceSettingsState,
action: AnyAction
): DataSourceSettingsState => {
if (initDataSourceSettingsSucceeded.match(action)) {
return { ...state, plugin: action.payload, loadError: null };
}
if (initDataSourceSettingsFailed.match(action)) {
return { ...state, plugin: null, loadError: action.payload.message };
}
if (testDataSourceStarting.match(action)) {
return {
...state,
testingStatus: {
message: 'Testing...',
status: 'info',
},
};
}
if (testDataSourceSucceeded.match(action)) {
return {
...state,
testingStatus: {
status: action.payload.status,
message: action.payload.message,
},
};
}
if (testDataSourceFailed.match(action)) {
return {
...state,
testingStatus: {
status: 'error',
message: action.payload.message,
},
};
}
return state;
};
export default {
dataSources: dataSourcesReducer,
dataSourceSettings: dataSourceSettingsReducer,
};
import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector';
import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data';
import { GenericDataSourcePlugin } from 'app/features/datasources/settings/PluginSettings';
export interface DataSourcesState {
dataSources: DataSourceSettings[];
......@@ -15,6 +16,15 @@ export interface DataSourcesState {
categories: DataSourcePluginCategory[];
}
export interface DataSourceSettingsState {
plugin?: GenericDataSourcePlugin;
testingStatus?: {
message?: string;
status?: string;
};
loadError?: string;
}
export interface DataSourcePluginCategory {
id: string;
title: string;
......
......@@ -7,7 +7,7 @@ import { AlertRulesState } from './alerting';
import { TeamsState, TeamState } from './teams';
import { FolderState } from './folders';
import { DashboardState } from './dashboard';
import { DataSourcesState } from './datasources';
import { DataSourcesState, DataSourceSettingsState } from './datasources';
import { ExploreState } from './explore';
import { UsersState, UserState, UserAdminState } from './user';
import { OrganizationState } from './organization';
......@@ -28,6 +28,7 @@ export interface StoreState {
dashboard: DashboardState;
panelEditor: PanelEditorState;
dataSources: DataSourcesState;
dataSourceSettings: DataSourceSettingsState;
explore: ExploreState;
users: UsersState;
organization: OrganizationState;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment