From b3d5e678f4987ee7900f947b257128c9dc3afc1f Mon Sep 17 00:00:00 2001 From: Shavonn Brown Date: Thu, 23 Jan 2020 20:37:20 -0500 Subject: [PATCH] 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 --- .../settings/DataSourceSettingsPage.test.tsx | 2 + .../settings/DataSourceSettingsPage.tsx | 131 +++++----------- .../datasources/state/actions.test.ts | 143 +++++++++++++++++- .../app/features/datasources/state/actions.ts | 82 +++++++++- .../datasources/state/reducers.test.ts | 38 ++++- .../features/datasources/state/reducers.ts | 73 ++++++++- public/app/types/datasources.ts | 10 ++ public/app/types/store.ts | 3 +- 8 files changed, 382 insertions(+), 100 deletions(-) diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index 43056f1fad4..064f536d9e6 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -24,6 +24,8 @@ const setup = (propOverrides?: object) => { loadDataSource: jest.fn(), setDataSourceName, updateDataSource: jest.fn(), + initDataSourceSettings: jest.fn(), + testDataSource: jest.fn(), setIsDefault, dataSourceLoaded, query: {}, diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 6c6e6c53adb..16ee086661c 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -1,7 +1,6 @@ // 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 { - 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 { + componentDidMount() { + const { initDataSourceSettings, pageId } = this.props; + initDataSourceSettings(pageId); } onSubmit = async (evt: React.FormEvent) => { @@ -136,40 +106,9 @@ export class DataSourceSettingsPage extends PureComponent { ); } - 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 { } 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 { } renderSettings() { - const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource } = this.props; - const { testingMessage, testingStatus, plugin } = this.state; + const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, testingStatus, plugin } = this.props; return (
@@ -265,10 +203,10 @@ export class DataSourceSettingsPage extends PureComponent { )}
- {testingMessage && ( -
+ {testingStatus && testingStatus.message && ( +
- {testingStatus === 'error' ? ( + {testingStatus.status === 'error' ? ( ) : ( @@ -276,7 +214,7 @@ export class DataSourceSettingsPage extends PureComponent {
- {testingMessage} + {testingStatus.message}
@@ -294,8 +232,7 @@ export class DataSourceSettingsPage extends PureComponent { } 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) +); diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts index d0a8caad9a7..a4604939c7a 100644 --- a/public/app/features/datasources/state/actions.test.ts +++ b/public/app/features/datasources/state/actions.test.ts @@ -1,5 +1,20 @@ -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; 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 => (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)]); + }); + }); +}); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 0557b341027..1eca6a63047 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -1,10 +1,10 @@ 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 => { + 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 => { + 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 { 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(); diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts index 286f756b69c..48624e55bb7 100644 --- a/public/app/features/datasources/state/reducers.test.ts +++ b/public/app/features/datasources/state/reducers.test.ts @@ -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() + .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() + .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; + }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 90edb44220e..191f6f84d2b 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -1,9 +1,10 @@ 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( + 'dataSourceSettings/initDataSourceSettingsSucceeded' +); + +export const initDataSourceSettingsFailed = createAction('dataSourceSettings/initDataSourceSettingsFailed'); + +export const testDataSourceStarting = createAction('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, }; diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 2db8bd2626a..a5889014841 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -1,5 +1,6 @@ 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; diff --git a/public/app/types/store.ts b/public/app/types/store.ts index b02cd1be549..4041422d7ad 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -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;