mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
e69039d8d1
commit
988b7c4dc3
@ -54,8 +54,14 @@ export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends D
|
||||
onChange: (value: TQuery) => void;
|
||||
}
|
||||
|
||||
export enum DatasourceStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
export interface ExploreQueryFieldProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
|
||||
datasource: DSType;
|
||||
datasourceStatus: DatasourceStatus;
|
||||
query: TQuery;
|
||||
error?: string | JSX.Element;
|
||||
hint?: QueryHint;
|
||||
|
@ -2,12 +2,16 @@ import React, { FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
message: any;
|
||||
button?: {
|
||||
text: string;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const Alert: FC<Props> = props => {
|
||||
const { message } = props;
|
||||
const { message, button } = props;
|
||||
return (
|
||||
<div className="gf-form-group section">
|
||||
<div className="alert-container">
|
||||
<div className="alert-error alert">
|
||||
<div className="alert-icon">
|
||||
<i className="fa fa-exclamation-triangle" />
|
||||
@ -15,6 +19,13 @@ export const Alert: FC<Props> = props => {
|
||||
<div className="alert-body">
|
||||
<div className="alert-title">{message}</div>
|
||||
</div>
|
||||
{button && (
|
||||
<div className="alert-button">
|
||||
<button className="btn btn-outline-danger" onClick={button.onClick}>
|
||||
{button.text}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
scanStart,
|
||||
setQueries,
|
||||
refreshExplore,
|
||||
reconnectDatasource,
|
||||
} from './state/actions';
|
||||
|
||||
// Types
|
||||
@ -39,6 +40,7 @@ import { Emitter } from 'app/core/utils/emitter';
|
||||
import { ExploreToolbar } from './ExploreToolbar';
|
||||
import { scanStopAction } from './state/actionTypes';
|
||||
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
|
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
||||
|
||||
interface ExploreProps {
|
||||
StartPage?: ComponentClass<ExploreStartPageProps>;
|
||||
@ -54,6 +56,7 @@ interface ExploreProps {
|
||||
modifyQueries: typeof modifyQueries;
|
||||
range: RawTimeRange;
|
||||
update: ExploreUpdateState;
|
||||
reconnectDatasource: typeof reconnectDatasource;
|
||||
refreshExplore: typeof refreshExplore;
|
||||
scanner?: RangeScanner;
|
||||
scanning?: boolean;
|
||||
@ -201,6 +204,13 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
);
|
||||
};
|
||||
|
||||
onReconnect = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { exploreId, reconnectDatasource } = this.props;
|
||||
|
||||
event.preventDefault();
|
||||
reconnectDatasource(exploreId);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
StartPage,
|
||||
@ -224,13 +234,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
{datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
|
||||
{datasourceMissing ? this.renderEmptyState() : null}
|
||||
|
||||
{datasourceError && (
|
||||
<FadeIn duration={datasourceError ? 150 : 5} in={datasourceError ? true : false}>
|
||||
<div className="explore-container">
|
||||
<Alert message={`Error connecting to datasource: ${datasourceError}`} />
|
||||
<Alert
|
||||
message={`Error connecting to datasource: ${datasourceError}`}
|
||||
button={{ text: 'Reconnect', onClick: this.onReconnect }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FadeIn>
|
||||
|
||||
{datasourceInstance && !datasourceError && (
|
||||
{datasourceInstance && (
|
||||
<div className="explore-container">
|
||||
<QueryRows exploreEvents={this.exploreEvents} exploreId={exploreId} queryKeys={queryKeys} />
|
||||
<AutoSizer onResize={this.onResize} disableHeight>
|
||||
@ -315,6 +328,7 @@ const mapDispatchToProps = {
|
||||
changeTime,
|
||||
initializeExplore,
|
||||
modifyQueries,
|
||||
reconnectDatasource,
|
||||
refreshExplore,
|
||||
scanStart,
|
||||
scanStopAction,
|
||||
|
@ -1,7 +1,9 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
// @ts-ignore
|
||||
import _ from 'lodash';
|
||||
import { hot } from 'react-hot-loader';
|
||||
// @ts-ignore
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Components
|
||||
@ -13,7 +15,14 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
|
||||
|
||||
// Types
|
||||
import { StoreState } from 'app/types';
|
||||
import { RawTimeRange, DataQuery, ExploreDataSourceApi, QueryHint, QueryFixAction } from '@grafana/ui';
|
||||
import {
|
||||
RawTimeRange,
|
||||
DataQuery,
|
||||
ExploreDataSourceApi,
|
||||
QueryHint,
|
||||
QueryFixAction,
|
||||
DatasourceStatus,
|
||||
} from '@grafana/ui';
|
||||
import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
|
||||
@ -32,6 +41,7 @@ interface QueryRowProps {
|
||||
className?: string;
|
||||
exploreId: ExploreId;
|
||||
datasourceInstance: ExploreDataSourceApi;
|
||||
datasourceStatus: DatasourceStatus;
|
||||
highlightLogsExpressionAction: typeof highlightLogsExpressionAction;
|
||||
history: HistoryItem[];
|
||||
index: number;
|
||||
@ -95,7 +105,16 @@ export class QueryRow extends PureComponent<QueryRowProps> {
|
||||
}, 500);
|
||||
|
||||
render() {
|
||||
const { datasourceInstance, history, index, query, queryTransactions, exploreEvents, range } = this.props;
|
||||
const {
|
||||
datasourceInstance,
|
||||
history,
|
||||
index,
|
||||
query,
|
||||
queryTransactions,
|
||||
exploreEvents,
|
||||
range,
|
||||
datasourceStatus,
|
||||
} = this.props;
|
||||
const transactions = queryTransactions.filter(t => t.rowIndex === index);
|
||||
const transactionWithError = transactions.find(t => t.error !== undefined);
|
||||
const hint = getFirstHintFromTransactions(transactions);
|
||||
@ -110,6 +129,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
|
||||
{QueryField ? (
|
||||
<QueryField
|
||||
datasource={datasourceInstance}
|
||||
datasourceStatus={datasourceStatus}
|
||||
query={query}
|
||||
error={queryError}
|
||||
hint={hint}
|
||||
@ -152,12 +172,19 @@ export class QueryRow extends PureComponent<QueryRowProps> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: StoreState, { exploreId, index }) {
|
||||
function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps) {
|
||||
const explore = state.explore;
|
||||
const item: ExploreItemState = explore[exploreId];
|
||||
const { datasourceInstance, history, queries, queryTransactions, range } = item;
|
||||
const { datasourceInstance, history, queries, queryTransactions, range, datasourceError } = item;
|
||||
const query = queries[index];
|
||||
return { datasourceInstance, history, query, queryTransactions, range };
|
||||
return {
|
||||
datasourceInstance,
|
||||
history,
|
||||
query,
|
||||
queryTransactions,
|
||||
range,
|
||||
datasourceStatus: datasourceError ? DatasourceStatus.Disconnected : DatasourceStatus.Connected,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
@ -79,7 +79,6 @@ export interface InitializeExplorePayload {
|
||||
exploreId: ExploreId;
|
||||
containerWidth: number;
|
||||
eventBridge: Emitter;
|
||||
exploreDatasources: DataSourceSelectItem[];
|
||||
queries: DataQuery[];
|
||||
range: RawTimeRange;
|
||||
ui: ExploreUIState;
|
||||
@ -99,16 +98,22 @@ export interface LoadDatasourcePendingPayload {
|
||||
requestedDatasourceName: string;
|
||||
}
|
||||
|
||||
export interface LoadDatasourceSuccessPayload {
|
||||
export interface LoadDatasourceReadyPayload {
|
||||
exploreId: ExploreId;
|
||||
StartPage?: any;
|
||||
datasourceInstance: any;
|
||||
history: HistoryItem[];
|
||||
logsHighlighterExpressions?: any[];
|
||||
showingStartPage: boolean;
|
||||
supportsGraph: boolean;
|
||||
supportsLogs: boolean;
|
||||
supportsTable: boolean;
|
||||
}
|
||||
|
||||
export interface TestDatasourcePendingPayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
|
||||
export interface TestDatasourceFailurePayload {
|
||||
exploreId: ExploreId;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface TestDatasourceSuccessPayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
|
||||
export interface ModifyQueriesPayload {
|
||||
@ -199,6 +204,11 @@ export interface QueriesImportedPayload {
|
||||
queries: DataQuery[];
|
||||
}
|
||||
|
||||
export interface LoadExploreDataSourcesPayload {
|
||||
exploreId: ExploreId;
|
||||
exploreDatasources: DataSourceSelectItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a query row after the row with the given index.
|
||||
*/
|
||||
@ -246,13 +256,6 @@ export const initializeExploreAction = actionCreatorFactory<InitializeExplorePay
|
||||
'explore/INITIALIZE_EXPLORE'
|
||||
).create();
|
||||
|
||||
/**
|
||||
* Display an error that happened during the selection of a datasource
|
||||
*/
|
||||
export const loadDatasourceFailureAction = actionCreatorFactory<LoadDatasourceFailurePayload>(
|
||||
'explore/LOAD_DATASOURCE_FAILURE'
|
||||
).create();
|
||||
|
||||
/**
|
||||
* Display an error when no datasources have been configured
|
||||
*/
|
||||
@ -268,12 +271,10 @@ export const loadDatasourcePendingAction = actionCreatorFactory<LoadDatasourcePe
|
||||
).create();
|
||||
|
||||
/**
|
||||
* Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
|
||||
* run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
|
||||
* e.g., Prometheus -> Loki queries.
|
||||
* Datasource loading was completed.
|
||||
*/
|
||||
export const loadDatasourceSuccessAction = actionCreatorFactory<LoadDatasourceSuccessPayload>(
|
||||
'explore/LOAD_DATASOURCE_SUCCESS'
|
||||
export const loadDatasourceReadyAction = actionCreatorFactory<LoadDatasourceReadyPayload>(
|
||||
'explore/LOAD_DATASOURCE_READY'
|
||||
).create();
|
||||
|
||||
/**
|
||||
@ -391,37 +392,21 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
|
||||
*/
|
||||
export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create();
|
||||
export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
|
||||
export const testDataSourcePendingAction = actionCreatorFactory<TestDatasourcePendingPayload>(
|
||||
'explore/TEST_DATASOURCE_PENDING'
|
||||
).create();
|
||||
export const testDataSourceSuccessAction = actionCreatorFactory<TestDatasourceSuccessPayload>(
|
||||
'explore/TEST_DATASOURCE_SUCCESS'
|
||||
).create();
|
||||
export const testDataSourceFailureAction = actionCreatorFactory<TestDatasourceFailurePayload>(
|
||||
'explore/TEST_DATASOURCE_FAILURE'
|
||||
).create();
|
||||
export const loadExploreDatasources = actionCreatorFactory<LoadExploreDataSourcesPayload>(
|
||||
'explore/LOAD_EXPLORE_DATASOURCES'
|
||||
).create();
|
||||
|
||||
export type HigherOrderAction =
|
||||
| ActionOf<SplitCloseActionPayload>
|
||||
| SplitOpenAction
|
||||
| ResetExploreAction
|
||||
| ActionOf<any>;
|
||||
|
||||
export type Action =
|
||||
| ActionOf<AddQueryRowPayload>
|
||||
| ActionOf<ChangeQueryPayload>
|
||||
| ActionOf<ChangeSizePayload>
|
||||
| ActionOf<ChangeTimePayload>
|
||||
| ActionOf<ClearQueriesPayload>
|
||||
| ActionOf<HighlightLogsExpressionPayload>
|
||||
| ActionOf<InitializeExplorePayload>
|
||||
| ActionOf<LoadDatasourceFailurePayload>
|
||||
| ActionOf<LoadDatasourceMissingPayload>
|
||||
| ActionOf<LoadDatasourcePendingPayload>
|
||||
| ActionOf<LoadDatasourceSuccessPayload>
|
||||
| ActionOf<ModifyQueriesPayload>
|
||||
| ActionOf<QueryTransactionFailurePayload>
|
||||
| ActionOf<QueryTransactionStartPayload>
|
||||
| ActionOf<QueryTransactionSuccessPayload>
|
||||
| ActionOf<RemoveQueryRowPayload>
|
||||
| ActionOf<ScanStartPayload>
|
||||
| ActionOf<ScanRangePayload>
|
||||
| ActionOf<SetQueriesPayload>
|
||||
| ActionOf<SplitOpenPayload>
|
||||
| ActionOf<ToggleTablePayload>
|
||||
| ActionOf<ToggleGraphPayload>
|
||||
| ActionOf<ToggleLogsPayload>
|
||||
| ActionOf<UpdateDatasourceInstancePayload>
|
||||
| ActionOf<QueriesImportedPayload>
|
||||
| ActionOf<ToggleLogLevelPayload>;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { refreshExplore } from './actions';
|
||||
import { refreshExplore, testDatasource, loadDatasource } from './actions';
|
||||
import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
|
||||
import { thunkTester } from 'test/core/thunk/thunkTester';
|
||||
import { LogsDedupStrategy } from 'app/core/logs_model';
|
||||
@ -8,10 +8,16 @@ import {
|
||||
changeTimeAction,
|
||||
updateUIStateAction,
|
||||
setQueriesAction,
|
||||
testDataSourcePendingAction,
|
||||
testDataSourceSuccessAction,
|
||||
testDataSourceFailureAction,
|
||||
loadDatasourcePendingAction,
|
||||
loadDatasourceReadyAction,
|
||||
} from './actionTypes';
|
||||
import { Emitter } from 'app/core/core';
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
|
||||
import { makeInitialUpdateState } from './reducers';
|
||||
import { DataQuery } from '@grafana/ui/src/types/datasource';
|
||||
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||
getDatasourceSrv: () => ({
|
||||
@ -41,7 +47,7 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
|
||||
eventBridge,
|
||||
update,
|
||||
datasourceInstance: { name: 'some-datasource' },
|
||||
queries: [],
|
||||
queries: [] as DataQuery[],
|
||||
range,
|
||||
ui,
|
||||
},
|
||||
@ -61,87 +67,204 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
|
||||
describe('refreshExplore', () => {
|
||||
describe('when explore is initialized', () => {
|
||||
describe('and update datasource is set', () => {
|
||||
it('then it should dispatch initializeExplore', () => {
|
||||
it('then it should dispatch initializeExplore', async () => {
|
||||
const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
|
||||
|
||||
thunkTester(initialState)
|
||||
const dispatchedActions = await thunkTester(initialState)
|
||||
.givenThunk(refreshExplore)
|
||||
.whenThunkIsDispatched(exploreId)
|
||||
.thenDispatchedActionsAreEqual(dispatchedActions => {
|
||||
const initializeExplore = dispatchedActions[0] as ActionOf<InitializeExplorePayload>;
|
||||
const { type, payload } = initializeExplore;
|
||||
.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<InitializeExplorePayload>;
|
||||
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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
// Libraries
|
||||
// @ts-ignore
|
||||
import _ from 'lodash';
|
||||
|
||||
// Services & Utils
|
||||
@ -22,6 +23,7 @@ import {
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
|
||||
// Types
|
||||
import { ThunkResult } from 'app/types';
|
||||
import {
|
||||
RawTimeRange,
|
||||
TimeRange,
|
||||
@ -31,7 +33,15 @@ import {
|
||||
QueryHint,
|
||||
QueryFixAction,
|
||||
} from '@grafana/ui/src/types';
|
||||
import { ExploreId, ExploreUrlState, RangeScanner, ResultType, QueryOptions, ExploreUIState } from 'app/types/explore';
|
||||
import {
|
||||
ExploreId,
|
||||
ExploreUrlState,
|
||||
RangeScanner,
|
||||
ResultType,
|
||||
QueryOptions,
|
||||
ExploreUIState,
|
||||
QueryTransaction,
|
||||
} from 'app/types/explore';
|
||||
import {
|
||||
updateDatasourceInstanceAction,
|
||||
changeQueryAction,
|
||||
@ -42,11 +52,10 @@ import {
|
||||
clearQueriesAction,
|
||||
initializeExploreAction,
|
||||
loadDatasourceMissingAction,
|
||||
loadDatasourceFailureAction,
|
||||
loadDatasourcePendingAction,
|
||||
queriesImportedAction,
|
||||
LoadDatasourceSuccessPayload,
|
||||
loadDatasourceSuccessAction,
|
||||
LoadDatasourceReadyPayload,
|
||||
loadDatasourceReadyAction,
|
||||
modifyQueriesAction,
|
||||
queryTransactionFailureAction,
|
||||
queryTransactionStartAction,
|
||||
@ -65,16 +74,19 @@ import {
|
||||
ToggleTablePayload,
|
||||
updateUIStateAction,
|
||||
runQueriesAction,
|
||||
testDataSourcePendingAction,
|
||||
testDataSourceSuccessAction,
|
||||
testDataSourceFailureAction,
|
||||
loadExploreDatasources,
|
||||
} from './actionTypes';
|
||||
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
|
||||
import { LogsDedupStrategy } from 'app/core/logs_model';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { parseTime } from '../TimePicker';
|
||||
|
||||
/**
|
||||
* Updates UI state and save it to the URL
|
||||
*/
|
||||
const updateExploreUIState = (exploreId, uiStateFragment: Partial<ExploreUIState>) => {
|
||||
const updateExploreUIState = (exploreId: ExploreId, uiStateFragment: Partial<ExploreUIState>): ThunkResult<void> => {
|
||||
return dispatch => {
|
||||
dispatch(updateUIStateAction({ exploreId, ...uiStateFragment }));
|
||||
dispatch(stateSave());
|
||||
@ -97,7 +109,14 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult<vo
|
||||
*/
|
||||
export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const newDataSourceInstance = await getDatasourceSrv().get(datasource);
|
||||
let newDataSourceInstance: DataSourceApi = null;
|
||||
|
||||
if (!datasource) {
|
||||
newDataSourceInstance = await getDatasourceSrv().get();
|
||||
} else {
|
||||
newDataSourceInstance = await getDatasourceSrv().get(datasource);
|
||||
}
|
||||
|
||||
const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance;
|
||||
const queries = getState().explore[exploreId].queries;
|
||||
|
||||
@ -105,12 +124,7 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun
|
||||
|
||||
dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: newDataSourceInstance }));
|
||||
|
||||
try {
|
||||
await dispatch(loadDatasource(exploreId, newDataSourceInstance));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
await dispatch(loadDatasource(exploreId, newDataSourceInstance));
|
||||
|
||||
dispatch(runQueries(exploreId));
|
||||
};
|
||||
@ -171,6 +185,33 @@ export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all explore data sources and sets the chosen datasource.
|
||||
* If there are no datasources a missing datasource action is dispatched.
|
||||
*/
|
||||
export function loadExploreDatasourcesAndSetDatasource(
|
||||
exploreId: ExploreId,
|
||||
datasourceName: string
|
||||
): ThunkResult<void> {
|
||||
return dispatch => {
|
||||
const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
|
||||
.getExternal()
|
||||
.map((ds: any) => ({
|
||||
value: ds.name,
|
||||
name: ds.name,
|
||||
meta: ds.meta,
|
||||
}));
|
||||
|
||||
dispatch(loadExploreDatasources({ exploreId, exploreDatasources }));
|
||||
|
||||
if (exploreDatasources.length >= 1) {
|
||||
dispatch(changeDatasource(exploreId, datasourceName));
|
||||
} else {
|
||||
dispatch(loadDatasourceMissingAction({ exploreId }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Explore state with state from the URL and the React component.
|
||||
* Call this only on components for with the Explore state has not been initialized.
|
||||
@ -185,83 +226,35 @@ export function initializeExplore(
|
||||
ui: ExploreUIState
|
||||
): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
|
||||
.getExternal()
|
||||
.map(ds => ({
|
||||
value: ds.name,
|
||||
name: ds.name,
|
||||
meta: ds.meta,
|
||||
}));
|
||||
|
||||
dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
|
||||
dispatch(
|
||||
initializeExploreAction({
|
||||
exploreId,
|
||||
containerWidth,
|
||||
eventBridge,
|
||||
exploreDatasources,
|
||||
queries,
|
||||
range,
|
||||
ui,
|
||||
})
|
||||
);
|
||||
|
||||
if (exploreDatasources.length >= 1) {
|
||||
let instance;
|
||||
|
||||
if (datasourceName) {
|
||||
try {
|
||||
instance = await getDatasourceSrv().get(datasourceName);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
// Checking on instance here because requested datasource could be deleted already
|
||||
if (!instance) {
|
||||
instance = await getDatasourceSrv().get();
|
||||
}
|
||||
|
||||
dispatch(updateDatasourceInstanceAction({ exploreId, datasourceInstance: instance }));
|
||||
|
||||
try {
|
||||
await dispatch(loadDatasource(exploreId, instance));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
dispatch(runQueries(exploreId, true));
|
||||
} else {
|
||||
dispatch(loadDatasourceMissingAction({ exploreId }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Datasource loading was successfully completed. The instance is stored in the state as well in case we need to
|
||||
* run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
|
||||
* e.g., Prometheus -> Loki queries.
|
||||
* Datasource loading was successfully completed.
|
||||
*/
|
||||
export const loadDatasourceSuccess = (exploreId: ExploreId, instance: any): ActionOf<LoadDatasourceSuccessPayload> => {
|
||||
// Capabilities
|
||||
const supportsGraph = instance.meta.metrics;
|
||||
const supportsLogs = instance.meta.logs;
|
||||
const supportsTable = instance.meta.tables;
|
||||
// Custom components
|
||||
const StartPage = instance.pluginExports.ExploreStartPage;
|
||||
|
||||
export const loadDatasourceReady = (
|
||||
exploreId: ExploreId,
|
||||
instance: DataSourceApi
|
||||
): ActionOf<LoadDatasourceReadyPayload> => {
|
||||
const historyKey = `grafana.explore.history.${instance.meta.id}`;
|
||||
const history = store.getObject(historyKey, []);
|
||||
// Save last-used datasource
|
||||
store.set(LAST_USED_DATASOURCE_KEY, instance.name);
|
||||
|
||||
return loadDatasourceSuccessAction({
|
||||
return loadDatasourceReadyAction({
|
||||
exploreId,
|
||||
StartPage,
|
||||
datasourceInstance: instance,
|
||||
history,
|
||||
showingStartPage: Boolean(StartPage),
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
});
|
||||
};
|
||||
|
||||
@ -270,8 +263,14 @@ export function importQueries(
|
||||
queries: DataQuery[],
|
||||
sourceDataSource: DataSourceApi,
|
||||
targetDataSource: DataSourceApi
|
||||
) {
|
||||
): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
if (!sourceDataSource) {
|
||||
// explore not initialized
|
||||
dispatch(queriesImportedAction({ exploreId, queries }));
|
||||
return;
|
||||
}
|
||||
|
||||
let importedQueries = queries;
|
||||
// Check if queries can be imported from previously selected datasource
|
||||
if (sourceDataSource.meta.id === targetDataSource.meta.id) {
|
||||
@ -295,16 +294,14 @@ export function importQueries(
|
||||
}
|
||||
|
||||
/**
|
||||
* Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
|
||||
* Tests datasource.
|
||||
*/
|
||||
export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const datasourceName = instance.name;
|
||||
|
||||
// Keep ID to track selection
|
||||
dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
|
||||
export const testDatasource = (exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
let datasourceError = null;
|
||||
|
||||
dispatch(testDataSourcePendingAction({ exploreId }));
|
||||
|
||||
try {
|
||||
const testResult = await instance.testDatasource();
|
||||
datasourceError = testResult.status === 'success' ? null : testResult.message;
|
||||
@ -313,10 +310,36 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
|
||||
}
|
||||
|
||||
if (datasourceError) {
|
||||
dispatch(loadDatasourceFailureAction({ exploreId, error: datasourceError }));
|
||||
return Promise.reject(`${datasourceName} loading failed`);
|
||||
dispatch(testDataSourceFailureAction({ exploreId, error: datasourceError }));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(testDataSourceSuccessAction({ exploreId }));
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnects datasource when there is a connection failure.
|
||||
*/
|
||||
export const reconnectDatasource = (exploreId: ExploreId): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
const instance = getState().explore[exploreId].datasourceInstance;
|
||||
dispatch(changeDatasource(exploreId, instance.name));
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback.
|
||||
*/
|
||||
export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const datasourceName = instance.name;
|
||||
|
||||
// Keep ID to track selection
|
||||
dispatch(loadDatasourcePendingAction({ exploreId, requestedDatasourceName: datasourceName }));
|
||||
|
||||
await dispatch(testDatasource(exploreId, instance));
|
||||
|
||||
if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
|
||||
// User already changed datasource again, discard results
|
||||
return;
|
||||
@ -331,8 +354,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(loadDatasourceSuccess(exploreId, instance));
|
||||
return Promise.resolve();
|
||||
dispatch(loadDatasourceReady(exploreId, instance));
|
||||
};
|
||||
}
|
||||
|
||||
@ -502,7 +524,7 @@ export function queryTransactionSuccess(
|
||||
/**
|
||||
* Main action to run queries and dispatches sub-actions based on which result viewers are active
|
||||
*/
|
||||
export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
|
||||
export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const {
|
||||
datasourceInstance,
|
||||
@ -513,8 +535,14 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
datasourceError,
|
||||
} = getState().explore[exploreId];
|
||||
|
||||
if (datasourceError) {
|
||||
// let's not run any queries if data source is in a faulty state
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasNonEmptyQuery(queries)) {
|
||||
dispatch(clearQueriesAction({ exploreId }));
|
||||
dispatch(stateSave()); // Remember to saves to state and update location
|
||||
@ -538,7 +566,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
|
||||
instant: true,
|
||||
valueWithRefId: true,
|
||||
},
|
||||
data => data[0]
|
||||
(data: any) => data[0]
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -576,7 +604,7 @@ function runQueriesForType(
|
||||
resultType: ResultType,
|
||||
queryOptions: QueryOptions,
|
||||
resultGetter?: any
|
||||
) {
|
||||
): ThunkResult<void> {
|
||||
return async (dispatch, getState) => {
|
||||
const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId];
|
||||
const datasourceId = datasourceInstance.meta.id;
|
||||
@ -659,9 +687,10 @@ export function splitOpen(): ThunkResult<void> {
|
||||
const leftState = getState().explore[ExploreId.left];
|
||||
const queryState = getState().location.query[ExploreId.left] as string;
|
||||
const urlState = parseUrlState(queryState);
|
||||
const queryTransactions: QueryTransaction[] = [];
|
||||
const itemState = {
|
||||
...leftState,
|
||||
queryTransactions: [],
|
||||
queryTransactions,
|
||||
queries: leftState.queries.slice(),
|
||||
exploreId: ExploreId.right,
|
||||
urlState,
|
||||
@ -675,7 +704,7 @@ export function splitOpen(): ThunkResult<void> {
|
||||
* Saves Explore state to URL using the `left` and `right` parameters.
|
||||
* If split view is not active, `right` will not be set.
|
||||
*/
|
||||
export function stateSave() {
|
||||
export function stateSave(): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const { left, right, split } = getState().explore;
|
||||
const urlStates: { [index: string]: string } = {};
|
||||
@ -720,7 +749,7 @@ const togglePanelActionCreator = (
|
||||
| ActionCreator<ToggleGraphPayload>
|
||||
| ActionCreator<ToggleLogsPayload>
|
||||
| ActionCreator<ToggleTablePayload>
|
||||
) => (exploreId: ExploreId, isPanelVisible: boolean) => {
|
||||
) => (exploreId: ExploreId, isPanelVisible: boolean): ThunkResult<void> => {
|
||||
return dispatch => {
|
||||
let uiFragmentStateUpdate: Partial<ExploreUIState>;
|
||||
const shouldRunQueries = !isPanelVisible;
|
||||
@ -764,7 +793,7 @@ export const toggleTable = togglePanelActionCreator(toggleTableAction);
|
||||
/**
|
||||
* Change logs deduplication strategy and update URL.
|
||||
*/
|
||||
export const changeDedupStrategy = (exploreId, dedupStrategy: LogsDedupStrategy) => {
|
||||
export const changeDedupStrategy = (exploreId: ExploreId, dedupStrategy: LogsDedupStrategy): ThunkResult<void> => {
|
||||
return dispatch => {
|
||||
dispatch(updateExploreUIState(exploreId, { dedupStrategy }));
|
||||
};
|
||||
|
@ -5,14 +5,32 @@ import {
|
||||
makeInitialUpdateState,
|
||||
initialExploreState,
|
||||
} from './reducers';
|
||||
import { ExploreId, ExploreItemState, ExploreUrlState, ExploreState } from 'app/types/explore';
|
||||
import {
|
||||
ExploreId,
|
||||
ExploreItemState,
|
||||
ExploreUrlState,
|
||||
ExploreState,
|
||||
QueryTransaction,
|
||||
RangeScanner,
|
||||
} from 'app/types/explore';
|
||||
import { reducerTester } from 'test/core/redux/reducerTester';
|
||||
import { scanStartAction, scanStopAction, splitOpenAction, splitCloseAction } from './actionTypes';
|
||||
import {
|
||||
scanStartAction,
|
||||
scanStopAction,
|
||||
testDataSourcePendingAction,
|
||||
testDataSourceSuccessAction,
|
||||
testDataSourceFailureAction,
|
||||
updateDatasourceInstanceAction,
|
||||
splitOpenAction,
|
||||
splitCloseAction,
|
||||
} from './actionTypes';
|
||||
import { Reducer } from 'redux';
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
|
||||
import { updateLocation } from 'app/core/actions/location';
|
||||
import { LogsDedupStrategy } from 'app/core/logs_model';
|
||||
import { LogsDedupStrategy, LogsModel } from 'app/core/logs_model';
|
||||
import { serializeStateToUrlParam } from 'app/core/utils/explore';
|
||||
import TableModel from 'app/core/table_model';
|
||||
import { DataSourceApi, DataQuery } from '@grafana/ui';
|
||||
|
||||
describe('Explore item reducer', () => {
|
||||
describe('scanning', () => {
|
||||
@ -21,7 +39,7 @@ describe('Explore item reducer', () => {
|
||||
const initalState = {
|
||||
...makeExploreItemState(),
|
||||
scanning: false,
|
||||
scanner: undefined,
|
||||
scanner: undefined as RangeScanner,
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
@ -53,6 +71,106 @@ describe('Explore item reducer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('testing datasource', () => {
|
||||
describe('when testDataSourcePendingAction is dispatched', () => {
|
||||
it('then it should set datasourceError', () => {
|
||||
reducerTester()
|
||||
.givenReducer(itemReducer, { datasourceError: {} })
|
||||
.whenActionIsDispatched(testDataSourcePendingAction({ exploreId: ExploreId.left }))
|
||||
.thenStateShouldEqual({ datasourceError: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when testDataSourceSuccessAction is dispatched', () => {
|
||||
it('then it should set datasourceError', () => {
|
||||
reducerTester()
|
||||
.givenReducer(itemReducer, { datasourceError: {} })
|
||||
.whenActionIsDispatched(testDataSourceSuccessAction({ exploreId: ExploreId.left }))
|
||||
.thenStateShouldEqual({ datasourceError: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when testDataSourceFailureAction is dispatched', () => {
|
||||
it('then it should set correct state', () => {
|
||||
const error = 'some error';
|
||||
const queryTransactions: QueryTransaction[] = [];
|
||||
const initalState: Partial<ExploreItemState> = {
|
||||
datasourceError: null,
|
||||
queryTransactions: [{} as QueryTransaction],
|
||||
graphResult: [],
|
||||
tableResult: {} as TableModel,
|
||||
logsResult: {} as LogsModel,
|
||||
update: {
|
||||
datasource: true,
|
||||
queries: true,
|
||||
range: true,
|
||||
ui: true,
|
||||
},
|
||||
};
|
||||
const expectedState = {
|
||||
datasourceError: error,
|
||||
queryTransactions,
|
||||
graphResult: undefined as any[],
|
||||
tableResult: undefined as TableModel,
|
||||
logsResult: undefined as LogsModel,
|
||||
update: makeInitialUpdateState(),
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(itemReducer, initalState)
|
||||
.whenActionIsDispatched(testDataSourceFailureAction({ exploreId: ExploreId.left, error }))
|
||||
.thenStateShouldEqual(expectedState);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing datasource', () => {
|
||||
describe('when updateDatasourceInstanceAction is dispatched', () => {
|
||||
describe('and datasourceInstance supports graph, logs, table and has a startpage', () => {
|
||||
it('then it should set correct state', () => {
|
||||
const StartPage = {};
|
||||
const datasourceInstance = {
|
||||
meta: {
|
||||
metrics: {},
|
||||
logs: {},
|
||||
tables: {},
|
||||
},
|
||||
pluginExports: {
|
||||
ExploreStartPage: StartPage,
|
||||
},
|
||||
} as DataSourceApi;
|
||||
const queries: DataQuery[] = [];
|
||||
const queryKeys: string[] = [];
|
||||
const initalState: Partial<ExploreItemState> = {
|
||||
datasourceInstance: null,
|
||||
supportsGraph: false,
|
||||
supportsLogs: false,
|
||||
supportsTable: false,
|
||||
StartPage: null,
|
||||
showingStartPage: false,
|
||||
queries,
|
||||
queryKeys,
|
||||
};
|
||||
const expectedState = {
|
||||
datasourceInstance,
|
||||
supportsGraph: true,
|
||||
supportsLogs: true,
|
||||
supportsTable: true,
|
||||
StartPage,
|
||||
showingStartPage: true,
|
||||
queries,
|
||||
queryKeys,
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(itemReducer, initalState)
|
||||
.whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId: ExploreId.left, datasourceInstance }))
|
||||
.thenStateShouldEqual(expectedState);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const setup = (urlStateOverrides?: any) => {
|
||||
@ -201,7 +319,8 @@ describe('Explore reducer', () => {
|
||||
describe('but urlState is not set in state', () => {
|
||||
it('then it should just add urlState and update in state', () => {
|
||||
const { initalState, serializedUrlState } = setup();
|
||||
const stateWithoutUrlState = { ...initalState, left: { urlState: null } };
|
||||
const urlState: ExploreUrlState = null;
|
||||
const stateWithoutUrlState = { ...initalState, left: { urlState } };
|
||||
const expectedState = { ...initalState };
|
||||
|
||||
reducerTester()
|
||||
|
@ -11,8 +11,16 @@ import {
|
||||
} from 'app/core/utils/explore';
|
||||
import { ExploreItemState, ExploreState, QueryTransaction, ExploreId, ExploreUpdateState } from 'app/types/explore';
|
||||
import { DataQuery } from '@grafana/ui/src/types';
|
||||
|
||||
import { HigherOrderAction, ActionTypes, SplitCloseActionPayload, splitCloseAction } from './actionTypes';
|
||||
import {
|
||||
HigherOrderAction,
|
||||
ActionTypes,
|
||||
testDataSourcePendingAction,
|
||||
testDataSourceSuccessAction,
|
||||
testDataSourceFailureAction,
|
||||
splitCloseAction,
|
||||
SplitCloseActionPayload,
|
||||
loadExploreDatasources,
|
||||
} from './actionTypes';
|
||||
import { reducerFactory } from 'app/core/redux';
|
||||
import {
|
||||
addQueryRowAction,
|
||||
@ -23,10 +31,9 @@ import {
|
||||
highlightLogsExpressionAction,
|
||||
initializeExploreAction,
|
||||
updateDatasourceInstanceAction,
|
||||
loadDatasourceFailureAction,
|
||||
loadDatasourceMissingAction,
|
||||
loadDatasourcePendingAction,
|
||||
loadDatasourceSuccessAction,
|
||||
loadDatasourceReadyAction,
|
||||
modifyQueriesAction,
|
||||
queryTransactionFailureAction,
|
||||
queryTransactionStartAction,
|
||||
@ -197,12 +204,11 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
.addMapper({
|
||||
filter: initializeExploreAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { containerWidth, eventBridge, exploreDatasources, queries, range, ui } = action.payload;
|
||||
const { containerWidth, eventBridge, queries, range, ui } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
containerWidth,
|
||||
eventBridge,
|
||||
exploreDatasources,
|
||||
range,
|
||||
queries,
|
||||
initialized: true,
|
||||
@ -216,17 +222,22 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
filter: updateDatasourceInstanceAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { datasourceInstance } = action.payload;
|
||||
return { ...state, datasourceInstance, queryKeys: getQueryKeys(state.queries, datasourceInstance) };
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: loadDatasourceFailureAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
// Capabilities
|
||||
const supportsGraph = datasourceInstance.meta.metrics;
|
||||
const supportsLogs = datasourceInstance.meta.logs;
|
||||
const supportsTable = datasourceInstance.meta.tables;
|
||||
// Custom components
|
||||
const StartPage = datasourceInstance.pluginExports.ExploreStartPage;
|
||||
|
||||
return {
|
||||
...state,
|
||||
datasourceError: action.payload.error,
|
||||
datasourceLoading: false,
|
||||
update: makeInitialUpdateState(),
|
||||
datasourceInstance,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
StartPage,
|
||||
showingStartPage: Boolean(StartPage),
|
||||
queryKeys: getQueryKeys(state.queries, datasourceInstance),
|
||||
};
|
||||
},
|
||||
})
|
||||
@ -244,37 +255,26 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
.addMapper({
|
||||
filter: loadDatasourcePendingAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
return { ...state, datasourceLoading: true, requestedDatasourceName: action.payload.requestedDatasourceName };
|
||||
return {
|
||||
...state,
|
||||
datasourceLoading: true,
|
||||
requestedDatasourceName: action.payload.requestedDatasourceName,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: loadDatasourceSuccessAction,
|
||||
filter: loadDatasourceReadyAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { containerWidth, range } = state;
|
||||
const {
|
||||
StartPage,
|
||||
datasourceInstance,
|
||||
history,
|
||||
showingStartPage,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
} = action.payload;
|
||||
const { containerWidth, range, datasourceInstance } = state;
|
||||
const { history } = action.payload;
|
||||
const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth);
|
||||
|
||||
return {
|
||||
...state,
|
||||
queryIntervals,
|
||||
StartPage,
|
||||
datasourceInstance,
|
||||
history,
|
||||
showingStartPage,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
datasourceLoading: false,
|
||||
datasourceMissing: false,
|
||||
datasourceError: null,
|
||||
logsHighlighterExpressions: undefined,
|
||||
queryTransactions: [],
|
||||
update: makeInitialUpdateState(),
|
||||
@ -517,6 +517,47 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: testDataSourcePendingAction,
|
||||
mapper: (state): ExploreItemState => {
|
||||
return {
|
||||
...state,
|
||||
datasourceError: null,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: testDataSourceSuccessAction,
|
||||
mapper: (state): ExploreItemState => {
|
||||
return {
|
||||
...state,
|
||||
datasourceError: null,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: testDataSourceFailureAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
return {
|
||||
...state,
|
||||
datasourceError: action.payload.error,
|
||||
queryTransactions: [],
|
||||
graphResult: undefined,
|
||||
tableResult: undefined,
|
||||
logsResult: undefined,
|
||||
update: makeInitialUpdateState(),
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: loadExploreDatasources,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
return {
|
||||
...state,
|
||||
exploreDatasources: action.payload.exploreDatasources,
|
||||
};
|
||||
},
|
||||
})
|
||||
.create();
|
||||
|
||||
export const updateChildRefreshState = (
|
||||
|
@ -2,12 +2,20 @@ import React, { FunctionComponent } from 'react';
|
||||
import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
|
||||
import { useLokiSyntax } from './useLokiSyntax';
|
||||
|
||||
const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => {
|
||||
const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider);
|
||||
const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({
|
||||
datasource,
|
||||
datasourceStatus,
|
||||
...otherProps
|
||||
}) => {
|
||||
const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(
|
||||
datasource.languageProvider,
|
||||
datasourceStatus
|
||||
);
|
||||
|
||||
return (
|
||||
<LokiQueryFieldForm
|
||||
datasource={datasource}
|
||||
datasourceStatus={datasourceStatus}
|
||||
syntaxLoaded={isSyntaxReady}
|
||||
/**
|
||||
* setActiveOption name is intentional. Because of the way rc-cascader requests additional data
|
||||
|
@ -1,6 +1,8 @@
|
||||
// Libraries
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import Cascader from 'rc-cascader';
|
||||
// @ts-ignore
|
||||
import PluginPrism from 'slate-prism';
|
||||
|
||||
// Components
|
||||
@ -15,10 +17,13 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
|
||||
// Types
|
||||
import { LokiQuery } from '../types';
|
||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps, DatasourceStatus } from '@grafana/ui';
|
||||
|
||||
function getChooserText(hasSytax, hasLogLabels) {
|
||||
if (!hasSytax) {
|
||||
function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DatasourceStatus) {
|
||||
if (datasourceStatus === DatasourceStatus.Disconnected) {
|
||||
return '(Disconnected)';
|
||||
}
|
||||
if (!hasSyntax) {
|
||||
return 'Loading labels...';
|
||||
}
|
||||
if (!hasLogLabels) {
|
||||
@ -76,15 +81,15 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
|
||||
modifiedSearch: string;
|
||||
modifiedQuery: string;
|
||||
|
||||
constructor(props: LokiQueryFieldFormProps, context) {
|
||||
constructor(props: LokiQueryFieldFormProps, context: React.Context<any>) {
|
||||
super(props, context);
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
RunnerPlugin({ handler: props.onExecuteQuery }),
|
||||
PluginPrism({
|
||||
onlyIn: node => node.type === 'code_block',
|
||||
getSyntax: node => 'promql',
|
||||
onlyIn: (node: any) => node.type === 'code_block',
|
||||
getSyntax: (node: any) => 'promql',
|
||||
}),
|
||||
];
|
||||
|
||||
@ -159,10 +164,12 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
|
||||
onLoadOptions,
|
||||
onLabelsRefresh,
|
||||
datasource,
|
||||
datasourceStatus,
|
||||
} = this.props;
|
||||
const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
|
||||
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
|
||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
|
||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus);
|
||||
const buttonDisabled = !syntaxLoaded || datasourceStatus === DatasourceStatus.Disconnected;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -172,13 +179,13 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
|
||||
options={logLabelOptions}
|
||||
onChange={this.onChangeLogLabels}
|
||||
loadData={onLoadOptions}
|
||||
onPopupVisibleChange={isVisible => {
|
||||
onPopupVisibleChange={(isVisible: boolean) => {
|
||||
if (isVisible && onLabelsRefresh) {
|
||||
onLabelsRefresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={buttonDisabled}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,4 +1,5 @@
|
||||
// Libraries
|
||||
// @ts-ignore
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
@ -60,13 +61,13 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
Object.assign(this, initialValues);
|
||||
}
|
||||
// Strip syntax chars
|
||||
cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||
cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||
|
||||
getSyntax() {
|
||||
return syntax;
|
||||
}
|
||||
|
||||
request = url => {
|
||||
request = (url: string) => {
|
||||
return this.datasource.metadataRequest(url);
|
||||
};
|
||||
|
||||
@ -100,12 +101,12 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
|
||||
if (history && history.length > 0) {
|
||||
const historyItems = _.chain(history)
|
||||
.map(h => h.query.expr)
|
||||
.map((h: any) => h.query.expr)
|
||||
.filter()
|
||||
.uniq()
|
||||
.take(HISTORY_ITEM_COUNT)
|
||||
.map(wrapLabel)
|
||||
.map(item => addHistoryMetadata(item, history))
|
||||
.map((item: CompletionItem) => addHistoryMetadata(item, history))
|
||||
.value();
|
||||
|
||||
suggestions.push({
|
||||
@ -191,7 +192,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
const selectorMatch = query.match(selectorRegexp);
|
||||
if (selectorMatch) {
|
||||
const selector = selectorMatch[0];
|
||||
const labels = {};
|
||||
const labels: { [key: string]: { value: any; operator: any } } = {};
|
||||
selector.replace(labelRegexp, (_, key, operator, value) => {
|
||||
labels[key] = { value, operator };
|
||||
return '';
|
||||
@ -200,7 +201,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
// Keep only labels that exist on origin and target datasource
|
||||
await this.start(); // fetches all existing label keys
|
||||
const existingKeys = this.labelKeys[EMPTY_SELECTOR];
|
||||
let labelsToKeep = {};
|
||||
let labelsToKeep: { [key: string]: { value: any; operator: any } } = {};
|
||||
if (existingKeys && existingKeys.length > 0) {
|
||||
// Check for common labels
|
||||
for (const key in labels) {
|
||||
@ -225,7 +226,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return '';
|
||||
}
|
||||
|
||||
async fetchLogLabels() {
|
||||
async fetchLogLabels(): Promise<any> {
|
||||
const url = '/api/prom/label';
|
||||
try {
|
||||
this.logLabelFetchTs = Date.now();
|
||||
@ -236,11 +237,13 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
...this.labelKeys,
|
||||
[EMPTY_SELECTOR]: labelKeys,
|
||||
};
|
||||
this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
|
||||
this.logLabelOptions = labelKeys.map((key: string) => ({ label: key, value: key, isLeaf: false }));
|
||||
|
||||
// Pre-load values for default labels
|
||||
return Promise.all(
|
||||
labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key))
|
||||
labelKeys
|
||||
.filter((key: string) => DEFAULT_KEYS.indexOf(key) > -1)
|
||||
.map((key: string) => this.fetchLabelValues(key))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -248,8 +251,8 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async refreshLogLabels() {
|
||||
if (this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) {
|
||||
async refreshLogLabels(forceRefresh?: boolean) {
|
||||
if ((this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
|
||||
await this.fetchLogLabels();
|
||||
}
|
||||
}
|
||||
@ -266,7 +269,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
if (keyOption.value === key) {
|
||||
return {
|
||||
...keyOption,
|
||||
children: values.map(value => ({ label: value, value })),
|
||||
children: values.map((value: string) => ({ label: value, value })),
|
||||
};
|
||||
}
|
||||
return keyOption;
|
||||
|
@ -1,7 +1,11 @@
|
||||
// @ts-ignore
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import Cascader from 'rc-cascader';
|
||||
// @ts-ignore
|
||||
import PluginPrism from 'slate-prism';
|
||||
// @ts-ignore
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
|
||||
@ -13,13 +17,23 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
|
||||
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
|
||||
import { PromQuery } from '../types';
|
||||
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps, DatasourceStatus } from '@grafana/ui';
|
||||
|
||||
const HISTOGRAM_GROUP = '__histograms__';
|
||||
const METRIC_MARK = 'metric';
|
||||
const PRISM_SYNTAX = 'promql';
|
||||
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
||||
|
||||
function getChooserText(hasSyntax: boolean, datasourceStatus: DatasourceStatus) {
|
||||
if (datasourceStatus === DatasourceStatus.Disconnected) {
|
||||
return '(Disconnected)';
|
||||
}
|
||||
if (!hasSyntax) {
|
||||
return 'Loading metrics...';
|
||||
}
|
||||
return 'Metrics';
|
||||
}
|
||||
|
||||
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
|
||||
// Filter out recording rules and insert as first option
|
||||
const ruleRegex = /:\w+:/;
|
||||
@ -36,8 +50,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
|
||||
const options = ruleNames.length > 0 ? [rulesOption] : [];
|
||||
|
||||
const metricsOptions = _.chain(metrics)
|
||||
.filter(metric => !ruleRegex.test(metric))
|
||||
.groupBy(metric => metric.split(delimiter)[0])
|
||||
.filter((metric: string) => !ruleRegex.test(metric))
|
||||
.groupBy((metric: string) => metric.split(delimiter)[0])
|
||||
.map(
|
||||
(metricsForPrefix: string[], prefix: string): CascaderOption => {
|
||||
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
|
||||
@ -103,7 +117,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
languageProvider: any;
|
||||
languageProviderInitializationPromise: CancelablePromise<any>;
|
||||
|
||||
constructor(props: PromQueryFieldProps, context) {
|
||||
constructor(props: PromQueryFieldProps, context: React.Context<any>) {
|
||||
super(props, context);
|
||||
|
||||
if (props.datasource.languageProvider) {
|
||||
@ -114,8 +128,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
BracesPlugin(),
|
||||
RunnerPlugin({ handler: props.onExecuteQuery }),
|
||||
PluginPrism({
|
||||
onlyIn: node => node.type === 'code_block',
|
||||
getSyntax: node => 'promql',
|
||||
onlyIn: (node: any) => node.type === 'code_block',
|
||||
getSyntax: (node: any) => 'promql',
|
||||
}),
|
||||
];
|
||||
|
||||
@ -127,17 +141,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
|
||||
componentDidMount() {
|
||||
if (this.languageProvider) {
|
||||
this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start());
|
||||
this.languageProviderInitializationPromise.promise
|
||||
.then(remaining => {
|
||||
remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
|
||||
})
|
||||
.then(() => this.onUpdateLanguage())
|
||||
.catch(({ isCanceled }) => {
|
||||
if (isCanceled) {
|
||||
console.warn('PromQueryField has unmounted, language provider intialization was canceled');
|
||||
}
|
||||
});
|
||||
this.refreshMetrics(makePromiseCancelable(this.languageProvider.start()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,6 +151,37 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: PromQueryFieldProps) {
|
||||
const reconnected =
|
||||
prevProps.datasourceStatus === DatasourceStatus.Disconnected &&
|
||||
this.props.datasourceStatus === DatasourceStatus.Connected;
|
||||
if (!reconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.languageProviderInitializationPromise) {
|
||||
this.languageProviderInitializationPromise.cancel();
|
||||
}
|
||||
|
||||
if (this.languageProvider) {
|
||||
this.refreshMetrics(makePromiseCancelable(this.languageProvider.fetchMetrics()));
|
||||
}
|
||||
}
|
||||
|
||||
refreshMetrics = (cancelablePromise: CancelablePromise<any>) => {
|
||||
this.languageProviderInitializationPromise = cancelablePromise;
|
||||
this.languageProviderInitializationPromise.promise
|
||||
.then(remaining => {
|
||||
remaining.map((task: Promise<any>) => task.then(this.onUpdateLanguage).catch(() => {}));
|
||||
})
|
||||
.then(() => this.onUpdateLanguage())
|
||||
.catch(({ isCanceled }) => {
|
||||
if (isCanceled) {
|
||||
console.warn('PromQueryField has unmounted, language provider intialization was canceled');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
|
||||
let query;
|
||||
if (selectedOptions.length === 1) {
|
||||
@ -202,7 +237,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
|
||||
// Build metrics tree
|
||||
const metricsByPrefix = groupMetricsByPrefix(metrics);
|
||||
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
|
||||
const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm }));
|
||||
const metricsOptions =
|
||||
histogramMetrics.length > 0
|
||||
? [
|
||||
@ -239,17 +274,18 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error, hint, query } = this.props;
|
||||
const { error, hint, query, datasourceStatus } = this.props;
|
||||
const { metricsOptions, syntaxLoaded } = this.state;
|
||||
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
|
||||
const chooserText = syntaxLoaded ? 'Metrics' : 'Loading metrics...';
|
||||
const chooserText = getChooserText(syntaxLoaded, datasourceStatus);
|
||||
const buttonDisabled = !syntaxLoaded || datasourceStatus === DatasourceStatus.Disconnected;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline gf-form-inline--nowrap">
|
||||
<div className="gf-form flex-shrink-0">
|
||||
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={buttonDisabled}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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%);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
|
||||
@ -9,18 +10,13 @@ export interface ThunkGiven {
|
||||
}
|
||||
|
||||
export interface ThunkWhen {
|
||||
whenThunkIsDispatched: (...args: any) => ThunkThen;
|
||||
whenThunkIsDispatched: (...args: any) => Promise<Array<ActionOf<any>>>;
|
||||
}
|
||||
|
||||
export interface ThunkThen {
|
||||
thenDispatchedActionsEqual: (actions: Array<ActionOf<any>>) => ThunkWhen;
|
||||
thenDispatchedActionsAreEqual: (callback: (actions: Array<ActionOf<any>>) => boolean) => ThunkWhen;
|
||||
thenThereAreNoDispatchedActions: () => ThunkWhen;
|
||||
}
|
||||
|
||||
export const thunkTester = (initialState: any): ThunkGiven => {
|
||||
export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => {
|
||||
const store = mockStore(initialState);
|
||||
let thunkUnderTest = null;
|
||||
let thunkUnderTest: any = null;
|
||||
let dispatchedActions: Array<ActionOf<any>> = [];
|
||||
|
||||
const givenThunk = (thunkFunction: any): ThunkWhen => {
|
||||
thunkUnderTest = thunkFunction;
|
||||
@ -28,36 +24,20 @@ export const thunkTester = (initialState: any): ThunkGiven => {
|
||||
return instance;
|
||||
};
|
||||
|
||||
function whenThunkIsDispatched(...args: any): ThunkThen {
|
||||
store.dispatch(thunkUnderTest(...arguments));
|
||||
const whenThunkIsDispatched = async (...args: any): Promise<Array<ActionOf<any>>> => {
|
||||
await store.dispatch(thunkUnderTest(...args));
|
||||
|
||||
return instance;
|
||||
}
|
||||
dispatchedActions = store.getActions();
|
||||
if (debug) {
|
||||
console.log('resultingActions:', dispatchedActions);
|
||||
}
|
||||
|
||||
const thenDispatchedActionsEqual = (actions: Array<ActionOf<any>>): ThunkWhen => {
|
||||
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([]);
|
||||
return dispatchedActions;
|
||||
};
|
||||
|
||||
const instance = {
|
||||
givenThunk,
|
||||
whenThunkIsDispatched,
|
||||
thenDispatchedActionsEqual,
|
||||
thenDispatchedActionsAreEqual,
|
||||
thenThereAreNoDispatchedActions,
|
||||
};
|
||||
|
||||
return instance;
|
||||
|
Loading…
Reference in New Issue
Block a user