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
This commit is contained in:
Hugo Häggmark 2019-04-01 07:38:00 +02:00 committed by GitHub
parent e69039d8d1
commit 988b7c4dc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 796 additions and 348 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { refreshExplore } from './actions'; import { refreshExplore, testDatasource, loadDatasource } from './actions';
import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types'; import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester'; import { thunkTester } from 'test/core/thunk/thunkTester';
import { LogsDedupStrategy } from 'app/core/logs_model'; import { LogsDedupStrategy } from 'app/core/logs_model';
@ -8,10 +8,16 @@ import {
changeTimeAction, changeTimeAction,
updateUIStateAction, updateUIStateAction,
setQueriesAction, setQueriesAction,
testDataSourcePendingAction,
testDataSourceSuccessAction,
testDataSourceFailureAction,
loadDatasourcePendingAction,
loadDatasourceReadyAction,
} from './actionTypes'; } from './actionTypes';
import { Emitter } from 'app/core/core'; import { Emitter } from 'app/core/core';
import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { makeInitialUpdateState } from './reducers'; import { makeInitialUpdateState } from './reducers';
import { DataQuery } from '@grafana/ui/src/types/datasource';
jest.mock('app/features/plugins/datasource_srv', () => ({ jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: () => ({ getDatasourceSrv: () => ({
@ -41,7 +47,7 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
eventBridge, eventBridge,
update, update,
datasourceInstance: { name: 'some-datasource' }, datasourceInstance: { name: 'some-datasource' },
queries: [], queries: [] as DataQuery[],
range, range,
ui, ui,
}, },
@ -61,87 +67,204 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
describe('refreshExplore', () => { describe('refreshExplore', () => {
describe('when explore is initialized', () => { describe('when explore is initialized', () => {
describe('and update datasource is set', () => { 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 }); const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
thunkTester(initialState) const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore) .givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId) .whenThunkIsDispatched(exploreId);
.thenDispatchedActionsAreEqual(dispatchedActions => {
const initializeExplore = dispatchedActions[0] as ActionOf<InitializeExplorePayload>;
const { type, payload } = initializeExplore;
expect(type).toEqual(initializeExploreAction.type); const initializeExplore = dispatchedActions[2] as ActionOf<InitializeExplorePayload>;
expect(payload.containerWidth).toEqual(containerWidth); const { type, payload } = initializeExplore;
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; 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', () => { 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 }); const { exploreId, range, initialState } = setup({ range: true });
thunkTester(initialState) const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore) .givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId) .whenThunkIsDispatched(exploreId);
.thenDispatchedActionsAreEqual(dispatchedActions => {
expect(dispatchedActions[0].type).toEqual(changeTimeAction.type);
expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
return true; expect(dispatchedActions[0].type).toEqual(changeTimeAction.type);
}); expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
}); });
}); });
describe('and update ui is set', () => { 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 }); const { exploreId, initialState, ui } = setup({ ui: true });
thunkTester(initialState) const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore) .givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId) .whenThunkIsDispatched(exploreId);
.thenDispatchedActionsAreEqual(dispatchedActions => {
expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type);
expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId });
return true; expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type);
}); expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId });
}); });
}); });
describe('and update queries is set', () => { 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 }); const { exploreId, initialState } = setup({ queries: true });
thunkTester(initialState) const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore) .givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId) .whenThunkIsDispatched(exploreId);
.thenDispatchedActionsAreEqual(dispatchedActions => {
expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
return true; expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
}); expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
}); });
}); });
}); });
describe('when update is not initialized', () => { 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 exploreId = ExploreId.left;
const initialState = { explore: { [exploreId]: { initialized: false } } }; const initialState = { explore: { [exploreId]: { initialized: false } } };
thunkTester(initialState) const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore) .givenThunk(refreshExplore)
.whenThunkIsDispatched(exploreId) .whenThunkIsDispatched(exploreId);
.thenThereAreNoDispatchedActions();
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 }),
]);
});
}); });
}); });
}); });

View File

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

View File

