Explore: Use rich history local storage for autocomplete (#81386)

* move autocomplete logic

* Tests

* Write to correct history

* Remove historyUpdatedAction and related code

* add helpful comments

* Add option to mute all errors/warnings for autocomplete

* Add back in legacy local storage query history for transition period

* Move params to an object for easier use and defaults

* Do not make time filter required

* fix tests

* change deprecation version and add issue number
This commit is contained in:
Kristina 2024-02-23 09:44:21 -06:00 committed by GitHub
parent ea8b3267e5
commit bbe9c8661a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 295 additions and 155 deletions

View File

@ -37,10 +37,16 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
const allQueries = getRichHistoryDTOs().map(fromDTO); const allQueries = getRichHistoryDTOs().map(fromDTO);
const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries; const queries = filters.starred ? allQueries.filter((q) => q.starred === true) : allQueries;
const richHistory = filterAndSortQueries(queries, filters.sortOrder, filters.datasourceFilters, filters.search, [ const timeFilter: [number, number] | undefined =
filters.from, filters.from && filters.to ? [filters.from, filters.to] : undefined;
filters.to,
]); const richHistory = filterAndSortQueries(
queries,
filters.sortOrder,
filters.datasourceFilters,
filters.search,
timeFilter
);
return { richHistory, total: richHistory.length }; return { richHistory, total: richHistory.length };
} }

View File

