Преглед изворни кода

Feat: Adds reconnect for failing datasource in Explore (#16226)

* Style: made outlined buttons and used it in Alert component

* Refactor: clean up state on load data source failure

* Refactor: test data source thunk created

* Refactor: move logic to changeDatasource and call that from intialize

* Refactor: move load explore datasources to own thunk

* Refactor: move logic to updateDatasourceInstanceAction

* Tests: reducer tests

* Test(Explore): Added tests and made thunkTester async

* Fix(Explore): Fixed so that we do not render StartPage if there is no StartPage

* Fix(Explore): Missed type in merge

* Refactor: Thunktester did not fail tests on async failures and prevented queires from running on datasource failures

* Feat: Fadein error alert to prevent flickering

* Feat: Refresh labels after reconnect

* Refactor: Move useLokiForceLabels into useLokiLabels from PR comments

* Feat: adds refresh metrics to Prometheus languageprovider

* Style: removes padding for connected datasources

* Chore: remove implicit anys
Hugo Häggmark пре 6 година
родитељ
комит
988b7c4dc3

+ 6 - 0
packages/grafana-ui/src/types/plugin.ts

@@ -54,8 +54,14 @@ export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends D
   onChange: (value: TQuery) => void;
 }
 
+export enum DatasourceStatus {
+  Connected,
+  Disconnected,
+}
+
 export interface ExploreQueryFieldProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
   datasource: DSType;
+  datasourceStatus: DatasourceStatus;
   query: TQuery;
   error?: string | JSX.Element;
   hint?: QueryHint;

+ 13 - 2
public/app/features/explore/Error.tsx

