From 988b7c4dc3de8098d4ca1faf93d8011fe1fc6037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 1 Apr 2019 07:38:00 +0200 Subject: [PATCH] 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 --- packages/grafana-ui/src/types/plugin.ts | 6 + public/app/features/explore/Error.tsx | 15 +- public/app/features/explore/Explore.tsx | 22 +- public/app/features/explore/QueryRow.tsx | 37 ++- .../app/features/explore/state/actionTypes.ts | 83 +++---- .../features/explore/state/actions.test.ts | 213 ++++++++++++++---- public/app/features/explore/state/actions.ts | 203 ++++++++++------- .../features/explore/state/reducers.test.ts | 129 ++++++++++- public/app/features/explore/state/reducers.ts | 109 ++++++--- .../loki/components/LokiQueryField.tsx | 12 +- .../loki/components/LokiQueryFieldForm.tsx | 25 +- .../loki/components/useLokiLabels.test.ts | 56 ++++- .../loki/components/useLokiLabels.ts | 31 ++- .../loki/components/useLokiSyntax.test.ts | 10 +- .../loki/components/useLokiSyntax.ts | 10 +- .../datasource/loki/language_provider.ts | 27 ++- .../prometheus/components/PromQueryField.tsx | 78 +++++-- .../prometheus/language_provider.ts | 17 +- public/sass/components/_buttons.scss | 2 +- public/sass/mixins/_buttons.scss | 15 +- public/test/core/thunk/thunkTester.ts | 44 +--- 21 files changed, 796 insertions(+), 348 deletions(-) diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index dcc20f19ea0..ae59322f596 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -54,8 +54,14 @@ export interface QueryEditorProps void; } +export enum DatasourceStatus { + Connected, + Disconnected, +} + export interface ExploreQueryFieldProps { datasource: DSType; + datasourceStatus: DatasourceStatus; query: TQuery; error?: string | JSX.Element; hint?: QueryHint; diff --git a/public/app/features/explore/Error.tsx b/public/app/features/explore/Error.tsx index 0c4e0e7ea85..bc6ecca994a 100644 --- a/public/app/features/explore/Error.tsx +++ b/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 => { - const { message } = props; + const { message, button } = props; return ( -
+
@@ -15,6 +19,13 @@ export const Alert: FC = props => {
{message}
+ {button && ( +
+ +
+ )}
); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index f61fb47b7a5..24d186cbbba 100644 --- a/public/app/features/explore/Explore.tsx +++ b/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; @@ -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 { ); }; + onReconnect = (event: React.MouseEvent) => { + const { exploreId, reconnectDatasource } = this.props; + + event.preventDefault(); + reconnectDatasource(exploreId); + }; + render() { const { StartPage, @@ -224,13 +234,16 @@ export class Explore extends React.PureComponent { {datasourceLoading ?
Loading datasource...
: null} {datasourceMissing ? this.renderEmptyState() : null} - {datasourceError && ( +
- +
- )} +
- {datasourceInstance && !datasourceError && ( + {datasourceInstance && (
@@ -315,6 +328,7 @@ const mapDispatchToProps = { changeTime, initializeExplore, modifyQueries, + reconnectDatasource, refreshExplore, scanStart, scanStopAction, diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx index 03ad8e7baa4..e35e62c36a1 100644 --- a/public/app/features/explore/QueryRow.tsx +++ b/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 { }, 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 { {QueryField ? ( { } } -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 = { diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index e6c606f7117..e3f913bba1e 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/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( - 'explore/LOAD_DATASOURCE_FAILURE' -).create(); - /** * Display an error when no datasources have been configured */ @@ -268,12 +271,10 @@ export const loadDatasourcePendingAction = actionCreatorFactory Loki queries. + * Datasource loading was completed. */ -export const loadDatasourceSuccessAction = actionCreatorFactory( - 'explore/LOAD_DATASOURCE_SUCCESS' +export const loadDatasourceReadyAction = actionCreatorFactory( + 'explore/LOAD_DATASOURCE_READY' ).create(); /** @@ -391,37 +392,21 @@ export const toggleLogLevelAction = actionCreatorFactory( */ export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create(); export const queriesImportedAction = actionCreatorFactory('explore/QueriesImported').create(); +export const testDataSourcePendingAction = actionCreatorFactory( + 'explore/TEST_DATASOURCE_PENDING' +).create(); +export const testDataSourceSuccessAction = actionCreatorFactory( + 'explore/TEST_DATASOURCE_SUCCESS' +).create(); +export const testDataSourceFailureAction = actionCreatorFactory( + 'explore/TEST_DATASOURCE_FAILURE' +).create(); +export const loadExploreDatasources = actionCreatorFactory( + 'explore/LOAD_EXPLORE_DATASOURCES' +).create(); export type HigherOrderAction = | ActionOf | SplitOpenAction | ResetExploreAction | ActionOf; - -export type Action = - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf - | ActionOf; diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index 4608c66c3ad..7dacfd07b16 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/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) => { eventBridge, update, datasourceInstance: { name: 'some-datasource' }, - queries: [], + queries: [] as DataQuery[], range, ui, }, @@ -61,87 +67,204 @@ const setup = (updateOverides?: Partial) => { 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; - const { type, payload } = initializeExplore; + .whenThunkIsDispatched(exploreId); - 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); + const initializeExplore = dispatchedActions[2] as ActionOf; + const { type, payload } = initializeExplore; - return true; - }); + 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 }), + ]); + }); }); }); }); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 2b8c13b0185..b90b7448887 100644 --- a/public/app/features/explore/state/actions.ts +++ b/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) => { +const updateExploreUIState = (exploreId: ExploreId, uiStateFragment: Partial): ThunkResult => { return dispatch => { dispatch(updateUIStateAction({ exploreId, ...uiStateFragment })); dispatch(stateSave()); @@ -97,7 +109,14 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { 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 { }; } +/** + * 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 { + 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 { 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 => { - // 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 => { 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 { 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 { - 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 => { + 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 => { + 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 { + 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 { 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 { 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 { 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 { * 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 { return (dispatch, getState) => { const { left, right, split } = getState().explore; const urlStates: { [index: string]: string } = {}; @@ -720,7 +749,7 @@ const togglePanelActionCreator = ( | ActionCreator | ActionCreator | ActionCreator -) => (exploreId: ExploreId, isPanelVisible: boolean) => { +) => (exploreId: ExploreId, isPanelVisible: boolean): ThunkResult => { return dispatch => { let uiFragmentStateUpdate: Partial; 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 => { return dispatch => { dispatch(updateExploreUIState(exploreId, { dedupStrategy })); }; diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 0a7aa2766e0..27970868697 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/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 = { + 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 = { + 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() diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 4eae1a55440..3683b87ba73 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/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({} 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({} 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({} 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({} 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 = ( diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index ada100ea4ca..d142c867303 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/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 = ({ datasource, ...otherProps }) => { - const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider); +const LokiQueryField: FunctionComponent = ({ + datasource, + datasourceStatus, + ...otherProps +}) => { + const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( + datasource.languageProvider, + datasourceStatus + ); return ( ) { 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 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 { + onPopupVisibleChange={(isVisible: boolean) => { if (isVisible && onLabelsRefresh) { onLabelsRefresh(); } }} > - diff --git a/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts b/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts index efe9e4a38bc..a662b92ea0e 100644 --- a/public/app/plugins/datasource/loki/components/useLokiLabels.test.ts +++ b/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!']; - - languageProvider.refreshLogLabels = () => { - languageProvider.logLabelOptions = logLabelOptionsMock; - return Promise.resolve(); - }; - it('should refresh labels', async () => { - const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, true, [])); + const datasource = { + metadataRequest: () => ({ data: { data: [] as any[] } }), + }; + const languageProvider = new LanguageProvider(datasource); + const logLabelOptionsMock = ['Holy mock!']; + + languageProvider.refreshLogLabels = () => { + languageProvider.logLabelOptions = logLabelOptionsMock; + return Promise.resolve(); + }; + + 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(); + }); }); diff --git a/public/app/plugins/datasource/loki/components/useLokiLabels.ts b/public/app/plugins/datasource/loki/components/useLokiLabels.ts index 18aaf54d679..d76766d52dc 100644 --- a/public/app/plugins/datasource/loki/components/useLokiLabels.ts +++ b/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, diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts index 9f137bcb4f5..64312fc4929 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts +++ b/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); diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts index b9a9fd271e0..83baa4f0e83 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts +++ b/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 diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index fd793a15329..e914b1d8dd0 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/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 { 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; diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 2ff720a1a04..45e665fcb71 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/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; - constructor(props: PromQueryFieldProps, context) { + constructor(props: PromQueryFieldProps, context: React.Context) { super(props, context); if (props.datasource.languageProvider) { @@ -114,8 +128,8 @@ class PromQueryField extends React.PureComponent 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 { - 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) => { + this.languageProviderInitializationPromise = cancelablePromise; + this.languageProviderInitializationPromise.promise + .then(remaining => { + remaining.map((task: Promise) => 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 ({ 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
- diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 277ab267d70..c071617fb6d 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/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 diff --git a/public/sass/components/_buttons.scss b/public/sass/components/_buttons.scss index 4bb4aca2ea7..427efda27d6 100644 --- a/public/sass/components/_buttons.scss +++ b/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 { diff --git a/public/sass/mixins/_buttons.scss b/public/sass/mixins/_buttons.scss index 2b2aa8bd049..990b9254bf7 100644 --- a/public/sass/mixins/_buttons.scss +++ b/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%); } diff --git a/public/test/core/thunk/thunkTester.ts b/public/test/core/thunk/thunkTester.ts index 8315338c412..3972ddc8ee0 100644 --- a/public/test/core/thunk/thunkTester.ts +++ b/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>>; } -export interface ThunkThen { - thenDispatchedActionsEqual: (actions: Array>) => ThunkWhen; - thenDispatchedActionsAreEqual: (callback: (actions: Array>) => 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> = []; 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)); + const whenThunkIsDispatched = async (...args: any): Promise>> => { + await store.dispatch(thunkUnderTest(...args)); - return instance; - } + dispatchedActions = store.getActions(); + if (debug) { + console.log('resultingActions:', dispatchedActions); + } - const thenDispatchedActionsEqual = (actions: Array>): ThunkWhen => { - const resultingActions = store.getActions(); - expect(resultingActions).toEqual(actions); - - return instance; - }; - - const thenDispatchedActionsAreEqual = (callback: (dispathedActions: Array>) => boolean): ThunkWhen => { - const resultingActions = store.getActions(); - expect(callback(resultingActions)).toBe(true); - - return instance; - }; - - const thenThereAreNoDispatchedActions = () => { - return thenDispatchedActionsEqual([]); + return dispatchedActions; }; const instance = { givenThunk, whenThunkIsDispatched, - thenDispatchedActionsEqual, - thenDispatchedActionsAreEqual, - thenThereAreNoDispatchedActions, }; return instance;