@ -10,10 +10,16 @@ import RichHistoryStorage from './RichHistoryStorage';
const richHistoryLocalStorage = new RichHistoryLocalStorage(); const richHistoryLocalStorage = new RichHistoryLocalStorage();
const richHistoryRemoteStorage = new RichHistoryRemoteStorage(); const richHistoryRemoteStorage = new RichHistoryRemoteStorage();
// for query history operations
export const getRichHistoryStorage = (): RichHistoryStorage => { export const getRichHistoryStorage = (): RichHistoryStorage => {
return config.queryHistoryEnabled ? richHistoryRemoteStorage : richHistoryLocalStorage; return config.queryHistoryEnabled ? richHistoryRemoteStorage : richHistoryLocalStorage;
}; };
// for autocomplete read and write operations
export const getLocalRichHistoryStorage = (): RichHistoryStorage => {
return richHistoryLocalStorage;
};
interface RichHistorySupportedFeatures { interface RichHistorySupportedFeatures {
availableFilters: SortOrder[]; availableFilters: SortOrder[];
lastUsedDataSourcesAvailable: boolean; lastUsedDataSourcesAvailable: boolean;

View File

@ -2,7 +2,6 @@ import { DataSourceApi, dateTime, ExploreUrlState, LogsSortOrder } from '@grafan
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { RefreshPicker } from '@grafana/ui'; import { RefreshPicker } from '@grafana/ui';
import store from 'app/core/store';
import { DEFAULT_RANGE } from 'app/features/explore/state/utils'; import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv'; import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv';
@ -11,7 +10,6 @@ import {
buildQueryTransaction, buildQueryTransaction,
hasNonEmptyQuery, hasNonEmptyQuery,
refreshIntervalToSortOrder, refreshIntervalToSortOrder,
updateHistory,
getExploreUrl, getExploreUrl,
GetExploreUrlArguments, GetExploreUrlArguments,
getTimeRange, getTimeRange,
@ -151,27 +149,6 @@ describe('getExploreUrl', () => {
}); });
}); });
describe('updateHistory()', () => {
const datasourceId = 'myDatasource';
const key = `grafana.explore.history.${datasourceId}`;
beforeEach(() => {
store.delete(key);
expect(store.exists(key)).toBeFalsy();
});
test('should save history item to localStorage', () => {
const expected = [
{
query: { refId: '1', expr: 'metric' },
},
];
expect(updateHistory([], datasourceId, [{ refId: '1', expr: 'metric' }])).toMatchObject(expected);
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)).toMatchObject(expected);
});
});
describe('hasNonEmptyQuery', () => { describe('hasNonEmptyQuery', () => {
test('should return true if one query is non-empty', () => { test('should return true if one query is non-empty', () => {
expect(hasNonEmptyQuery([{ refId: '1', key: '2', context: 'explore', expr: 'foo' }])).toBeTruthy(); expect(hasNonEmptyQuery([{ refId: '1', key: '2', context: 'explore', expr: 'foo' }])).toBeTruthy();

View File

@ -10,7 +10,6 @@ import {
DataSourceApi, DataSourceApi,
DataSourceRef, DataSourceRef,
DefaultTimeZone, DefaultTimeZone,
HistoryItem,
IntervalValues, IntervalValues,
LogsDedupStrategy, LogsDedupStrategy,
LogsSortOrder, LogsSortOrder,
@ -37,8 +36,6 @@ export const DEFAULT_UI_STATE = {
export const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; export const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(ID_ALPHABET, 3); const nanoid = customAlphabet(ID_ALPHABET, 3);
const MAX_HISTORY_ITEMS = 100;
const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`; const lastUsedDatasourceKeyForOrgId = (orgId: number) => `${LAST_USED_DATASOURCE_KEY}.${orgId}`;
export const getLastUsedDatasourceUID = (orgId: number) => export const getLastUsedDatasourceUID = (orgId: number) =>
@ -276,35 +273,6 @@ export function hasNonEmptyQuery<TQuery extends DataQuery>(queries: TQuery[]): b
); );
} }
/**
* Update the query history. Side-effect: store history in local storage
*/
export function updateHistory<T extends DataQuery>(
history: Array<HistoryItem<T>>,
datasourceId: string,
queries: T[]
): Array<HistoryItem<T>> {
const ts = Date.now();
let updatedHistory = history;
queries.forEach((query) => {
updatedHistory = [{ query, ts }, ...updatedHistory];
});
if (updatedHistory.length > MAX_HISTORY_ITEMS) {
updatedHistory = updatedHistory.slice(0, MAX_HISTORY_ITEMS);
}
// Combine all queries of a datasource type into one history
const historyKey = `grafana.explore.history.${datasourceId}`;
try {
store.setObject(historyKey, updatedHistory);
return updatedHistory;
} catch (error) {
console.error(error);
return history;
}
}
export const getQueryKeys = (queries: DataQuery[]): string[] => { export const getQueryKeys = (queries: DataQuery[]): string[] => {
const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => { const queryKeys = queries.reduce<string[]>((newQueryKeys, query, index) => {
const primaryKey = query.datasource?.uid || query.key; const primaryKey = query.datasource?.uid || query.key;

View File

@ -109,15 +109,13 @@ describe('richHistory', () => {
it('should append query to query history', async () => { it('should append query to query history', async () => {
Date.now = jest.fn(() => 2); Date.now = jest.fn(() => 2);
const { limitExceeded, richHistoryStorageFull } = await addToRichHistory( const { limitExceeded, richHistoryStorageFull } = await addToRichHistory({
mock.testDatasourceUid, localOverride: false,
mock.testDatasourceName, datasource: { uid: mock.testDatasourceUid, name: mock.testDatasourceName },
mock.testQueries, queries: mock.testQueries,
mock.testStarred, starred: mock.testStarred,
mock.testComment, comment: mock.testComment,
true, });
true
);
expect(limitExceeded).toBeFalsy(); expect(limitExceeded).toBeFalsy();
expect(richHistoryStorageFull).toBeFalsy(); expect(richHistoryStorageFull).toBeFalsy();
expect(richHistoryStorageMock.addToRichHistory).toBeCalledWith({ expect(richHistoryStorageMock.addToRichHistory).toBeCalledWith({
@ -142,15 +140,13 @@ describe('richHistory', () => {
}); });
}); });
const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( const { richHistoryStorageFull, limitExceeded } = await addToRichHistory({
mock.testDatasourceUid, localOverride: false,
mock.testDatasourceName, datasource: { uid: mock.testDatasourceUid, name: mock.testDatasourceName },
mock.testQueries, queries: mock.testQueries,
mock.testStarred, starred: mock.testStarred,
mock.testComment, comment: mock.testComment,
true, });
true
);
expect(richHistoryStorageFull).toBeFalsy(); expect(richHistoryStorageFull).toBeFalsy();
expect(limitExceeded).toBeTruthy(); expect(limitExceeded).toBeTruthy();
}); });

View File

@ -15,7 +15,7 @@ import {
RichHistoryStorageWarning, RichHistoryStorageWarning,
RichHistoryStorageWarningDetails, RichHistoryStorageWarningDetails,
} from '../history/RichHistoryStorage'; } from '../history/RichHistoryStorage';
import { getRichHistoryStorage } from '../history/richHistoryStorageProvider'; import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from './richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from './richHistoryTypes';
@ -26,15 +26,27 @@ export { RichHistorySearchFilters, RichHistorySettings, SortOrder };
* Side-effect: store history in local storage * Side-effect: store history in local storage
*/ */
type addToRichHistoryParams = {
localOverride: boolean;
datasource: { uid: string; name?: string };
queries: DataQuery[];
starred: boolean;
comment?: string;
showNotif?: {
quotaExceededError?: boolean;
limitExceededWarning?: boolean;
otherErrors?: boolean;
};
};
export async function addToRichHistory( export async function addToRichHistory(
datasourceUid: string, params: addToRichHistoryParams
datasourceName: string | null,
queries: DataQuery[],
starred: boolean,
comment: string | null,
showQuotaExceededError: boolean,
showLimitExceededWarning: boolean
): Promise<{ richHistoryStorageFull?: boolean; limitExceeded?: boolean }> { ): Promise<{ richHistoryStorageFull?: boolean; limitExceeded?: boolean }> {
const { queries, localOverride, datasource, starred, comment, showNotif } = params;
// default showing of errors to true
const showQuotaExceededError = showNotif?.quotaExceededError ?? true;
const showLimitExceededWarning = showNotif?.limitExceededWarning ?? true;
const showOtherErrors = showNotif?.otherErrors ?? true;
/* Save only queries, that are not falsy (e.g. empty object, null, ...) */ /* Save only queries, that are not falsy (e.g. empty object, null, ...) */
const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query)); const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query));
@ -44,9 +56,11 @@ export async function addToRichHistory(
let warning: RichHistoryStorageWarningDetails | undefined; let warning: RichHistoryStorageWarningDetails | undefined;
try { try {
const result = await getRichHistoryStorage().addToRichHistory({ // for autocomplete we want to ensure writing to local storage
datasourceUid: datasourceUid, const storage = localOverride ? getLocalRichHistoryStorage() : getRichHistoryStorage();
datasourceName: datasourceName ?? '', const result = await storage.addToRichHistory({
datasourceUid: datasource.uid,
datasourceName: datasource.name ?? '',
queries: newQueriesToSave, queries: newQueriesToSave,
starred, starred,
comment: comment ?? '', comment: comment ?? '',
@ -57,7 +71,7 @@ export async function addToRichHistory(
if (error.name === RichHistoryServiceError.StorageFull) { if (error.name === RichHistoryServiceError.StorageFull) {
richHistoryStorageFull = true; richHistoryStorageFull = true;
showQuotaExceededError && dispatch(notifyApp(createErrorNotification(error.message))); showQuotaExceededError && dispatch(notifyApp(createErrorNotification(error.message)));
} else if (error.name !== RichHistoryServiceError.DuplicatedEntry) { } else if (showOtherErrors && error.name !== RichHistoryServiceError.DuplicatedEntry) {
dispatch( dispatch(
notifyApp( notifyApp(
createErrorNotification( createErrorNotification(

View File

@ -23,8 +23,8 @@ export type RichHistorySearchFilters = {
sortOrder: SortOrder; sortOrder: SortOrder;
/** Names of data sources (not uids) - used by local and remote storage **/ /** Names of data sources (not uids) - used by local and remote storage **/
datasourceFilters: string[]; datasourceFilters: string[];
from: number; from?: number;
to: number; to?: number;
starred: boolean; starred: boolean;
page?: number; page?: number;
}; };

View File

@ -166,6 +166,10 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) {
const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder); const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder);
const sortOrderOptions = getSortOrderOptions(); const sortOrderOptions = getSortOrderOptions();
const partialResults = queries.length && queries.length !== totalQueries; const partialResults = queries.length && queries.length !== totalQueries;
const timeFilter = [
richHistorySearchFilters.from || 0,
richHistorySearchFilters.to || richHistorySettings.retentionPeriod,
];
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -174,13 +178,13 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) {
<div className={styles.labelSlider}> <div className={styles.labelSlider}>
<Trans i18nKey="explore.rich-history-queries-tab.filter-history">Filter history</Trans> <Trans i18nKey="explore.rich-history-queries-tab.filter-history">Filter history</Trans>
</div> </div>
<div className={styles.labelSlider}>{mapNumbertoTimeInSlider(richHistorySearchFilters.from)}</div> <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(timeFilter[0])}</div>
<div className={styles.slider}> <div className={styles.slider}>
<RangeSlider <RangeSlider
tooltipAlwaysVisible={false} tooltipAlwaysVisible={false}
min={0} min={0}
max={richHistorySettings.retentionPeriod} max={richHistorySettings.retentionPeriod}
value={[richHistorySearchFilters.from, richHistorySearchFilters.to]} value={timeFilter}
orientation="vertical" orientation="vertical"
formatTooltipResult={mapNumbertoTimeInSlider} formatTooltipResult={mapNumbertoTimeInSlider}
reverse={true} reverse={true}
@ -189,7 +193,7 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) {
}} }}
/> />
</div> </div>
<div className={styles.labelSlider}>{mapNumbertoTimeInSlider(richHistorySearchFilters.to)}</div> <div className={styles.labelSlider}>{mapNumbertoTimeInSlider(timeFilter[1])}</div>
</div> </div>
</div> </div>

View File

@ -20,7 +20,6 @@ import { createAsyncThunk, ThunkResult } from 'app/types';
import { ExploreItemState } from 'app/types/explore'; import { ExploreItemState } from 'app/types/explore';
import { datasourceReducer } from './datasource'; import { datasourceReducer } from './datasource';
import { historyReducer } from './history';
import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction } from './main'; import { richHistorySearchFiltersUpdatedAction, richHistoryUpdatedAction } from './main';
import { queryReducer, runQueries } from './query'; import { queryReducer, runQueries } from './query';
import { timeReducer, updateTime } from './time'; import { timeReducer, updateTime } from './time';
@ -214,7 +213,6 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
state = queryReducer(state, action); state = queryReducer(state, action);
state = datasourceReducer(state, action); state = datasourceReducer(state, action);
state = timeReducer(state, action); state = timeReducer(state, action);
state = historyReducer(state, action);
if (richHistoryUpdatedAction.match(action)) { if (richHistoryUpdatedAction.match(action)) {
const { richHistory, total } = action.payload.richHistoryResults; const { richHistory, total } = action.payload.richHistoryResults;

View File

@ -1,6 +1,3 @@
import { AnyAction, createAction } from '@reduxjs/toolkit';
import { HistoryItem } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { import {
addToRichHistory, addToRichHistory,
@ -26,16 +23,6 @@ import {
} from './main'; } from './main';
import { selectPanesEntries } from './selectors'; import { selectPanesEntries } from './selectors';
//
// Actions and Payloads
//
export interface HistoryUpdatedPayload {
exploreId: string;
history: HistoryItem[];
}
export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore/historyUpdated');
// //
// Action creators // Action creators
// //
@ -74,25 +61,33 @@ const forEachExplorePane = (state: ExploreState, callback: (item: ExploreItemSta
}; };
export const addHistoryItem = ( export const addHistoryItem = (
localOverride: boolean,
datasourceUid: string, datasourceUid: string,
datasourceName: string, datasourceName: string,
queries: DataQuery[] queries: DataQuery[],
hideAllErrorsAndWarnings: boolean
): ThunkResult<void> => { ): ThunkResult<void> => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { richHistoryStorageFull, limitExceeded } = await addToRichHistory( const showNotif = hideAllErrorsAndWarnings
datasourceUid, ? { quotaExceededError: false, limitExceededWarning: false, otherErrors: false }
datasourceName, : {
quotaExceededError: !getState().explore.richHistoryStorageFull,
limitExceededWarning: !getState().explore.richHistoryLimitExceededWarningShown,
};
const { richHistoryStorageFull, limitExceeded } = await addToRichHistory({
localOverride,
datasource: { uid: datasourceUid, name: datasourceName },
queries, queries,
false, starred: false,
'', showNotif,
!getState().explore.richHistoryStorageFull, });
!getState().explore.richHistoryLimitExceededWarningShown if (!hideAllErrorsAndWarnings) {
); if (richHistoryStorageFull) {
if (richHistoryStorageFull) { dispatch(richHistoryStorageFullAction());
dispatch(richHistoryStorageFullAction()); }
} if (limitExceeded) {
if (limitExceeded) { dispatch(richHistoryLimitExceededAction());
dispatch(richHistoryLimitExceededAction()); }
} }
}; };
}; };
@ -199,13 +194,3 @@ export const updateHistorySearchFilters = (exploreId: string, filters: RichHisto
} }
}; };
}; };
export const historyReducer = (state: ExploreItemState, action: AnyAction): ExploreItemState => {
if (historyUpdatedAction.match(action)) {
return {
...state,
history: action.payload.history,
};
}
return state;
};

View File

@ -16,10 +16,12 @@ import {
SupplementaryQueryType, SupplementaryQueryType,
} from '@grafana/data'; } from '@grafana/data';
import { DataQuery, DataSourceRef } from '@grafana/schema'; import { DataQuery, DataSourceRef } from '@grafana/schema';
import config from 'app/core/config';
import { queryLogsSample, queryLogsVolume } from 'app/features/logs/logsModel'; import { queryLogsSample, queryLogsVolume } from 'app/features/logs/logsModel';
import { createAsyncThunk, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; import { createAsyncThunk, ExploreItemState, StoreState, ThunkDispatch } from 'app/types';
import { reducerTester } from '../../../../test/core/redux/reducerTester'; import { reducerTester } from '../../../../test/core/redux/reducerTester';
import * as richHistory from '../../../core/utils/richHistory';
import { configureStore } from '../../../store/configureStore'; import { configureStore } from '../../../store/configureStore';
import { setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv'; import { setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { makeLogs } from '../__mocks__/makeLogs'; import { makeLogs } from '../__mocks__/makeLogs';
@ -155,6 +157,11 @@ describe('runQueries', () => {
} as unknown as Partial<StoreState>); } as unknown as Partial<StoreState>);
}; };
beforeEach(() => {
config.queryHistoryEnabled = false;
jest.clearAllMocks();
});
it('should pass dataFrames to state even if there is error in response', async () => { it('should pass dataFrames to state even if there is error in response', async () => {
const { dispatch, getState } = setupTests(); const { dispatch, getState } = setupTests();
setupQueryResponse(getState()); setupQueryResponse(getState());
@ -202,6 +209,24 @@ describe('runQueries', () => {
await dispatch(saveCorrelationsAction({ exploreId: 'left', correlations: [] })); await dispatch(saveCorrelationsAction({ exploreId: 'left', correlations: [] }));
expect(getState().explore.panes.left!.graphResult).toBeDefined(); expect(getState().explore.panes.left!.graphResult).toBeDefined();
}); });
it('should add history items to both local and remote storage with the flag enabled', async () => {
config.queryHistoryEnabled = true;
const { dispatch } = setupTests();
jest.spyOn(richHistory, 'addToRichHistory');
await dispatch(runQueries({ exploreId: 'left' }));
expect((richHistory.addToRichHistory as jest.Mock).mock.calls).toHaveLength(2);
expect((richHistory.addToRichHistory as jest.Mock).mock.calls[0][0].localOverride).toBeTruthy();
expect((richHistory.addToRichHistory as jest.Mock).mock.calls[1][0].localOverride).toBeFalsy();
});
it('should add history items to local storage only with the flag disabled', async () => {
const { dispatch } = setupTests();
jest.spyOn(richHistory, 'addToRichHistory');
await dispatch(runQueries({ exploreId: 'left' }));
expect((richHistory.addToRichHistory as jest.Mock).mock.calls).toHaveLength(1);
expect((richHistory.addToRichHistory as jest.Mock).mock.calls[0][0].localOverride).toBeTruthy();
});
}); });
describe('running queries', () => { describe('running queries', () => {

View File

@ -13,7 +13,6 @@ import {
dateTimeForTimeZone, dateTimeForTimeZone,
hasQueryExportSupport, hasQueryExportSupport,
hasQueryImportSupport, hasQueryImportSupport,
HistoryItem,
LoadingState, LoadingState,
LogsVolumeType, LogsVolumeType,
PanelEvents, PanelEvents,
@ -35,7 +34,6 @@ import {
getTimeRange, getTimeRange,
hasNonEmptyQuery, hasNonEmptyQuery,
stopQueryState, stopQueryState,
updateHistory,
} from 'app/core/utils/explore'; } from 'app/core/utils/explore';
import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { getCorrelationsBySourceUIDs } from 'app/features/correlations/utils'; import { getCorrelationsBySourceUIDs } from 'app/features/correlations/utils';
@ -67,7 +65,7 @@ import {
import { getCorrelations } from './correlations'; import { getCorrelations } from './correlations';
import { saveCorrelationsAction } from './explorePane'; import { saveCorrelationsAction } from './explorePane';
import { addHistoryItem, historyUpdatedAction, loadRichHistory } from './history'; import { addHistoryItem, loadRichHistory } from './history';
import { changeCorrelationEditorDetails } from './main'; import { changeCorrelationEditorDetails } from './main';
import { updateTime } from './time'; import { updateTime } from './time';
import { import {
@ -481,16 +479,18 @@ export function modifyQueries(
async function handleHistory( async function handleHistory(
dispatch: ThunkDispatch, dispatch: ThunkDispatch,
state: ExploreState, state: ExploreState,
history: Array<HistoryItem<DataQuery>>,
datasource: DataSourceApi, datasource: DataSourceApi,
queries: DataQuery[], queries: DataQuery[]
exploreId: string
) { ) {
const datasourceId = datasource.meta.id; /*
const nextHistory = updateHistory(history, datasourceId, queries); Always write to local storage. If query history is enabled, we will use local storage for autocomplete only (and want to hide errors)
dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); If query history is disabled, we will use local storage for query history as well, and will want to show errors
*/
dispatch(addHistoryItem(datasource.uid, datasource.name, queries)); dispatch(addHistoryItem(true, datasource.uid, datasource.name, queries, config.queryHistoryEnabled));
if (config.queryHistoryEnabled) {
// write to remote if flag enabled
dispatch(addHistoryItem(false, datasource.uid, datasource.name, queries, false));
}
// Because filtering happens in the backend we cannot add a new entry without checking if it matches currently // Because filtering happens in the backend we cannot add a new entry without checking if it matches currently
// used filters. Instead, we refresh the query history list. // used filters. Instead, we refresh the query history list.
@ -550,7 +550,7 @@ export const runQueries = createAsyncThunk<void, RunQueriesOptions>(
})); }));
if (datasourceInstance != null) { if (datasourceInstance != null) {
handleHistory(dispatch, getState().explore, exploreItemState.history, datasourceInstance, queries, exploreId); handleHistory(dispatch, getState().explore, datasourceInstance, queries);
} }
const cachedValue = getResultsFromCache(cache, absoluteRange); const cachedValue = getResultsFromCache(cache, absoluteRange);

View File

@ -1,6 +1,9 @@
import { dateTime } from '@grafana/data'; import { dateTime } from '@grafana/data';
import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider';
import * as exploreUtils from 'app/core/utils/explore'; import * as exploreUtils from 'app/core/utils/explore';
import { loadAndInitDatasource, getRange, fromURLRange, MAX_HISTORY_AUTOCOMPLETE_ITEMS } from './utils';
const dataSourceMock = { const dataSourceMock = {
get: jest.fn(), get: jest.fn(),
}; };
@ -8,7 +11,15 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: jest.fn(() => dataSourceMock), getDatasourceSrv: jest.fn(() => dataSourceMock),
})); }));
import { loadAndInitDatasource, getRange, fromURLRange } from './utils'; const mockLocalDataStorage = {
getRichHistory: jest.fn(),
};
jest.mock('app/core/history/richHistoryStorageProvider', () => ({
getLocalRichHistoryStorage: jest.fn(() => {
return mockLocalDataStorage;
}),
}));
const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' }; const DEFAULT_DATASOURCE = { uid: 'abc123', name: 'Default' };
const TEST_DATASOURCE = { uid: 'def789', name: 'Test' }; const TEST_DATASOURCE = { uid: 'def789', name: 'Test' };
@ -28,6 +39,7 @@ describe('loadAndInitDatasource', () => {
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockRejectedValueOnce(new Error('Datasource not found')); dataSourceMock.get.mockRejectedValueOnce(new Error('Datasource not found'));
dataSourceMock.get.mockResolvedValue(DEFAULT_DATASOURCE); dataSourceMock.get.mockResolvedValue(DEFAULT_DATASOURCE);
mockLocalDataStorage.getRichHistory.mockResolvedValue({ total: 0, richHistory: [] });
const { instance } = await loadAndInitDatasource(1, { uid: 'Unknown' }); const { instance } = await loadAndInitDatasource(1, { uid: 'Unknown' });
@ -41,14 +53,111 @@ describe('loadAndInitDatasource', () => {
it('saves last loaded data source uid', async () => { it('saves last loaded data source uid', async () => {
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID'); setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE); dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
mockLocalDataStorage.getRichHistory.mockResolvedValue({
total: 0,
richHistory: [],
});
const { instance } = await loadAndInitDatasource(1, { uid: 'Test' }); const { instance } = await loadAndInitDatasource(1, { uid: 'Test' });
expect(dataSourceMock.get).toBeCalledTimes(1); expect(dataSourceMock.get).toHaveBeenCalledTimes(1);
expect(dataSourceMock.get).toBeCalledWith({ uid: 'Test' }); expect(dataSourceMock.get).toHaveBeenCalledWith({ uid: 'Test' });
expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1);
expect(instance).toMatchObject(TEST_DATASOURCE); expect(instance).toMatchObject(TEST_DATASOURCE);
expect(setLastUsedDatasourceUIDSpy).toBeCalledWith(1, TEST_DATASOURCE.uid); expect(setLastUsedDatasourceUIDSpy).toBeCalledWith(1, TEST_DATASOURCE.uid);
}); });
it('pulls history data and returns the history by query', async () => {
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({
total: 1,
richHistory: [
{
id: '0',
createdAt: 0,
datasourceUid: 'Test',
datasourceName: 'Test',
starred: false,
comment: '',
queries: [{ refId: 'A' }, { refId: 'B' }],
},
],
});
const { history } = await loadAndInitDatasource(1, { uid: 'Test' });
expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1);
expect(history.length).toEqual(2);
});
it('pulls history data and returns the history by query with Mixed results', async () => {
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({
total: 1,
richHistory: [
{
id: '0',
createdAt: 0,
datasourceUid: 'Test',
datasourceName: 'Test',
starred: false,
comment: '',
queries: [{ refId: 'A' }, { refId: 'B' }],
},
],
});
mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({
total: 1,
richHistory: [
{
id: '0',
createdAt: 0,
datasourceUid: 'Mixed',
datasourceName: 'Mixed',
starred: false,
comment: '',
queries: [
{ refId: 'A', datasource: { uid: 'def789' } },
{ refId: 'B', datasource: { uid: 'def789' } },
],
},
],
});
const { history } = await loadAndInitDatasource(1, { uid: 'Test' });
expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1);
expect(history.length).toEqual(4);
});
it('pulls history data and returns only a max of MAX_HISTORY_AUTOCOMPLETE_ITEMS items', async () => {
const queryList = [...Array(MAX_HISTORY_AUTOCOMPLETE_ITEMS + 50).keys()].map((i) => {
return { refId: `ref-${i}` };
});
setLastUsedDatasourceUIDSpy = jest.spyOn(exploreUtils, 'setLastUsedDatasourceUID');
dataSourceMock.get.mockResolvedValue(TEST_DATASOURCE);
mockLocalDataStorage.getRichHistory.mockResolvedValueOnce({
total: 1,
richHistory: [
{
id: '0',
createdAt: 0,
datasourceUid: 'Test',
datasourceName: 'Test',
starred: false,
comment: '',
queries: queryList,
},
],
});
const { history } = await loadAndInitDatasource(1, { uid: 'Test' });
expect(getLocalRichHistoryStorage).toHaveBeenCalledTimes(1);
expect(history.length).toEqual(MAX_HISTORY_AUTOCOMPLETE_ITEMS);
});
}); });
describe('getRange', () => { describe('getRange', () => {

View File

@ -21,16 +21,20 @@ import {
URLRangeValue, URLRangeValue,
} from '@grafana/data'; } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery, DataSourceRef, TimeZone } from '@grafana/schema'; import { DataQuery, DataSourceJsonData, DataSourceRef, TimeZone } from '@grafana/schema';
import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider';
import { SortOrder } from 'app/core/utils/richHistory';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ExplorePanelData, StoreState } from 'app/types'; import { ExplorePanelData, StoreState } from 'app/types';
import { ExploreItemState } from 'app/types/explore'; import { ExploreItemState, RichHistoryQuery } from 'app/types/explore';
import store from '../../../core/store'; import store from '../../../core/store';
import { setLastUsedDatasourceUID } from '../../../core/utils/explore'; import { setLastUsedDatasourceUID } from '../../../core/utils/explore';
import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { loadSupplementaryQueries } from '../utils/supplementaryQueries'; import { loadSupplementaryQueries } from '../utils/supplementaryQueries';
export const MAX_HISTORY_AUTOCOMPLETE_ITEMS = 100;
export const DEFAULT_RANGE = { export const DEFAULT_RANGE = {
from: 'now-1h', from: 'now-1h',
to: 'now', to: 'now',
@ -100,7 +104,7 @@ export async function loadAndInitDatasource(
orgId: number, orgId: number,
datasource: DataSourceRef | string datasource: DataSourceRef | string
): Promise<{ history: HistoryItem[]; instance: DataSourceApi }> { ): Promise<{ history: HistoryItem[]; instance: DataSourceApi }> {
let instance; let instance: DataSourceApi<DataQuery, DataSourceJsonData, {}>;
try { try {
// let datasource be a ref if we have the info, otherwise a name or uid will do for lookup // let datasource be a ref if we have the info, otherwise a name or uid will do for lookup
instance = await getDatasourceSrv().get(datasource); instance = await getDatasourceSrv().get(datasource);
@ -119,12 +123,60 @@ export async function loadAndInitDatasource(
} }
} }
const historyKey = `grafana.explore.history.${instance.meta?.id}`; let history: HistoryItem[] = [];
const history = store.getObject<HistoryItem[]>(historyKey, []);
// Save last-used datasource
const localStorageHistory = getLocalRichHistoryStorage();
const historyResults = await localStorageHistory.getRichHistory({
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [instance.name],
starred: false,
});
// first, fill autocomplete with query history for that datasource
if ((historyResults.total || 0) > 0) {
historyResults.richHistory.forEach((historyResult: RichHistoryQuery) => {
historyResult.queries.forEach((q) => {
history.push({ ts: parseInt(historyResult.id, 10), query: q });
});
});
}
if (history.length < MAX_HISTORY_AUTOCOMPLETE_ITEMS) {
// check the last 100 mixed history results seperately
const historyMixedResults = await localStorageHistory.getRichHistory({
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [MIXED_DATASOURCE_NAME],
starred: false,
});
if ((historyMixedResults.total || 0) > 0) {
// second, fill autocomplete with queries for that datasource used in Mixed scenarios
historyMixedResults.richHistory.forEach((historyResult: RichHistoryQuery) => {
historyResult.queries.forEach((q) => {
if (q?.datasource?.uid === instance.uid) {
history.push({ ts: parseInt(historyResult.id, 10), query: q });
}
});
});
}
}
// finally, add any legacy local storage history that might exist. To be removed in Grafana 12 #83309
if (history.length < MAX_HISTORY_AUTOCOMPLETE_ITEMS) {
const historyKey = `grafana.explore.history.${instance.meta?.id}`;
history = [...history, ...store.getObject<HistoryItem[]>(historyKey, [])];
}
if (history.length > MAX_HISTORY_AUTOCOMPLETE_ITEMS) {
history.length = MAX_HISTORY_AUTOCOMPLETE_ITEMS;
}
// Save last-used datasource
setLastUsedDatasourceUID(orgId, instance.uid); setLastUsedDatasourceUID(orgId, instance.uid);
return { history, instance }; return { history: history, instance };
} }
export function createCacheKey(absRange: AbsoluteTimeRange) { export function createCacheKey(absRange: AbsoluteTimeRange) {