@@ -2,12 +2,16 @@ import React, { FC } from 'react';
 
 interface Props {
   message: any;
+  button?: {
+    text: string;
+    onClick: (event: React.MouseEvent) => void;
+  };
 }
 
 export const Alert: FC<Props> = props => {
-  const { message } = props;
+  const { message, button } = props;
   return (
-    <div className="gf-form-group section">
+    <div className="alert-container">
       <div className="alert-error alert">
         <div className="alert-icon">
           <i className="fa fa-exclamation-triangle" />
@@ -15,6 +19,13 @@ export const Alert: FC<Props> = props => {
         <div className="alert-body">
           <div className="alert-title">{message}</div>
         </div>
+        {button && (
+          <div className="alert-button">
+            <button className="btn btn-outline-danger" onClick={button.onClick}>
+              {button.text}
+            </button>
+          </div>
+        )}
       </div>
     </div>
   );

+ 18 - 4
public/app/features/explore/Explore.tsx

@@ -28,6 +28,7 @@ import {
   scanStart,
   setQueries,
   refreshExplore,
+  reconnectDatasource,
 } from './state/actions';
 
 // Types
@@ -39,6 +40,7 @@ import { Emitter } from 'app/core/utils/emitter';
 import { ExploreToolbar } from './ExploreToolbar';
 import { scanStopAction } from './state/actionTypes';
 import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
+import { FadeIn } from 'app/core/components/Animations/FadeIn';
 
 interface ExploreProps {
   StartPage?: ComponentClass<ExploreStartPageProps>;
@@ -54,6 +56,7 @@ interface ExploreProps {
   modifyQueries: typeof modifyQueries;
   range: RawTimeRange;
   update: ExploreUpdateState;
+  reconnectDatasource: typeof reconnectDatasource;
   refreshExplore: typeof refreshExplore;
   scanner?: RangeScanner;
   scanning?: boolean;
@@ -201,6 +204,13 @@ export class Explore extends React.PureComponent<ExploreProps> {
     );
   };
 
+  onReconnect = (event: React.MouseEvent<HTMLButtonElement>) => {
+    const { exploreId, reconnectDatasource } = this.props;
+
+    event.preventDefault();
+    reconnectDatasource(exploreId);
+  };
+
   render() {
     const {
       StartPage,
@@ -224,13 +234,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
         {datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
         {datasourceMissing ? this.renderEmptyState() : null}
 
-        {datasourceError && (
+        <FadeIn duration={datasourceError ? 150 : 5} in={datasourceError ? true : false}>
           <div className="explore-container">
-            <Alert message={`Error connecting to datasource: ${datasourceError}`} />
+            <Alert
+              message={`Error connecting to datasource: ${datasourceError}`}
+              button={{ text: 'Reconnect', onClick: this.onReconnect }}
+            />
           </div>
-        )}
+        </FadeIn>
 
-        {datasourceInstance && !datasourceError && (
+        {datasourceInstance && (
           <div className="explore-container">
             <QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} queryKeys={queryKeys} />
             <AutoSizer onResize={this.onResize} disableHeight>
@@ -315,6 +328,7 @@ const mapDispatchToProps = {
   changeTime,
   initializeExplore,
   modifyQueries,
+  reconnectDatasource,
   refreshExplore,
   scanStart,
   scanStopAction,

+ 32 - 5
public/app/features/explore/QueryRow.tsx

@@ -1,7 +1,9 @@
 // Libraries
 import React, { PureComponent } from 'react';
+// @ts-ignore
 import _ from 'lodash';
 import { hot } from 'react-hot-loader';
+// @ts-ignore
 import { connect } from 'react-redux';
 
 // Components
@@ -13,7 +15,14 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
 
 // Types
 import { StoreState } from 'app/types';
-import { RawTimeRange, DataQuery, ExploreDataSourceApi, QueryHint, QueryFixAction } from '@grafana/ui';
+import {
+  RawTimeRange,
+  DataQuery,
+  ExploreDataSourceApi,
+  QueryHint,
+  QueryFixAction,
+  DatasourceStatus,
+} from '@grafana/ui';
 import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
 import { Emitter } from 'app/core/utils/emitter';
 import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
@@ -32,6 +41,7 @@ interface QueryRowProps {
   className?: string;
   exploreId: ExploreId;
   datasourceInstance: ExploreDataSourceApi;
+  datasourceStatus: DatasourceStatus;
   highlightLogsExpressionAction: typeof highlightLogsExpressionAction;
   history: HistoryItem[];
   index: number;
@@ -95,7 +105,16 @@ export class QueryRow extends PureComponent<QueryRowProps> {
   }, 500);
 
   render() {
-    const { datasourceInstance, history, index, query, queryTransactions, exploreEvents, range } = this.props;
+    const {
+      datasourceInstance,
+      history,
+      index,
+      query,
+      queryTransactions,
+      exploreEvents,
+      range,
+      datasourceStatus,
+    } = this.props;
     const transactions = queryTransactions.filter(t => t.rowIndex === index);
     const transactionWithError = transactions.find(t => t.error !== undefined);
     const hint = getFirstHintFromTransactions(transactions);
@@ -110,6 +129,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
           {QueryField ? (
             <QueryField
               datasource={datasourceInstance}
+              datasourceStatus={datasourceStatus}
               query={query}
               error={queryError}
               hint={hint}
@@ -152,12 +172,19 @@ export class QueryRow extends PureComponent<QueryRowProps> {
   }
 }
 
-function mapStateToProps(state: StoreState, { exploreId, index }) {
+function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps) {
   const explore = state.explore;
   const item: ExploreItemState = explore[exploreId];
-  const { datasourceInstance, history, queries, queryTransactions, range } = item;
+  const { datasourceInstance, history, queries, queryTransactions, range, datasourceError } = item;
   const query = queries[index];
-  return { datasourceInstance, history, query, queryTransactions, range };
+  return {
+    datasourceInstance,
+    history,
+    query,
+    queryTransactions,
+    range,
+    datasourceStatus: datasourceError ? DatasourceStatus.Disconnected : DatasourceStatus.Connected,
+  };
 }
 
 const mapDispatchToProps = {

+ 34 - 49
public/app/features/explore/state/actionTypes.ts

@@ -79,7 +79,6 @@ export interface InitializeExplorePayload {
   exploreId: ExploreId;
   containerWidth: number;
   eventBridge: Emitter;
-  exploreDatasources: DataSourceSelectItem[];
   queries: DataQuery[];
   range: RawTimeRange;
   ui: ExploreUIState;
@@ -99,16 +98,22 @@ export interface LoadDatasourcePendingPayload {
   requestedDatasourceName: string;
 }
 
-export interface LoadDatasourceSuccessPayload {
+export interface LoadDatasourceReadyPayload {
   exploreId: ExploreId;
-  StartPage?: any;
-  datasourceInstance: any;
   history: HistoryItem[];
-  logsHighlighterExpressions?: any[];
-  showingStartPage: boolean;
-  supportsGraph: boolean;
-  supportsLogs: boolean;
-  supportsTable: boolean;
+}
+
+export interface TestDatasourcePendingPayload {
+  exploreId: ExploreId;
+}
+
+export interface TestDatasourceFailurePayload {
+  exploreId: ExploreId;
+  error: string;
+}
+
+export interface TestDatasourceSuccessPayload {
+  exploreId: ExploreId;
 }
 
 export interface ModifyQueriesPayload {
@@ -199,6 +204,11 @@ export interface QueriesImportedPayload {
   queries: DataQuery[];
 }
 
+export interface LoadExploreDataSourcesPayload {
+  exploreId: ExploreId;
+  exploreDatasources: DataSourceSelectItem[];
+}
+
 /**
  * Adds a query row after the row with the given index.
  */
@@ -246,13 +256,6 @@ export const initializeExploreAction = actionCreatorFactory<InitializeExplorePay
   'explore/INITIALIZE_EXPLORE'
 ).create();
 
-/**
- * Display an error that happened during the selection of a datasource
- */
-export const loadDatasourceFailureAction = actionCreatorFactory<LoadDatasourceFailurePayload>(
-  'explore/LOAD_DATASOURCE_FAILURE'
-).create();
-
 /**
  * Display an error when no datasources have been configured
  */
@@ -268,12 +271,10 @@ export const loadDatasourcePendingAction = actionCreatorFactory<LoadDatasourcePe
 ).create();
 
 /**
- * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
- * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
- * e.g., Prometheus -> Loki queries.
+ * Datasource loading was completed.
  */
-export const loadDatasourceSuccessAction = actionCreatorFactory<LoadDatasourceSuccessPayload>(
-  'explore/LOAD_DATASOURCE_SUCCESS'
+export const loadDatasourceReadyAction = actionCreatorFactory<LoadDatasourceReadyPayload>(
+  'explore/LOAD_DATASOURCE_READY'
 ).create();
 
 /**
@@ -391,37 +392,21 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
  */
 export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create();
 export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
+export const testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>(
+  'explore/TEST_DATASOURCE_PENDING'
+).create();
+export const testDataSourceSuccessAction = actionCreatorFactory<TestDatasourceSuccessPayload>(
+  'explore/TEST_DATASOURCE_SUCCESS'
+).create();
+export const testDataSourceFailureAction = actionCreatorFactory<TestDatasourceFailurePayload>(
+  'explore/TEST_DATASOURCE_FAILURE'
+).create();
+export const loadExploreDatasources = actionCreatorFactory<LoadExploreDataSourcesPayload>(
+  'explore/LOAD_EXPLORE_DATASOURCES'
+).create();
 
 export type HigherOrderAction =
   | ActionOf<SplitCloseActionPayload>
   | SplitOpenAction
   | ResetExploreAction
   | ActionOf<any>;
-
-export type Action =
-  | ActionOf<AddQueryRowPayload>
-  | ActionOf<ChangeQueryPayload>
-  | ActionOf<ChangeSizePayload>
-  | ActionOf<ChangeTimePayload>
-  | ActionOf<ClearQueriesPayload>
-  | ActionOf<HighlightLogsExpressionPayload>
-  | ActionOf<InitializeExplorePayload>
-  | ActionOf<LoadDatasourceFailurePayload>
-  | ActionOf<LoadDatasourceMissingPayload>
-  | ActionOf<LoadDatasourcePendingPayload>
-  | ActionOf<LoadDatasourceSuccessPayload>
-  | ActionOf<ModifyQueriesPayload>
-  | ActionOf<QueryTransactionFailurePayload>
-  | ActionOf<QueryTransactionStartPayload>
-  | ActionOf<QueryTransactionSuccessPayload>
-  | ActionOf<RemoveQueryRowPayload>
-  | ActionOf<ScanStartPayload>
-  | ActionOf<ScanRangePayload>
-  | ActionOf<SetQueriesPayload>
-  | ActionOf<SplitOpenPayload>
-  | ActionOf<ToggleTablePayload>
-  | ActionOf<ToggleGraphPayload>
-  | ActionOf<ToggleLogsPayload>
-  | ActionOf<UpdateDatasourceInstancePayload>
-  | ActionOf<QueriesImportedPayload>
-  | ActionOf<ToggleLogLevelPayload>;

+ 170 - 47
public/app/features/explore/state/actions.test.ts

@@ -1,4 +1,4 @@
-import { refreshExplore } from './actions';
+import { refreshExplore, testDatasource, loadDatasource } from './actions';
 import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
 import { thunkTester } from 'test/core/thunk/thunkTester';
 import { LogsDedupStrategy } from 'app/core/logs_model';
@@ -8,10 +8,16 @@ import {
   changeTimeAction,
   updateUIStateAction,
   setQueriesAction,
+  testDataSourcePendingAction,
+  testDataSourceSuccessAction,
+  testDataSourceFailureAction,
+  loadDatasourcePendingAction,
+  loadDatasourceReadyAction,
 } from './actionTypes';
 import { Emitter } from 'app/core/core';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
 import { makeInitialUpdateState } from './reducers';
+import { DataQuery } from '@grafana/ui/src/types/datasource';
 
 jest.mock('app/features/plugins/datasource_srv', () => ({
   getDatasourceSrv: () => ({
@@ -41,7 +47,7 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
         eventBridge,
         update,
         datasourceInstance: { name: 'some-datasource' },
-        queries: [],
+        queries: [] as DataQuery[],
         range,
         ui,
       },
@@ -61,87 +67,204 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
 describe('refreshExplore', () => {
   describe('when explore is initialized', () => {
     describe('and update datasource is set', () => {
-      it('then it should dispatch initializeExplore', () => {
+      it('then it should dispatch initializeExplore', async () => {
         const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
 
-        thunkTester(initialState)
+        const dispatchedActions = await thunkTester(initialState)
           .givenThunk(refreshExplore)
-          .whenThunkIsDispatched(exploreId)
-          .thenDispatchedActionsAreEqual(dispatchedActions => {
-            const initializeExplore = dispatchedActions[0] as ActionOf<InitializeExplorePayload>;
-            const { type, payload } = initializeExplore;
-
-            expect(type).toEqual(initializeExploreAction.type);
-            expect(payload.containerWidth).toEqual(containerWidth);
-            expect(payload.eventBridge).toEqual(eventBridge);
-            expect(payload.exploreDatasources).toEqual([]);
-            expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
-            expect(payload.range).toEqual(range);
-            expect(payload.ui).toEqual(ui);
-
-            return true;
-          });
+          .whenThunkIsDispatched(exploreId);
+
+        const initializeExplore = dispatchedActions[2] as ActionOf<InitializeExplorePayload>;
+        const { type, payload } = initializeExplore;
+
+        expect(type).toEqual(initializeExploreAction.type);
+        expect(payload.containerWidth).toEqual(containerWidth);
+        expect(payload.eventBridge).toEqual(eventBridge);
+        expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
+        expect(payload.range).toEqual(range);
+        expect(payload.ui).toEqual(ui);
       });
     });
 
     describe('and update range is set', () => {
-      it('then it should dispatch changeTimeAction', () => {
+      it('then it should dispatch changeTimeAction', async () => {
         const { exploreId, range, initialState } = setup({ range: true });
 
-        thunkTester(initialState)
+        const dispatchedActions = await thunkTester(initialState)
           .givenThunk(refreshExplore)
-          .whenThunkIsDispatched(exploreId)
-          .thenDispatchedActionsAreEqual(dispatchedActions => {
-            expect(dispatchedActions[0].type).toEqual(changeTimeAction.type);
-            expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
+          .whenThunkIsDispatched(exploreId);
 
-            return true;
-          });
+        expect(dispatchedActions[0].type).toEqual(changeTimeAction.type);
+        expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
       });
     });
 
     describe('and update ui is set', () => {
-      it('then it should dispatch updateUIStateAction', () => {
+      it('then it should dispatch updateUIStateAction', async () => {
         const { exploreId, initialState, ui } = setup({ ui: true });
 
-        thunkTester(initialState)
+        const dispatchedActions = await thunkTester(initialState)
           .givenThunk(refreshExplore)
-          .whenThunkIsDispatched(exploreId)
-          .thenDispatchedActionsAreEqual(dispatchedActions => {
-            expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type);
-            expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId });
+          .whenThunkIsDispatched(exploreId);
 
-            return true;
-          });
+        expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type);
+        expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId });
       });
     });
 
     describe('and update queries is set', () => {
-      it('then it should dispatch setQueriesAction', () => {
+      it('then it should dispatch setQueriesAction', async () => {
         const { exploreId, initialState } = setup({ queries: true });
 
-        thunkTester(initialState)
+        const dispatchedActions = await thunkTester(initialState)
           .givenThunk(refreshExplore)
-          .whenThunkIsDispatched(exploreId)
-          .thenDispatchedActionsAreEqual(dispatchedActions => {
-            expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
-            expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
+          .whenThunkIsDispatched(exploreId);
 
-            return true;
-          });
+        expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
+        expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
       });
     });
   });
 
   describe('when update is not initialized', () => {
-    it('then it should not dispatch any actions', () => {
+    it('then it should not dispatch any actions', async () => {
       const exploreId = ExploreId.left;
       const initialState = { explore: { [exploreId]: { initialized: false } } };
 
-      thunkTester(initialState)
+      const dispatchedActions = await thunkTester(initialState)
         .givenThunk(refreshExplore)
-        .whenThunkIsDispatched(exploreId)
-        .thenThereAreNoDispatchedActions();
+        .whenThunkIsDispatched(exploreId);
+
+      expect(dispatchedActions).toEqual([]);
+    });
+  });
+});
+
+describe('test datasource', () => {
+  describe('when testDatasource thunk is dispatched', () => {
+    describe('and testDatasource call on instance is successful', () => {
+      it('then it should dispatch testDataSourceSuccessAction', async () => {
+        const exploreId = ExploreId.left;
+        const mockDatasourceInstance = {
+          testDatasource: () => {
+            return Promise.resolve({ status: 'success' });
+          },
+        };
+
+        const dispatchedActions = await thunkTester({})
+          .givenThunk(testDatasource)
+          .whenThunkIsDispatched(exploreId, mockDatasourceInstance);
+
+        expect(dispatchedActions).toEqual([
+          testDataSourcePendingAction({ exploreId }),
+          testDataSourceSuccessAction({ exploreId }),
+        ]);
+      });
+    });
+
+    describe('and testDatasource call on instance is not successful', () => {
+      it('then it should dispatch testDataSourceFailureAction', async () => {
+        const exploreId = ExploreId.left;
+        const error = 'something went wrong';
+        const mockDatasourceInstance = {
+          testDatasource: () => {
+            return Promise.resolve({ status: 'fail', message: error });
+          },
+        };
+
+        const dispatchedActions = await thunkTester({})
+          .givenThunk(testDatasource)
+          .whenThunkIsDispatched(exploreId, mockDatasourceInstance);
+
+        expect(dispatchedActions).toEqual([
+          testDataSourcePendingAction({ exploreId }),
+          testDataSourceFailureAction({ exploreId, error }),
+        ]);
+      });
+    });
+
+    describe('and testDatasource call on instance throws', () => {
+      it('then it should dispatch testDataSourceFailureAction', async () => {
+        const exploreId = ExploreId.left;
+        const error = 'something went wrong';
+        const mockDatasourceInstance = {
+          testDatasource: () => {
+            throw { statusText: error };
+          },
+        };
+
+        const dispatchedActions = await thunkTester({})
+          .givenThunk(testDatasource)
+          .whenThunkIsDispatched(exploreId, mockDatasourceInstance);
+
+        expect(dispatchedActions).toEqual([
+          testDataSourcePendingAction({ exploreId }),
+          testDataSourceFailureAction({ exploreId, error }),
+        ]);
+      });
+    });
+  });
+});
+
+describe('loading datasource', () => {
+  describe('when loadDatasource thunk is dispatched', () => {
+    describe('and all goes fine', () => {
+      it('then it should dispatch correct actions', async () => {
+        const exploreId = ExploreId.left;
+        const name = 'some-datasource';
+        const initialState = { explore: { [exploreId]: { requestedDatasourceName: name } } };
+        const mockDatasourceInstance = {
+          testDatasource: () => {
+            return Promise.resolve({ status: 'success' });
+          },
+          name,
+          init: jest.fn(),
+          meta: { id: 'some id' },
+        };
+
+        const dispatchedActions = await thunkTester(initialState)
+          .givenThunk(loadDatasource)
+          .whenThunkIsDispatched(exploreId, mockDatasourceInstance);
+
+        expect(dispatchedActions).toEqual([
+          loadDatasourcePendingAction({
+            exploreId,
+            requestedDatasourceName: mockDatasourceInstance.name,
+          }),
+          testDataSourcePendingAction({ exploreId }),
+          testDataSourceSuccessAction({ exploreId }),
+          loadDatasourceReadyAction({ exploreId, history: [] }),
+        ]);
+      });
+    });
+
+    describe('and user changes datasource during load', () => {
+      it('then it should dispatch correct actions', async () => {
+        const exploreId = ExploreId.left;
+        const name = 'some-datasource';
+        const initialState = { explore: { [exploreId]: { requestedDatasourceName: 'some-other-datasource' } } };
+        const mockDatasourceInstance = {
+          testDatasource: () => {
+            return Promise.resolve({ status: 'success' });
+          },
+          name,
+          init: jest.fn(),
+          meta: { id: 'some id' },
+        };
+
+        const dispatchedActions = await thunkTester(initialState)
+          .givenThunk(loadDatasource)
+          .whenThunkIsDispatched(exploreId, mockDatasourceInstance);
+
+        expect(dispatchedActions).toEqual([
+          loadDatasourcePendingAction({
+            exploreId,
+            requestedDatasourceName: mockDatasourceInstance.name,
+          }),
+          testDataSourcePendingAction({ exploreId }),
+          testDataSourceSuccessAction({ exploreId }),
+        ]);
+      });
     });
   });
 });

+ 116 - 87
public/app/features/explore/state/actions.ts

@@ -1,4 +1,5 @@
 // Libraries
+// @ts-ignore
 import _ from 'lodash';
 
 // Services & Utils
@@ -22,6 +23,7 @@ import {
 import { updateLocation } from 'app/core/actions';
 
 // Types
+import { ThunkResult } from 'app/types';
 import {
   RawTimeRange,
   TimeRange,
@@ -31,7 +33,15 @@ import {
   QueryHint,
   QueryFixAction,
 } from '@grafana/ui/src/types';
-import { ExploreId, ExploreUrlState, RangeScanner, ResultType, QueryOptions, ExploreUIState } from 'app/types/explore';
+import {
+  ExploreId,
+  ExploreUrlState,
+  RangeScanner,
+  ResultType,
+  QueryOptions,
+  ExploreUIState,
+  QueryTransaction,
+} from 'app/types/explore';
 import {
   updateDatasourceInstanceAction,
   changeQueryAction,
@@ -42,11 +52,10 @@ import {
   clearQueriesAction,
   initializeExploreAction,
   loadDatasourceMissingAction,
-  loadDatasourceFailureAction,
   loadDatasourcePendingAction,
   queriesImportedAction,
-  LoadDatasourceSuccessPayload,
-  loadDatasourceSuccessAction,
+  LoadDatasourceReadyPayload,
+  loadDatasourceReadyAction,
   modifyQueriesAction,
   queryTransactionFailureAction,
   queryTransactionStartAction,
@@ -65,16 +74,19 @@ import {
   ToggleTablePayload,
   updateUIStateAction,
   runQueriesAction,
+  testDataSourcePendingAction,
+  testDataSourceSuccessAction,
+  testDataSourceFailureAction,
+  loadExploreDatasources,
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { LogsDedupStrategy } from 'app/core/logs_model';
-import { ThunkResult } from 'app/types';
 import { parseTime } from '../TimePicker';
 
 /**
  * Updates UI state and save it to the URL
  */
-const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState>) => {
+const updateExploreUIState = (exploreId: ExploreId, uiStateFragment: Partial<ExploreUIState>): ThunkResult<void> => {
   return dispatch => {
     dispatch(updateUIStateAction({ exploreId, ...uiStateFragment }));
     dispatch(stateSave());
@@ -97,7 +109,14 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<vo
  */
 export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult<void> {
   return async (dispatch, getState) => {
-    const newDataSourceInstance = await getDatasourceSrv().get(datasource);
+    let newDataSourceInstance: DataSourceApi = null;
+
+    if (!datasource) {
+      newDataSourceInstance = await getDatasourceSrv().get();
+    } else {
+      newDataSourceInstance = await getDatasourceSrv().get(datasource);
+    }
+
     const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance;
     const queries = getState().explore[exploreId].queries;
 
@@ -105,12 +124,7 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun
 
     dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: newDataSourceInstance }));
 
-    try {
-      await dispatch(loadDatasource(exploreId, newDataSourceInstance));
-    } catch (error) {
-      console.error(error);
-      return;
-    }
+    await dispatch(loadDatasource(exploreId, newDataSourceInstance));
 
     dispatch(runQueries(exploreId));
   };
@@ -171,6 +185,33 @@ export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
   };
 }
 
+/**
+ * Loads all explore data sources and sets the chosen datasource.
+ * If there are no datasources a missing datasource action is dispatched.
+ */
+export function loadExploreDatasourcesAndSetDatasource(
+  exploreId: ExploreId,
+  datasourceName: string
+): ThunkResult<void> {
+  return dispatch => {
+    const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
+      .getExternal()
+      .map((ds: any) => ({
+        value: ds.name,
+        name: ds.name,
+        meta: ds.meta,
+      }));
+
+    dispatch(loadExploreDatasources({ exploreId, exploreDatasources }));
+
+    if (exploreDatasources.length >= 1) {
+      dispatch(changeDatasource(exploreId, datasourceName));
+    } else {
+      dispatch(loadDatasourceMissingAction({ exploreId }));
+    }
+  };
+}
+
 /**
  * Initialize Explore state with state from the URL and the React component.
  * Call this only on components for with the Explore state has not been initialized.
@@ -185,83 +226,35 @@ export function initializeExplore(
   ui: ExploreUIState
 ): ThunkResult<void> {
   return async dispatch => {
-    const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
-      .getExternal()
-      .map(ds => ({
-        value: ds.name,
-        name: ds.name,
-        meta: ds.meta,
-      }));
-
+    dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
     dispatch(
       initializeExploreAction({
         exploreId,
         containerWidth,
         eventBridge,
-        exploreDatasources,
         queries,
         range,
         ui,
       })
     );
-
-    if (exploreDatasources.length >= 1) {
-      let instance;
-
-      if (datasourceName) {
-        try {
-          instance = await getDatasourceSrv().get(datasourceName);
-        } catch (error) {
-          console.error(error);
-        }
-      }
-      // Checking on instance here because requested datasource could be deleted already
-      if (!instance) {
-        instance = await getDatasourceSrv().get();
-      }
-
-      dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: instance }));
-
-      try {
-        await dispatch(loadDatasource(exploreId, instance));
-      } catch (error) {
-        console.error(error);
-        return;
-      }
-      dispatch(runQueries(exploreId, true));
-    } else {
-      dispatch(loadDatasourceMissingAction({ exploreId }));
-    }
   };
 }
 
 /**
- * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
- * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
- * e.g., Prometheus -> Loki queries.
+ * Datasource loading was successfully completed.
  */
-export const loadDatasourceSuccess = (exploreId: ExploreId, instance: any): ActionOf<LoadDatasourceSuccessPayload> => {
-  // Capabilities
-  const supportsGraph = instance.meta.metrics;
-  const supportsLogs = instance.meta.logs;
-  const supportsTable = instance.meta.tables;
-  // Custom components
-  const StartPage = instance.pluginExports.ExploreStartPage;
-
+export const loadDatasourceReady = (
+  exploreId: ExploreId,
+  instance: DataSourceApi
+): ActionOf<LoadDatasourceReadyPayload> => {
   const historyKey = `grafana.explore.history.${instance.meta.id}`;
   const history = store.getObject(historyKey, []);
   // Save last-used datasource
   store.set(LAST_USED_DATASOURCE_KEY, instance.name);
 
-  return loadDatasourceSuccessAction({
+  return loadDatasourceReadyAction({
     exploreId,
-    StartPage,
-    datasourceInstance: instance,
     history,
-    showingStartPage: Boolean(StartPage),
-    supportsGraph,
-    supportsLogs,
-    supportsTable,
   });
 };
 
@@ -270,8 +263,14 @@ export function importQueries(
   queries: DataQuery[],
   sourceDataSource: DataSourceApi,
   targetDataSource: DataSourceApi
-) {
+): ThunkResult<void> {
   return async dispatch => {
+    if (!sourceDataSource) {
+      // explore not initialized
+      dispatch(queriesImportedAction({ exploreId, queries }));
+      return;
+    }
+
     let importedQueries = queries;
     // Check if queries can be imported from previously selected datasource
     if (sourceDataSource.meta.id === targetDataSource.meta.id) {
@@ -295,16 +294,14 @@ export function importQueries(
 }
 
 /**
- * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ * Tests datasource.
  */
-export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> {
-  return async (dispatch, getState) => {
-    const datasourceName = instance.name;
-
-    // Keep ID to track selection
-    dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
+export const testDatasource = (exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> => {
+  return async dispatch => {
     let datasourceError = null;
 
+    dispatch(testDataSourcePendingAction({ exploreId }));
+
     try {
       const testResult = await instance.testDatasource();
       datasourceError = testResult.status === 'success' ? null : testResult.message;
@@ -313,10 +310,36 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
     }
 
     if (datasourceError) {
-      dispatch(loadDatasourceFailureAction({ exploreId, error: datasourceError }));
-      return Promise.reject(`${datasourceName} loading failed`);
+      dispatch(testDataSourceFailureAction({ exploreId, error: datasourceError }));
+      return;
     }
 
+    dispatch(testDataSourceSuccessAction({ exploreId }));
+  };
+};
+
+/**
+ * Reconnects datasource when there is a connection failure.
+ */
+export const reconnectDatasource = (exploreId: ExploreId): ThunkResult<void> => {
+  return async (dispatch, getState) => {
+    const instance = getState().explore[exploreId].datasourceInstance;
+    dispatch(changeDatasource(exploreId, instance.name));
+  };
+};
+
+/**
+ * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
+ */
+export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> {
+  return async (dispatch, getState) => {
+    const datasourceName = instance.name;
+
+    // Keep ID to track selection
+    dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
+
+    await dispatch(testDatasource(exploreId, instance));
+
     if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
       // User already changed datasource again, discard results
       return;
@@ -331,8 +354,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
       return;
     }
 
-    dispatch(loadDatasourceSuccess(exploreId, instance));
-    return Promise.resolve();
+    dispatch(loadDatasourceReady(exploreId, instance));
   };
 }
 
@@ -502,7 +524,7 @@ export function queryTransactionSuccess(
 /**
  * Main action to run queries and dispatches sub-actions based on which result viewers are active
  */
-export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
+export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<void> {
   return (dispatch, getState) => {
     const {
       datasourceInstance,
@@ -513,8 +535,14 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
       supportsGraph,
       supportsLogs,
       supportsTable,
+      datasourceError,
     } = getState().explore[exploreId];
 
+    if (datasourceError) {
+      // let's not run any queries if data source is in a faulty state
+      return;
+    }
+
     if (!hasNonEmptyQuery(queries)) {
       dispatch(clearQueriesAction({ exploreId }));
       dispatch(stateSave()); // Remember to saves to state and update location
@@ -538,7 +566,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
             instant: true,
             valueWithRefId: true,
           },
-          data => data[0]
+          (data: any) => data[0]
         )
       );
     }
@@ -576,7 +604,7 @@ function runQueriesForType(
   resultType: ResultType,
   queryOptions: QueryOptions,
   resultGetter?: any
-) {
+): ThunkResult<void> {
   return async (dispatch, getState) => {
     const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId];
     const datasourceId = datasourceInstance.meta.id;
@@ -659,9 +687,10 @@ export function splitOpen(): ThunkResult<void> {
     const leftState = getState().explore[ExploreId.left];
     const queryState = getState().location.query[ExploreId.left] as string;
     const urlState = parseUrlState(queryState);
+    const queryTransactions: QueryTransaction[] = [];
     const itemState = {
       ...leftState,
-      queryTransactions: [],
+      queryTransactions,
       queries: leftState.queries.slice(),
       exploreId: ExploreId.right,
       urlState,
@@ -675,7 +704,7 @@ export function splitOpen(): ThunkResult<void> {
  * Saves Explore state to URL using the `left` and `right` parameters.
  * If split view is not active, `right` will not be set.
  */
-export function stateSave() {
+export function stateSave(): ThunkResult<void> {
   return (dispatch, getState) => {
     const { left, right, split } = getState().explore;
     const urlStates: { [index: string]: string } = {};
@@ -720,7 +749,7 @@ const togglePanelActionCreator = (
     | ActionCreator<ToggleGraphPayload>
     | ActionCreator<ToggleLogsPayload>
     | ActionCreator<ToggleTablePayload>
-) => (exploreId: ExploreId, isPanelVisible: boolean) => {
+) => (exploreId: ExploreId, isPanelVisible: boolean): ThunkResult<void> => {
   return dispatch => {
     let uiFragmentStateUpdate: Partial<ExploreUIState>;
     const shouldRunQueries = !isPanelVisible;
@@ -764,7 +793,7 @@ export const toggleTable = togglePanelActionCreator(toggleTableAction);
 /**
  * Change logs deduplication strategy and update URL.
  */
-export const changeDedupStrategy = (exploreId, dedupStrategy: LogsDedupStrategy) => {
+export const changeDedupStrategy = (exploreId: ExploreId, dedupStrategy: LogsDedupStrategy): ThunkResult<void> => {
   return dispatch => {
     dispatch(updateExploreUIState(exploreId, { dedupStrategy }));
   };

+ 124 - 5
public/app/features/explore/state/reducers.test.ts

@@ -5,14 +5,32 @@ import {
   makeInitialUpdateState,
   initialExploreState,
 } from './reducers';
-import { ExploreId, ExploreItemState, ExploreUrlState, ExploreState } from 'app/types/explore';
+import {
+  ExploreId,
+  ExploreItemState,
+  ExploreUrlState,
+  ExploreState,
+  QueryTransaction,
+  RangeScanner,
+} from 'app/types/explore';
 import { reducerTester } from 'test/core/redux/reducerTester';
-import { scanStartAction, scanStopAction, splitOpenAction, splitCloseAction } from './actionTypes';
+import {
+  scanStartAction,
+  scanStopAction,
+  testDataSourcePendingAction,
+  testDataSourceSuccessAction,
+  testDataSourceFailureAction,
+  updateDatasourceInstanceAction,
+  splitOpenAction,
+  splitCloseAction,
+} from './actionTypes';
 import { Reducer } from 'redux';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
 import { updateLocation } from 'app/core/actions/location';
-import { LogsDedupStrategy } from 'app/core/logs_model';
+import { LogsDedupStrategy, LogsModel } from 'app/core/logs_model';
 import { serializeStateToUrlParam } from 'app/core/utils/explore';
+import TableModel from 'app/core/table_model';
+import { DataSourceApi, DataQuery } from '@grafana/ui';
 
 describe('Explore item reducer', () => {
   describe('scanning', () => {
@@ -21,7 +39,7 @@ describe('Explore item reducer', () => {
       const initalState = {
         ...makeExploreItemState(),
         scanning: false,
-        scanner: undefined,
+        scanner: undefined as RangeScanner,
       };
 
       reducerTester()
@@ -53,6 +71,106 @@ describe('Explore item reducer', () => {
         });
     });
   });
+
+  describe('testing datasource', () => {
+    describe('when testDataSourcePendingAction is dispatched', () => {
+      it('then it should set datasourceError', () => {
+        reducerTester()
+          .givenReducer(itemReducer, { datasourceError: {} })
+          .whenActionIsDispatched(testDataSourcePendingAction({ exploreId: ExploreId.left }))
+          .thenStateShouldEqual({ datasourceError: null });
+      });
+    });
+
+    describe('when testDataSourceSuccessAction is dispatched', () => {
+      it('then it should set datasourceError', () => {
+        reducerTester()
+          .givenReducer(itemReducer, { datasourceError: {} })
+          .whenActionIsDispatched(testDataSourceSuccessAction({ exploreId: ExploreId.left }))
+          .thenStateShouldEqual({ datasourceError: null });
+      });
+    });
+
+    describe('when testDataSourceFailureAction is dispatched', () => {
+      it('then it should set correct state', () => {
+        const error = 'some error';
+        const queryTransactions: QueryTransaction[] = [];
+        const initalState: Partial<ExploreItemState> = {
+          datasourceError: null,
+          queryTransactions: [{} as QueryTransaction],
+          graphResult: [],
+          tableResult: {} as TableModel,
+          logsResult: {} as LogsModel,
+          update: {
+            datasource: true,
+            queries: true,
+            range: true,
+            ui: true,
+          },
+        };
+        const expectedState = {
+          datasourceError: error,
+          queryTransactions,
+          graphResult: undefined as any[],
+          tableResult: undefined as TableModel,
+          logsResult: undefined as LogsModel,
+          update: makeInitialUpdateState(),
+        };
+
+        reducerTester()
+          .givenReducer(itemReducer, initalState)
+          .whenActionIsDispatched(testDataSourceFailureAction({ exploreId: ExploreId.left, error }))
+          .thenStateShouldEqual(expectedState);
+      });
+    });
+  });
+
+  describe('changing datasource', () => {
+    describe('when updateDatasourceInstanceAction is dispatched', () => {
+      describe('and datasourceInstance supports graph, logs, table and has a startpage', () => {
+        it('then it should set correct state', () => {
+          const StartPage = {};
+          const datasourceInstance = {
+            meta: {
+              metrics: {},
+              logs: {},
+              tables: {},
+            },
+            pluginExports: {
+              ExploreStartPage: StartPage,
+            },
+          } as DataSourceApi;
+          const queries: DataQuery[] = [];
+          const queryKeys: string[] = [];
+          const initalState: Partial<ExploreItemState> = {
+            datasourceInstance: null,
+            supportsGraph: false,
+            supportsLogs: false,
+            supportsTable: false,
+            StartPage: null,
+            showingStartPage: false,
+            queries,
+            queryKeys,
+          };
+          const expectedState = {
+            datasourceInstance,
+            supportsGraph: true,
+            supportsLogs: true,
+            supportsTable: true,
+            StartPage,
+            showingStartPage: true,
+            queries,
+            queryKeys,
+          };
+
+          reducerTester()
+            .givenReducer(itemReducer, initalState)
+            .whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance }))
+            .thenStateShouldEqual(expectedState);
+        });
+      });
+    });
+  });
 });
 
 export const setup = (urlStateOverrides?: any) => {
@@ -201,7 +319,8 @@ describe('Explore reducer', () => {
         describe('but urlState is not set in state', () => {
           it('then it should just add urlState and update in state', () => {
             const { initalState, serializedUrlState } = setup();
-            const stateWithoutUrlState = { ...initalState, left: { urlState: null } };
+            const urlState: ExploreUrlState = null;
+            const stateWithoutUrlState = { ...initalState, left: { urlState } };
             const expectedState = { ...initalState };
 
             reducerTester()

+ 75 - 34
public/app/features/explore/state/reducers.ts

@@ -11,8 +11,16 @@ import {
 } from 'app/core/utils/explore';
 import { ExploreItemState, ExploreState, QueryTransaction, ExploreId, ExploreUpdateState } from 'app/types/explore';
 import { DataQuery } from '@grafana/ui/src/types';
-
-import { HigherOrderAction, ActionTypes, SplitCloseActionPayload, splitCloseAction } from './actionTypes';
+import {
+  HigherOrderAction,
+  ActionTypes,
+  testDataSourcePendingAction,
+  testDataSourceSuccessAction,
+  testDataSourceFailureAction,
+  splitCloseAction,
+  SplitCloseActionPayload,
+  loadExploreDatasources,
+} from './actionTypes';
 import { reducerFactory } from 'app/core/redux';
 import {
   addQueryRowAction,
@@ -23,10 +31,9 @@ import {
   highlightLogsExpressionAction,
   initializeExploreAction,
   updateDatasourceInstanceAction,
-  loadDatasourceFailureAction,
   loadDatasourceMissingAction,
   loadDatasourcePendingAction,
-  loadDatasourceSuccessAction,
+  loadDatasourceReadyAction,
   modifyQueriesAction,
   queryTransactionFailureAction,
   queryTransactionStartAction,
@@ -197,12 +204,11 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   .addMapper({
     filter: initializeExploreAction,
     mapper: (state, action): ExploreItemState => {
-      const { containerWidth, eventBridge, exploreDatasources, queries, range, ui } = action.payload;
+      const { containerWidth, eventBridge, queries, range, ui } = action.payload;
       return {
         ...state,
         containerWidth,
         eventBridge,
-        exploreDatasources,
         range,
         queries,
         initialized: true,
@@ -216,17 +222,22 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
     filter: updateDatasourceInstanceAction,
     mapper: (state, action): ExploreItemState => {
       const { datasourceInstance } = action.payload;
-      return { ...state, datasourceInstance, queryKeys: getQueryKeys(state.queries, datasourceInstance) };
-    },
-  })
-  .addMapper({
-    filter: loadDatasourceFailureAction,
-    mapper: (state, action): ExploreItemState => {
+      // Capabilities
+      const supportsGraph = datasourceInstance.meta.metrics;
+      const supportsLogs = datasourceInstance.meta.logs;
+      const supportsTable = datasourceInstance.meta.tables;
+      // Custom components
+      const StartPage = datasourceInstance.pluginExports.ExploreStartPage;
+
       return {
         ...state,
-        datasourceError: action.payload.error,
-        datasourceLoading: false,
-        update: makeInitialUpdateState(),
+        datasourceInstance,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+        StartPage,
+        showingStartPage: Boolean(StartPage),
+        queryKeys: getQueryKeys(state.queries, datasourceInstance),
       };
     },
   })
@@ -244,37 +255,26 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   .addMapper({
     filter: loadDatasourcePendingAction,
     mapper: (state, action): ExploreItemState => {
-      return { ...state, datasourceLoading: true, requestedDatasourceName: action.payload.requestedDatasourceName };
+      return {
+        ...state,
+        datasourceLoading: true,
+        requestedDatasourceName: action.payload.requestedDatasourceName,
+      };
     },
   })
   .addMapper({
-    filter: loadDatasourceSuccessAction,
+    filter: loadDatasourceReadyAction,
     mapper: (state, action): ExploreItemState => {
-      const { containerWidth, range } = state;
-      const {
-        StartPage,
-        datasourceInstance,
-        history,
-        showingStartPage,
-        supportsGraph,
-        supportsLogs,
-        supportsTable,
-      } = action.payload;
+      const { containerWidth, range, datasourceInstance } = state;
+      const { history } = action.payload;
       const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
 
       return {
         ...state,
         queryIntervals,
-        StartPage,
-        datasourceInstance,
         history,
-        showingStartPage,
-        supportsGraph,
-        supportsLogs,
-        supportsTable,
         datasourceLoading: false,
         datasourceMissing: false,
-        datasourceError: null,
         logsHighlighterExpressions: undefined,
         queryTransactions: [],
         update: makeInitialUpdateState(),
@@ -517,6 +517,47 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
+  .addMapper({
+    filter: testDataSourcePendingAction,
+    mapper: (state): ExploreItemState => {
+      return {
+        ...state,
+        datasourceError: null,
+      };
+    },
+  })
+  .addMapper({
+    filter: testDataSourceSuccessAction,
+    mapper: (state): ExploreItemState => {
+      return {
+        ...state,
+        datasourceError: null,
+      };
+    },
+  })
+  .addMapper({
+    filter: testDataSourceFailureAction,
+    mapper: (state, action): ExploreItemState => {
+      return {
+        ...state,
+        datasourceError: action.payload.error,
+        queryTransactions: [],
+        graphResult: undefined,
+        tableResult: undefined,
+        logsResult: undefined,
+        update: makeInitialUpdateState(),
+      };
+    },
+  })
+  .addMapper({
+    filter: loadExploreDatasources,
+    mapper: (state, action): ExploreItemState => {
+      return {
+        ...state,
+        exploreDatasources: action.payload.exploreDatasources,
+      };
+    },
+  })
   .create();
 
 export const updateChildRefreshState = (

+ 10 - 2
public/app/plugins/datasource/loki/components/LokiQueryField.tsx

@@ -2,12 +2,20 @@ import React, { FunctionComponent } from 'react';
 import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
 import { useLokiSyntax } from './useLokiSyntax';
 
-const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => {
-  const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider);
+const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({
+  datasource,
+  datasourceStatus,
+  ...otherProps
+}) => {
+  const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(
+    datasource.languageProvider,
+    datasourceStatus
+  );
 
   return (
     <LokiQueryFieldForm
       datasource={datasource}
+      datasourceStatus={datasourceStatus}
       syntaxLoaded={isSyntaxReady}
       /**
        * setActiveOption name is intentional. Because of the way rc-cascader requests additional data

+ 16 - 9
public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx

@@ -1,6 +1,8 @@
 // Libraries
 import React from 'react';
+// @ts-ignore
 import Cascader from 'rc-cascader';
+// @ts-ignore
 import PluginPrism from 'slate-prism';
 
 // Components
@@ -15,10 +17,13 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
 // Types
 import { LokiQuery } from '../types';
 import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
-import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
+import { ExploreDataSourceApi, ExploreQueryFieldProps, DatasourceStatus } from '@grafana/ui';
 
-function getChooserText(hasSytax, hasLogLabels) {
-  if (!hasSytax) {
+function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DatasourceStatus) {
+  if (datasourceStatus === DatasourceStatus.Disconnected) {
+    return '(Disconnected)';
+  }
+  if (!hasSyntax) {
     return 'Loading labels...';
   }
   if (!hasLogLabels) {
@@ -76,15 +81,15 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
   modifiedSearch: string;
   modifiedQuery: string;
 
-  constructor(props: LokiQueryFieldFormProps, context) {
+  constructor(props: LokiQueryFieldFormProps, context: React.Context<any>) {
     super(props, context);
 
     this.plugins = [
       BracesPlugin(),
       RunnerPlugin({ handler: props.onExecuteQuery }),
       PluginPrism({
-        onlyIn: node => node.type === 'code_block',
-        getSyntax: node => 'promql',
+        onlyIn: (node: any) => node.type === 'code_block',
+        getSyntax: (node: any) => 'promql',
       }),
     ];
 
@@ -159,10 +164,12 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
       onLoadOptions,
       onLabelsRefresh,
       datasource,
+      datasourceStatus,
     } = this.props;
     const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
     const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
-    const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
+    const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus);
+    const buttonDisabled = !syntaxLoaded || datasourceStatus === DatasourceStatus.Disconnected;
 
     return (
       <>
@@ -172,13 +179,13 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
               options={logLabelOptions}
               onChange={this.onChangeLogLabels}
               loadData={onLoadOptions}
-              onPopupVisibleChange={isVisible => {
+              onPopupVisibleChange={(isVisible: boolean) => {
                 if (isVisible && onLabelsRefresh) {
                   onLabelsRefresh();
                 }
               }}
             >
-              <button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
+              <button className="gf-form-label gf-form-label--btn" disabled={buttonDisabled}>
                 {chooserText} <i className="fa fa-caret-down" />
               </button>
             </Cascader>

+ 43 - 11
public/app/plugins/datasource/loki/components/useLokiLabels.test.ts

@@ -1,24 +1,56 @@
 import { renderHook, act } from 'react-hooks-testing-library';
 import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { useLokiLabels } from './useLokiLabels';
+import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
 
 describe('useLokiLabels hook', () => {
-  const datasource = {
-    metadataRequest: () => ({ data: { data: [] } }),
-  };
-  const languageProvider = new LanguageProvider(datasource);
-  const logLabelOptionsMock = ['Holy mock!'];
+  it('should refresh labels', async () => {
+    const datasource = {
+      metadataRequest: () => ({ data: { data: [] as any[] } }),
+    };
+    const languageProvider = new LanguageProvider(datasource);
+    const logLabelOptionsMock = ['Holy mock!'];
 
-  languageProvider.refreshLogLabels = () => {
-    languageProvider.logLabelOptions = logLabelOptionsMock;
-    return Promise.resolve();
-  };
+    languageProvider.refreshLogLabels = () => {
+      languageProvider.logLabelOptions = logLabelOptionsMock;
+      return Promise.resolve();
+    };
 
-  it('should refresh labels', async () => {
-    const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, true, []));
+    const { result, waitForNextUpdate } = renderHook(() =>
+      useLokiLabels(languageProvider, true, [], DatasourceStatus.Connected, DatasourceStatus.Connected)
+    );
     act(() => result.current.refreshLabels());
     expect(result.current.logLabelOptions).toEqual([]);
     await waitForNextUpdate();
     expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock);
   });
+
+  it('should force refresh labels after a disconnect', () => {
+    const datasource = {
+      metadataRequest: () => ({ data: { data: [] as any[] } }),
+    };
+    const languageProvider = new LanguageProvider(datasource);
+    languageProvider.refreshLogLabels = jest.fn();
+
+    renderHook(() =>
+      useLokiLabels(languageProvider, true, [], DatasourceStatus.Connected, DatasourceStatus.Disconnected)
+    );
+
+    expect(languageProvider.refreshLogLabels).toBeCalledTimes(1);
+    expect(languageProvider.refreshLogLabels).toBeCalledWith(true);
+  });
+
+  it('should not force refresh labels after a connect', () => {
+    const datasource = {
+      metadataRequest: () => ({ data: { data: [] as any[] } }),
+    };
+    const languageProvider = new LanguageProvider(datasource);
+    languageProvider.refreshLogLabels = jest.fn();
+
+    renderHook(() =>
+      useLokiLabels(languageProvider, true, [], DatasourceStatus.Disconnected, DatasourceStatus.Connected)
+    );
+
+    expect(languageProvider.refreshLogLabels).not.toBeCalled();
+  });
 });

+ 25 - 6
public/app/plugins/datasource/loki/components/useLokiLabels.ts

@@ -1,4 +1,6 @@
 import { useState, useEffect } from 'react';
+import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
+
 import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
 import { useRefMounted } from 'app/core/hooks/useRefMounted';
@@ -14,16 +16,22 @@ import { useRefMounted } from 'app/core/hooks/useRefMounted';
 export const useLokiLabels = (
   languageProvider: LokiLanguageProvider,
   languageProviderInitialised: boolean,
-  activeOption: CascaderOption[]
+  activeOption: CascaderOption[],
+  datasourceStatus: DatasourceStatus,
+  initialDatasourceStatus?: DatasourceStatus // used for test purposes
 ) => {
   const mounted = useRefMounted();
 
   // State
   const [logLabelOptions, setLogLabelOptions] = useState([]);
   const [shouldTryRefreshLabels, setRefreshLabels] = useState(false);
+  const [prevDatasourceStatus, setPrevDatasourceStatus] = useState(
+    initialDatasourceStatus || DatasourceStatus.Connected
+  );
+  const [shouldForceRefreshLabels, setForceRefreshLabels] = useState(false);
 
   // Async
-  const fetchOptionValues = async option => {
+  const fetchOptionValues = async (option: string) => {
     await languageProvider.fetchLabelValues(option);
     if (mounted.current) {
       setLogLabelOptions(languageProvider.logLabelOptions);
@@ -31,9 +39,11 @@ export const useLokiLabels = (
   };
 
   const tryLabelsRefresh = async () => {
-    await languageProvider.refreshLogLabels();
+    await languageProvider.refreshLogLabels(shouldForceRefreshLabels);
+
     if (mounted.current) {
       setRefreshLabels(false);
+      setForceRefreshLabels(false);
       setLogLabelOptions(languageProvider.logLabelOptions);
     }
   };
@@ -62,14 +72,23 @@ export const useLokiLabels = (
     }
   }, [activeOption]);
 
-  // This effect is performed on shouldTryRefreshLabels state change only.
+  // This effect is performed on shouldTryRefreshLabels or shouldForceRefreshLabels state change only.
   // Since shouldTryRefreshLabels is reset AFTER the labels are refreshed we are secured in case of trying to refresh
   // when previous refresh hasn't finished yet
   useEffect(() => {
-    if (shouldTryRefreshLabels) {
+    if (shouldTryRefreshLabels || shouldForceRefreshLabels) {
       tryLabelsRefresh();
     }
-  }, [shouldTryRefreshLabels]);
+  }, [shouldTryRefreshLabels, shouldForceRefreshLabels]);
+
+  // This effect is performed on datasourceStatus state change only.
+  // We want to make sure to only force refresh AFTER a disconnected state thats why we store the previous datasourceStatus in state
+  useEffect(() => {
+    if (datasourceStatus === DatasourceStatus.Connected && prevDatasourceStatus === DatasourceStatus.Disconnected) {
+      setForceRefreshLabels(true);
+    }
+    setPrevDatasourceStatus(datasourceStatus);
+  }, [datasourceStatus]);
 
   return {
     logLabelOptions,

+ 6 - 4
public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts

@@ -1,11 +1,13 @@
 import { renderHook, act } from 'react-hooks-testing-library';
+import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
+
 import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { useLokiSyntax } from './useLokiSyntax';
 import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
 
 describe('useLokiSyntax hook', () => {
   const datasource = {
-    metadataRequest: () => ({ data: { data: [] } }),
+    metadataRequest: () => ({ data: { data: [] as any[] } }),
   };
   const languageProvider = new LanguageProvider(datasource);
   const logLabelOptionsMock = ['Holy mock!'];
@@ -28,7 +30,7 @@ describe('useLokiSyntax hook', () => {
   };
 
   it('should provide Loki syntax when used', async () => {
-    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DatasourceStatus.Connected));
     expect(result.current.syntax).toEqual(null);
 
     await waitForNextUpdate();
@@ -37,7 +39,7 @@ describe('useLokiSyntax hook', () => {
   });
 
   it('should fetch labels on first call', async () => {
-    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DatasourceStatus.Connected));
     expect(result.current.isSyntaxReady).toBeFalsy();
     expect(result.current.logLabelOptions).toEqual([]);
 
@@ -48,7 +50,7 @@ describe('useLokiSyntax hook', () => {
   });
 
   it('should try to fetch missing options when active option changes', async () => {
-    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DatasourceStatus.Connected));
     await waitForNextUpdate();
     expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
 

+ 7 - 3
public/app/plugins/datasource/loki/components/useLokiSyntax.ts

@@ -1,6 +1,9 @@
 import { useState, useEffect } from 'react';
-import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
+// @ts-ignore
 import Prism from 'prismjs';
+import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
+
+import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { useLokiLabels } from 'app/plugins/datasource/loki/components/useLokiLabels';
 import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
 import { useRefMounted } from 'app/core/hooks/useRefMounted';
@@ -12,7 +15,7 @@ const PRISM_SYNTAX = 'promql';
  * @param languageProvider
  * @description Initializes given language provider, exposes Loki syntax and enables loading label option values
  */
-export const useLokiSyntax = (languageProvider: LokiLanguageProvider) => {
+export const useLokiSyntax = (languageProvider: LokiLanguageProvider, datasourceStatus: DatasourceStatus) => {
   const mounted = useRefMounted();
   // State
   const [languageProviderInitialized, setLanguageProviderInitilized] = useState(false);
@@ -28,7 +31,8 @@ export const useLokiSyntax = (languageProvider: LokiLanguageProvider) => {
   const { logLabelOptions, setLogLabelOptions, refreshLabels } = useLokiLabels(
     languageProvider,
     languageProviderInitialized,
-    activeOption
+    activeOption,
+    datasourceStatus
   );
 
   // Async

+ 15 - 12
public/app/plugins/datasource/loki/language_provider.ts

@@ -1,4 +1,5 @@
 // Libraries
+// @ts-ignore
 import _ from 'lodash';
 import moment from 'moment';
 
@@ -60,13 +61,13 @@ export default class LokiLanguageProvider extends LanguageProvider {
     Object.assign(this, initialValues);
   }
   // Strip syntax chars
-  cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+  cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 
   getSyntax() {
     return syntax;
   }
 
-  request = url => {
+  request = (url: string) => {
     return this.datasource.metadataRequest(url);
   };
 
@@ -100,12 +101,12 @@ export default class LokiLanguageProvider extends LanguageProvider {
 
     if (history && history.length > 0) {
       const historyItems = _.chain(history)
-        .map(h => h.query.expr)
+        .map((h: any) => h.query.expr)
         .filter()
         .uniq()
         .take(HISTORY_ITEM_COUNT)
         .map(wrapLabel)
-        .map(item => addHistoryMetadata(item, history))
+        .map((item: CompletionItem) => addHistoryMetadata(item, history))
         .value();
 
       suggestions.push({
@@ -191,7 +192,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
     const selectorMatch = query.match(selectorRegexp);
     if (selectorMatch) {
       const selector = selectorMatch[0];
-      const labels = {};
+      const labels: { [key: string]: { value: any; operator: any } } = {};
       selector.replace(labelRegexp, (_, key, operator, value) => {
         labels[key] = { value, operator };
         return '';
@@ -200,7 +201,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
       // Keep only labels that exist on origin and target datasource
       await this.start(); // fetches all existing label keys
       const existingKeys = this.labelKeys[EMPTY_SELECTOR];
-      let labelsToKeep = {};
+      let labelsToKeep: { [key: string]: { value: any; operator: any } } = {};
       if (existingKeys && existingKeys.length > 0) {
         // Check for common labels
         for (const key in labels) {
@@ -225,7 +226,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
     return '';
   }
 
-  async fetchLogLabels() {
+  async fetchLogLabels(): Promise<any> {
     const url = '/api/prom/label';
     try {
       this.logLabelFetchTs = Date.now();
@@ -236,11 +237,13 @@ export default class LokiLanguageProvider extends LanguageProvider {
         ...this.labelKeys,
         [EMPTY_SELECTOR]: labelKeys,
       };
-      this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
+      this.logLabelOptions = labelKeys.map((key: string) => ({ label: key, value: key, isLeaf: false }));
 
       // Pre-load values for default labels
       return Promise.all(
-        labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key))
+        labelKeys
+          .filter((key: string) => DEFAULT_KEYS.indexOf(key) > -1)
+          .map((key: string) => this.fetchLabelValues(key))
       );
     } catch (e) {
       console.error(e);
@@ -248,8 +251,8 @@ export default class LokiLanguageProvider extends LanguageProvider {
     return [];
   }
 
-  async refreshLogLabels() {
-    if (this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) {
+  async refreshLogLabels(forceRefresh?: boolean) {
+    if ((this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
       await this.fetchLogLabels();
     }
   }
@@ -266,7 +269,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
         if (keyOption.value === key) {
           return {
             ...keyOption,
-            children: values.map(value => ({ label: value, value })),
+            children: values.map((value: string) => ({ label: value, value })),
           };
         }
         return keyOption;

+ 57 - 21
public/app/plugins/datasource/prometheus/components/PromQueryField.tsx

@@ -1,7 +1,11 @@
+// @ts-ignore
 import _ from 'lodash';
 import React from 'react';
+// @ts-ignore
 import Cascader from 'rc-cascader';
+// @ts-ignore
 import PluginPrism from 'slate-prism';
+// @ts-ignore
 import Prism from 'prismjs';
 
 import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
@@ -13,13 +17,23 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
 import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
 import { PromQuery } from '../types';
 import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
-import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
+import { ExploreDataSourceApi, ExploreQueryFieldProps, DatasourceStatus } from '@grafana/ui';
 
 const HISTOGRAM_GROUP = '__histograms__';
 const METRIC_MARK = 'metric';
 const PRISM_SYNTAX = 'promql';
 export const RECORDING_RULES_GROUP = '__recording_rules__';
 
+function getChooserText(hasSyntax: boolean, datasourceStatus: DatasourceStatus) {
+  if (datasourceStatus === DatasourceStatus.Disconnected) {
+    return '(Disconnected)';
+  }
+  if (!hasSyntax) {
+    return 'Loading metrics...';
+  }
+  return 'Metrics';
+}
+
 export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
   // Filter out recording rules and insert as first option
   const ruleRegex = /:\w+:/;
@@ -36,8 +50,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
   const options = ruleNames.length > 0 ? [rulesOption] : [];
 
   const metricsOptions = _.chain(metrics)
-    .filter(metric => !ruleRegex.test(metric))
-    .groupBy(metric => metric.split(delimiter)[0])
+    .filter((metric: string) => !ruleRegex.test(metric))
+    .groupBy((metric: string) => metric.split(delimiter)[0])
     .map(
       (metricsForPrefix: string[], prefix: string): CascaderOption => {
         const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
@@ -103,7 +117,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   languageProvider: any;
   languageProviderInitializationPromise: CancelablePromise<any>;
 
-  constructor(props: PromQueryFieldProps, context) {
+  constructor(props: PromQueryFieldProps, context: React.Context<any>) {
     super(props, context);
 
     if (props.datasource.languageProvider) {
@@ -114,8 +128,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
       BracesPlugin(),
       RunnerPlugin({ handler: props.onExecuteQuery }),
       PluginPrism({
-        onlyIn: node => node.type === 'code_block',
-        getSyntax: node => 'promql',
+        onlyIn: (node: any) => node.type === 'code_block',
+        getSyntax: (node: any) => 'promql',
       }),
     ];
 
@@ -127,17 +141,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
   componentDidMount() {
     if (this.languageProvider) {
-      this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start());
-      this.languageProviderInitializationPromise.promise
-        .then(remaining => {
-          remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
-        })
-        .then(() => this.onUpdateLanguage())
-        .catch(({ isCanceled }) => {
-          if (isCanceled) {
-            console.warn('PromQueryField has unmounted, language provider intialization was canceled');
-          }
-        });
+      this.refreshMetrics(makePromiseCancelable(this.languageProvider.start()));
     }
   }
 
@@ -147,6 +151,37 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     }
   }
 
+  componentDidUpdate(prevProps: PromQueryFieldProps) {
+    const reconnected =
+      prevProps.datasourceStatus === DatasourceStatus.Disconnected &&
+      this.props.datasourceStatus === DatasourceStatus.Connected;
+    if (!reconnected) {
+      return;
+    }
+
+    if (this.languageProviderInitializationPromise) {
+      this.languageProviderInitializationPromise.cancel();
+    }
+
+    if (this.languageProvider) {
+      this.refreshMetrics(makePromiseCancelable(this.languageProvider.fetchMetrics()));
+    }
+  }
+
+  refreshMetrics = (cancelablePromise: CancelablePromise<any>) => {
+    this.languageProviderInitializationPromise = cancelablePromise;
+    this.languageProviderInitializationPromise.promise
+      .then(remaining => {
+        remaining.map((task: Promise<any>) => task.then(this.onUpdateLanguage).catch(() => {}));
+      })
+      .then(() => this.onUpdateLanguage())
+      .catch(({ isCanceled }) => {
+        if (isCanceled) {
+          console.warn('PromQueryField has unmounted, language provider intialization was canceled');
+        }
+      });
+  };
+
   onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
     let query;
     if (selectedOptions.length === 1) {
@@ -202,7 +237,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
     // Build metrics tree
     const metricsByPrefix = groupMetricsByPrefix(metrics);
-    const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
+    const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm }));
     const metricsOptions =
       histogramMetrics.length > 0
         ? [
@@ -239,17 +274,18 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
   };
 
   render() {
-    const { error, hint, query } = this.props;
+    const { error, hint, query, datasourceStatus } = this.props;
     const { metricsOptions, syntaxLoaded } = this.state;
     const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
-    const chooserText = syntaxLoaded ? 'Metrics' : 'Loading metrics...';
+    const chooserText = getChooserText(syntaxLoaded, datasourceStatus);
+    const buttonDisabled = !syntaxLoaded || datasourceStatus === DatasourceStatus.Disconnected;
 
     return (
       <>
         <div className="gf-form-inline gf-form-inline--nowrap">
           <div className="gf-form flex-shrink-0">
             <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
-              <button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
+              <button className="gf-form-label gf-form-label--btn" disabled={buttonDisabled}>
                 {chooserText} <i className="fa fa-caret-down" />
               </button>
             </Cascader>

+ 11 - 6
public/app/plugins/datasource/prometheus/language_provider.ts

@@ -1,3 +1,4 @@
+// @ts-ignore
 import _ from 'lodash';
 import moment from 'moment';
 
@@ -60,23 +61,27 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     Object.assign(this, initialValues);
   }
   // Strip syntax chars
-  cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
+  cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 
   getSyntax() {
     return PromqlSyntax;
   }
 
-  request = url => {
+  request = (url: string) => {
     return this.datasource.metadataRequest(url);
   };
 
   start = () => {
     if (!this.startTask) {
-      this.startTask = this.fetchMetricNames().then(() => [this.fetchHistogramMetrics()]);
+      this.startTask = this.fetchMetrics();
     }
     return this.startTask;
   };
 
+  fetchMetrics = async () => {
+    return this.fetchMetricNames().then(() => [this.fetchHistogramMetrics()]);
+  };
+
   // Keep this DOM-free for testing
   provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput {
     // Local text properties
@@ -125,12 +130,12 @@ export default class PromQlLanguageProvider extends LanguageProvider {
 
     if (history && history.length > 0) {
       const historyItems = _.chain(history)
-        .map(h => h.query.expr)
+        .map((h: any) => h.query.expr)
         .filter()
         .uniq()
         .take(HISTORY_ITEM_COUNT)
         .map(wrapLabel)
-        .map(item => addHistoryMetadata(item, history))
+        .map((item: CompletionItem) => addHistoryMetadata(item, history))
         .value();
 
       suggestions.push({
@@ -184,7 +189,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
 
     // Stitch all query lines together to support multi-line queries
     let queryOffset;
-    const queryText = value.document.getBlocks().reduce((text, block) => {
+    const queryText = value.document.getBlocks().reduce((text: string, block: any) => {
       const blockText = block.getText();
       if (value.anchorBlock.key === block.key) {
         // Newline characters are not accounted for but this is irrelevant

+ 1 - 1
public/sass/components/_buttons.scss

@@ -123,7 +123,7 @@
   @include button-outline-variant($btn-inverse-bg);
 }
 .btn-outline-danger {
-  @include button-outline-variant(green);
+  @include button-outline-variant($btn-danger-bg);
 }
 
 .btn-outline-disabled {

+ 6 - 9
public/sass/mixins/_buttons.scss

@@ -27,35 +27,32 @@
 }
 
 @mixin button-outline-variant($color) {
-  color: $color;
+  color: $white;
   background-image: none;
   background-color: transparent;
-  border-color: $color;
+  border: 1px solid $white;
 
   @include hover {
-    color: #fff;
+    color: $white;
     background-color: $color;
-    border-color: $color;
   }
 
   &:focus,
   &.focus {
-    color: #fff;
+    color: $white;
     background-color: $color;
-    border-color: $color;
   }
 
   &:active,
   &.active,
   .open > &.dropdown-toggle {
-    color: #fff;
+    color: $white;
     background-color: $color;
-    border-color: $color;
 
     &:hover,
     &:focus,
     &.focus {
-      color: #fff;
+      color: $white;
       background-color: darken($color, 17%);
       border-color: darken($color, 25%);
     }

+ 12 - 32
public/test/core/thunk/thunkTester.ts

@@ -1,3 +1,4 @@
+// @ts-ignore
 import configureMockStore from 'redux-mock-store';
 import thunk from 'redux-thunk';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
@@ -9,18 +10,13 @@ export interface ThunkGiven {
 }
 
 export interface ThunkWhen {
-  whenThunkIsDispatched: (...args: any) => ThunkThen;
+  whenThunkIsDispatched: (...args: any) => Promise<Array<ActionOf<any>>>;
 }
 
-export interface ThunkThen {
-  thenDispatchedActionsEqual: (actions: Array<ActionOf<any>>) => ThunkWhen;
-  thenDispatchedActionsAreEqual: (callback: (actions: Array<ActionOf<any>>) => boolean) => ThunkWhen;
-  thenThereAreNoDispatchedActions: () => ThunkWhen;
-}
-
-export const thunkTester = (initialState: any): ThunkGiven => {
+export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => {
   const store = mockStore(initialState);
-  let thunkUnderTest = null;
+  let thunkUnderTest: any = null;
+  let dispatchedActions: Array<ActionOf<any>> = [];
 
   const givenThunk = (thunkFunction: any): ThunkWhen => {
     thunkUnderTest = thunkFunction;
@@ -28,36 +24,20 @@ export const thunkTester = (initialState: any): ThunkGiven => {
     return instance;
   };
 
-  function whenThunkIsDispatched(...args: any): ThunkThen {
-    store.dispatch(thunkUnderTest(...arguments));
-
-    return instance;
-  }
-
-  const thenDispatchedActionsEqual = (actions: Array<ActionOf<any>>): ThunkWhen => {
-    const resultingActions = store.getActions();
-    expect(resultingActions).toEqual(actions);
+  const whenThunkIsDispatched = async (...args: any): Promise<Array<ActionOf<any>>> => {
+    await store.dispatch(thunkUnderTest(...args));
 
-    return instance;
-  };
-
-  const thenDispatchedActionsAreEqual = (callback: (dispathedActions: Array<ActionOf<any>>) => boolean): ThunkWhen => {
-    const resultingActions = store.getActions();
-    expect(callback(resultingActions)).toBe(true);
-
-    return instance;
-  };
+    dispatchedActions = store.getActions();
+    if (debug) {
+      console.log('resultingActions:', dispatchedActions);
+    }
 
-  const thenThereAreNoDispatchedActions = () => {
-    return thenDispatchedActionsEqual([]);
+    return dispatchedActions;
   };
 
   const instance = {
     givenThunk,
     whenThunkIsDispatched,
-    thenDispatchedActionsEqual,
-    thenDispatchedActionsAreEqual,
-    thenThereAreNoDispatchedActions,
   };
 
   return instance;