@ -5,14 +5,32 @@ import {
makeInitialUpdateState, makeInitialUpdateState,
initialExploreState, initialExploreState,
} from './reducers'; } 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 { 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 { Reducer } from 'redux';
import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { updateLocation } from 'app/core/actions/location'; 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 { serializeStateToUrlParam } from 'app/core/utils/explore';
import TableModel from 'app/core/table_model';
import { DataSourceApi, DataQuery } from '@grafana/ui';
describe('Explore item reducer', () => { describe('Explore item reducer', () => {
describe('scanning', () => { describe('scanning', () => {
@ -21,7 +39,7 @@ describe('Explore item reducer', () => {
const initalState = { const initalState = {
...makeExploreItemState(), ...makeExploreItemState(),
scanning: false, scanning: false,
scanner: undefined, scanner: undefined as RangeScanner,
}; };
reducerTester() 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) => { export const setup = (urlStateOverrides?: any) => {
@ -201,7 +319,8 @@ describe('Explore reducer', () => {
describe('but urlState is not set in state', () => { describe('but urlState is not set in state', () => {
it('then it should just add urlState and update in state', () => { it('then it should just add urlState and update in state', () => {
const { initalState, serializedUrlState } = setup(); const { initalState, serializedUrlState } = setup();
const stateWithoutUrlState = { ...initalState, left: { urlState: null } }; const urlState: ExploreUrlState = null;
const stateWithoutUrlState = { ...initalState, left: { urlState } };
const expectedState = { ...initalState }; const expectedState = { ...initalState };
reducerTester() reducerTester()

View File

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

View File

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

View File

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

View File

@ -1,24 +1,56 @@
import { renderHook, act } from 'react-hooks-testing-library'; import { renderHook, act } from 'react-hooks-testing-library';
import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
import { useLokiLabels } from './useLokiLabels'; import { useLokiLabels } from './useLokiLabels';
import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
describe('useLokiLabels hook', () => { 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 () => { 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()); act(() => result.current.refreshLabels());
expect(result.current.logLabelOptions).toEqual([]); expect(result.current.logLabelOptions).toEqual([]);
await waitForNextUpdate(); await waitForNextUpdate();
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock); 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();
});
}); });

View File

@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { DatasourceStatus } from '@grafana/ui/src/types/plugin';
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider'; import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm'; import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
import { useRefMounted } from 'app/core/hooks/useRefMounted'; import { useRefMounted } from 'app/core/hooks/useRefMounted';
@ -14,16 +16,22 @@ import { useRefMounted } from 'app/core/hooks/useRefMounted';
export const useLokiLabels = ( export const useLokiLabels = (
languageProvider: LokiLanguageProvider, languageProvider: LokiLanguageProvider,
languageProviderInitialised: boolean, languageProviderInitialised: boolean,
activeOption: CascaderOption[] activeOption: CascaderOption[],
datasourceStatus: DatasourceStatus,
initialDatasourceStatus?: DatasourceStatus // used for test purposes
) => { ) => {
const mounted = useRefMounted(); const mounted = useRefMounted();
// State // State
const [logLabelOptions, setLogLabelOptions] = useState([]); const [logLabelOptions, setLogLabelOptions] = useState([]);
const [shouldTryRefreshLabels, setRefreshLabels] = useState(false); const [shouldTryRefreshLabels, setRefreshLabels] = useState(false);
const [prevDatasourceStatus, setPrevDatasourceStatus] = useState(
initialDatasourceStatus || DatasourceStatus.Connected
);
const [shouldForceRefreshLabels, setForceRefreshLabels] = useState(false);
// Async // Async
const fetchOptionValues = async option => { const fetchOptionValues = async (option: string) => {
await languageProvider.fetchLabelValues(option); await languageProvider.fetchLabelValues(option);
if (mounted.current) { if (mounted.current) {
setLogLabelOptions(languageProvider.logLabelOptions); setLogLabelOptions(languageProvider.logLabelOptions);
@ -31,9 +39,11 @@ export const useLokiLabels = (
}; };
const tryLabelsRefresh = async () => { const tryLabelsRefresh = async () => {
await languageProvider.refreshLogLabels(); await languageProvider.refreshLogLabels(shouldForceRefreshLabels);
if (mounted.current) { if (mounted.current) {
setRefreshLabels(false); setRefreshLabels(false);
setForceRefreshLabels(false);
setLogLabelOptions(languageProvider.logLabelOptions); setLogLabelOptions(languageProvider.logLabelOptions);
} }
}; };
@ -62,14 +72,23 @@ export const useLokiLabels = (
} }
}, [activeOption]); }, [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 // 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 // when previous refresh hasn't finished yet
useEffect(() => { useEffect(() => {
if (shouldTryRefreshLabels) { if (shouldTryRefreshLabels || shouldForceRefreshLabels) {
tryLabelsRefresh(); 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 { return {
logLabelOptions, logLabelOptions,

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
// @ts-ignore
import _ from 'lodash'; import _ from 'lodash';
import React from 'react'; import React from 'react';
// @ts-ignore
import Cascader from 'rc-cascader'; import Cascader from 'rc-cascader';
// @ts-ignore
import PluginPrism from 'slate-prism'; import PluginPrism from 'slate-prism';
// @ts-ignore
import Prism from 'prismjs'; import Prism from 'prismjs';
import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; 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 QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
import { PromQuery } from '../types'; import { PromQuery } from '../types';
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; 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 HISTOGRAM_GROUP = '__histograms__';
const METRIC_MARK = 'metric'; const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql'; const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__'; 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[] { export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option // Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/; const ruleRegex = /:\w+:/;
@ -36,8 +50,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
const options = ruleNames.length > 0 ? [rulesOption] : []; const options = ruleNames.length > 0 ? [rulesOption] : [];
const metricsOptions = _.chain(metrics) const metricsOptions = _.chain(metrics)
.filter(metric => !ruleRegex.test(metric)) .filter((metric: string) => !ruleRegex.test(metric))
.groupBy(metric => metric.split(delimiter)[0]) .groupBy((metric: string) => metric.split(delimiter)[0])
.map( .map(
(metricsForPrefix: string[], prefix: string): CascaderOption => { (metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix; const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
@ -103,7 +117,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
languageProvider: any; languageProvider: any;
languageProviderInitializationPromise: CancelablePromise<any>; languageProviderInitializationPromise: CancelablePromise<any>;
constructor(props: PromQueryFieldProps, context) { constructor(props: PromQueryFieldProps, context: React.Context<any>) {
super(props, context); super(props, context);
if (props.datasource.languageProvider) { if (props.datasource.languageProvider) {
@ -114,8 +128,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
BracesPlugin(), BracesPlugin(),
RunnerPlugin({ handler: props.onExecuteQuery }), RunnerPlugin({ handler: props.onExecuteQuery }),
PluginPrism({ PluginPrism({
onlyIn: node => node.type === 'code_block', onlyIn: (node: any) => node.type === 'code_block',
getSyntax: node => 'promql', getSyntax: (node: any) => 'promql',
}), }),
]; ];
@ -127,17 +141,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
componentDidMount() { componentDidMount() {
if (this.languageProvider) { if (this.languageProvider) {
this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start()); this.refreshMetrics(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');
}
});
} }
} }
@ -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[]) => { onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query; let query;
if (selectedOptions.length === 1) { if (selectedOptions.length === 1) {
@ -202,7 +237,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
// Build metrics tree // Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics); 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 = const metricsOptions =
histogramMetrics.length > 0 histogramMetrics.length > 0
? [ ? [
@ -239,17 +274,18 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
}; };
render() { render() {
const { error, hint, query } = this.props; const { error, hint, query, datasourceStatus } = this.props;
const { metricsOptions, syntaxLoaded } = this.state; const { metricsOptions, syntaxLoaded } = this.state;
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined; 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 ( return (
<> <>
<div className="gf-form-inline gf-form-inline--nowrap"> <div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form flex-shrink-0"> <div className="gf-form flex-shrink-0">
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}> <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" /> {chooserText} <i className="fa fa-caret-down" />
</button> </button>
</Cascader> </Cascader>

View File

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

View File

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

View File

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

View File

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