diff --git a/public/app/core/history/RichHistoryLocalStorage.test.ts b/public/app/core/history/RichHistoryLocalStorage.test.ts new file mode 100644 index 00000000000..00f20f48e09 --- /dev/null +++ b/public/app/core/history/RichHistoryLocalStorage.test.ts @@ -0,0 +1,204 @@ +import RichHistoryLocalStorage, { MAX_HISTORY_ITEMS } from './RichHistoryLocalStorage'; +import store from 'app/core/store'; +import { RichHistoryQuery } from '../../types'; +import { DataQuery } from '@grafana/data'; +import { afterEach, beforeEach } from '../../../test/lib/common'; +import { RichHistoryStorageWarning } from './RichHistoryStorage'; + +const key = 'grafana.explore.richHistory'; + +const mockItem: RichHistoryQuery = { + ts: 2, + starred: true, + datasourceName: 'dev-test', + datasourceId: 'test-id', + comment: 'test', + sessionName: 'session-name', + queries: [{ refId: 'ref', query: 'query-test' } as DataQuery], +}; + +const mockItem2: RichHistoryQuery = { + ts: 3, + starred: true, + datasourceName: 'dev-test-2', + datasourceId: 'test-id-2', + comment: 'test-2', + sessionName: 'session-name-2', + queries: [{ refId: 'ref-2', query: 'query-2' } as DataQuery], +}; + +describe('RichHistoryLocalStorage', () => { + let storage: RichHistoryLocalStorage; + + beforeEach(async () => { + storage = new RichHistoryLocalStorage(); + await storage.deleteAll(); + }); + + describe('basic api', () => { + let dateSpy: jest.SpyInstance; + + beforeEach(() => { + dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => 2); + }); + + afterEach(() => { + dateSpy.mockRestore(); + }); + + it('should save query history to localStorage', async () => { + await storage.addToRichHistory(mockItem); + expect(store.exists(key)).toBeTruthy(); + expect(store.getObject(key)).toMatchObject([mockItem]); + }); + + it('should not save duplicated query to localStorage', async () => { + await storage.addToRichHistory(mockItem); + await storage.addToRichHistory(mockItem2); + await expect(async () => { + await storage.addToRichHistory(mockItem2); + }).rejects.toThrow('Entry already exists'); + expect(store.getObject(key)).toMatchObject([mockItem2, mockItem]); + }); + + it('should update starred in localStorage', async () => { + await storage.addToRichHistory(mockItem); + await storage.updateStarred(mockItem.ts, false); + expect(store.getObject(key)[0].starred).toEqual(false); + }); + + it('should update comment in localStorage', async () => { + await storage.addToRichHistory(mockItem); + await storage.updateComment(mockItem.ts, 'new comment'); + expect(store.getObject(key)[0].comment).toEqual('new comment'); + }); + + it('should delete query in localStorage', async () => { + await storage.addToRichHistory(mockItem); + await storage.deleteRichHistory(mockItem.ts); + expect(store.getObject(key)).toEqual([]); + }); + }); + + describe('retention policy and max limits', () => { + it('should clear old not-starred items', async () => { + const now = Date.now(); + const history = [ + { starred: true, ts: 0, queries: [] }, + { starred: true, ts: now, queries: [] }, + { starred: false, ts: 0, queries: [] }, + { starred: false, ts: now, queries: [] }, + ]; + store.setObject(key, history); + + await storage.addToRichHistory(mockItem); + const richHistory = await storage.getRichHistory(); + + expect(richHistory).toMatchObject([ + mockItem, + { starred: true, ts: 0, queries: [] }, + { starred: true, ts: now, queries: [] }, + { starred: false, ts: now, queries: [] }, + ]); + }); + + it('should not save more than MAX_HISTORY_ITEMS', async () => { + // For testing we create storage of MAX_HISTORY_ITEMS + extraItems. Half ot these items are starred. + const extraItems = 100; + + let history = []; + for (let i = 0; i < MAX_HISTORY_ITEMS + extraItems; i++) { + history.push({ + starred: i % 2 === 0, + comment: i.toString(), + queries: [], + ts: Date.now() + 10000, // to bypass retention policy + }); + } + + const starredItemsInHistory = (MAX_HISTORY_ITEMS + extraItems) / 2; + const notStarredItemsInHistory = (MAX_HISTORY_ITEMS + extraItems) / 2; + + expect(history.filter((h) => h.starred)).toHaveLength(starredItemsInHistory); + expect(history.filter((h) => !h.starred)).toHaveLength(notStarredItemsInHistory); + + store.setObject(key, history); + const warning = await storage.addToRichHistory(mockItem); + expect(warning).toMatchObject({ + type: RichHistoryStorageWarning.LimitExceeded, + }); + + // one not starred replaced with a newly added starred item + const removedNotStarredItems = extraItems + 1; // + 1 to make space for the new item + const newHistory = store.getObject(key); + expect(newHistory).toHaveLength(MAX_HISTORY_ITEMS); // starred item added + expect(newHistory.filter((h: RichHistoryQuery) => h.starred)).toHaveLength(starredItemsInHistory + 1); // starred item added + expect(newHistory.filter((h: RichHistoryQuery) => !h.starred)).toHaveLength( + starredItemsInHistory - removedNotStarredItems + ); + }); + }); + + describe('migration', () => { + afterEach(() => { + storage.deleteAll(); + expect(store.exists(key)).toBeFalsy(); + }); + + describe('should load from localStorage data in old formats', () => { + it('should load when queries are strings', async () => { + const oldHistoryItem = { + ...mockItem, + queries: ['test query 1', 'test query 2', 'test query 3'], + }; + store.setObject(key, [oldHistoryItem]); + const expectedHistoryItem = { + ...mockItem, + queries: [ + { + expr: 'test query 1', + refId: 'A', + }, + { + expr: 'test query 2', + refId: 'B', + }, + { + expr: 'test query 3', + refId: 'C', + }, + ], + }; + + const result = await storage.getRichHistory(); + expect(result).toStrictEqual([expectedHistoryItem]); + }); + + it('should load when queries are json-encoded strings', async () => { + const oldHistoryItem = { + ...mockItem, + queries: ['{"refId":"A","key":"key1","metrics":[]}', '{"refId":"B","key":"key2","metrics":[]}'], + }; + store.setObject(key, [oldHistoryItem]); + const expectedHistoryItem = { + ...mockItem, + queries: [ + { + refId: 'A', + key: 'key1', + metrics: [], + }, + { + refId: 'B', + key: 'key2', + metrics: [], + }, + ], + }; + + const result = await storage.getRichHistory(); + expect(result).toStrictEqual([expectedHistoryItem]); + }); + }); + }); +}); diff --git a/public/app/core/history/RichHistoryLocalStorage.ts b/public/app/core/history/RichHistoryLocalStorage.ts new file mode 100644 index 00000000000..d0b1c0b4ccd --- /dev/null +++ b/public/app/core/history/RichHistoryLocalStorage.ts @@ -0,0 +1,168 @@ +import RichHistoryStorage, { RichHistoryServiceError, RichHistoryStorageWarning } from './RichHistoryStorage'; +import { RichHistoryQuery } from '../../types'; +import store from '../store'; +import { DataQuery } from '@grafana/data'; +import { isEqual, omit } from 'lodash'; +import { createRetentionPeriodBoundary, RICH_HISTORY_SETTING_KEYS } from './richHistoryLocalStorageUtils'; + +export const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; +export const MAX_HISTORY_ITEMS = 10000; + +/** + * Local storage implementation for Rich History. It keeps all entries in browser's local storage. + */ +export default class RichHistoryLocalStorage implements RichHistoryStorage { + /** + * Return all history entries, perform migration and clean up entries not matching retention policy. + */ + async getRichHistory() { + const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); + const transformedRichHistory = migrateRichHistory(richHistory); + return transformedRichHistory; + } + + async addToRichHistory(richHistoryQuery: RichHistoryQuery) { + const richHistory = cleanUp(await this.getRichHistory()); + + /* Compare queries of a new query and last saved queries. If they are the same, (except selected properties, + * which can be different) don't save it in rich history. + */ + const newQueriesToCompare = richHistoryQuery.queries.map((q) => omit(q, ['key', 'refId'])); + const lastQueriesToCompare = + richHistory.length > 0 && + richHistory[0].queries.map((q) => { + return omit(q, ['key', 'refId']); + }); + + if (isEqual(newQueriesToCompare, lastQueriesToCompare)) { + const error = new Error('Entry already exists'); + error.name = RichHistoryServiceError.DuplicatedEntry; + throw error; + } + + const { queriesToKeep, limitExceeded } = checkLimits(richHistory); + + const updatedHistory: RichHistoryQuery[] = [richHistoryQuery, ...queriesToKeep]; + + try { + store.setObject(RICH_HISTORY_KEY, updatedHistory); + } catch (error) { + if (error.name === 'QuotaExceededError') { + throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`); + } else { + throw error; + } + } + + if (limitExceeded) { + return { + type: RichHistoryStorageWarning.LimitExceeded, + message: `Query history reached the limit of ${MAX_HISTORY_ITEMS}. Old, not-starred items have been removed.`, + }; + } + + return undefined; + } + + async deleteAll() { + store.delete(RICH_HISTORY_KEY); + } + + async deleteRichHistory(id: number) { + const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); + const updatedHistory = richHistory.filter((query) => query.ts !== id); + store.setObject(RICH_HISTORY_KEY, updatedHistory); + } + + async updateStarred(id: number, starred: boolean) { + const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); + const updatedHistory = richHistory.map((query) => { + if (query.ts === id) { + query.starred = starred; + } + return query; + }); + + store.setObject(RICH_HISTORY_KEY, updatedHistory); + } + + async updateComment(id: number, comment: string) { + const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); + const updatedHistory = richHistory.map((query) => { + if (query.ts === id) { + query.comment = comment; + } + return query; + }); + store.setObject(RICH_HISTORY_KEY, updatedHistory); + } +} + +/** + * Removes entries that do not match retention policy criteria. + */ +function cleanUp(richHistory: RichHistoryQuery[]): RichHistoryQuery[] { + const retentionPeriod: number = store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7); + const retentionPeriodLastTs = createRetentionPeriodBoundary(retentionPeriod, false); + + /* Keep only queries, that are within the selected retention period or that are starred. + * If no queries, initialize with empty array + */ + return richHistory.filter((q) => q.ts > retentionPeriodLastTs || q.starred === true) || []; +} + +/** + * Ensures the entry can be added. Throws an error if current limit has been hit. + * Returns queries that should be saved back giving space for one extra query. + */ +function checkLimits(queriesToKeep: RichHistoryQuery[]): { queriesToKeep: RichHistoryQuery[]; limitExceeded: boolean } { + // remove oldest non-starred items to give space for the recent query + let limitExceeded = false; + let current = queriesToKeep.length - 1; + while (current >= 0 && queriesToKeep.length >= MAX_HISTORY_ITEMS) { + if (!queriesToKeep[current].starred) { + queriesToKeep.splice(current, 1); + limitExceeded = true; + } + current--; + } + + return { queriesToKeep, limitExceeded }; +} + +function migrateRichHistory(richHistory: RichHistoryQuery[]) { + const transformedRichHistory = richHistory.map((query) => { + const transformedQueries: DataQuery[] = query.queries.map((q, index) => createDataQuery(query, q, index)); + return { ...query, queries: transformedQueries }; + }); + + return transformedRichHistory; +} + +function createDataQuery(query: RichHistoryQuery, individualQuery: DataQuery | string, index: number) { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVXYZ'; + if (typeof individualQuery === 'object') { + // the current format + return individualQuery; + } else if (isParsable(individualQuery)) { + // ElasticSearch (maybe other datasoures too) before grafana7 + return JSON.parse(individualQuery); + } + // prometehus (maybe other datasources too) before grafana7 + return { expr: individualQuery, refId: letters[index] }; +} + +function isParsable(string: string) { + try { + JSON.parse(string); + } catch (e) { + return false; + } + return true; +} + +function throwError(name: string, message: string) { + const error = new Error(message); + error.name = name; + throw error; +} diff --git a/public/app/core/history/RichHistoryStorage.ts b/public/app/core/history/RichHistoryStorage.ts new file mode 100644 index 00000000000..4254be0eee4 --- /dev/null +++ b/public/app/core/history/RichHistoryStorage.ts @@ -0,0 +1,40 @@ +import { RichHistoryQuery } from '../../types'; + +/** + * Errors are used when the operation on Rich History was not successful. + */ +export enum RichHistoryServiceError { + StorageFull = 'StorageFull', + DuplicatedEntry = 'DuplicatedEntry', +} + +/** + * Warnings are used when an entry has been added but there are some side effects that user should be informed about. + */ +export enum RichHistoryStorageWarning { + /** + * Returned when an entry was successfully added but maximum items limit has been reached and old entries have been removed. + */ + LimitExceeded = 'LimitExceeded', +} + +/** + * Detailed information about the warning that can be shown to the user + */ +export type RichHistoryStorageWarningDetails = { + type: RichHistoryStorageWarning; + message: string; +}; + +/** + * @internal + * @alpha + */ +export default interface RichHistoryStorage { + getRichHistory(): Promise; + addToRichHistory(richHistoryQuery: RichHistoryQuery): Promise; + deleteAll(): Promise; + deleteRichHistory(id: number): Promise; + updateStarred(id: number, starred: boolean): Promise; + updateComment(id: number, comment: string | undefined): Promise; +} diff --git a/public/app/core/history/richHistoryLocalStorageUtils.ts b/public/app/core/history/richHistoryLocalStorageUtils.ts new file mode 100644 index 00000000000..a7c9a1de442 --- /dev/null +++ b/public/app/core/history/richHistoryLocalStorageUtils.ts @@ -0,0 +1,80 @@ +import { RichHistoryQuery } from '../../types'; +import { omit } from 'lodash'; +import { SortOrder } from '../utils/richHistoryTypes'; + +/** + * Temporary place for local storage specific items that are still in use in richHistory.ts + * + * Should be migrated to RichHistoryLocalStorage.ts + */ + +export const createRetentionPeriodBoundary = (days: number, isLastTs: boolean) => { + const today = new Date(); + const date = new Date(today.setDate(today.getDate() - days)); + /* + * As a retention period boundaries, we consider: + * - The last timestamp equals to the 24:00 of the last day of retention + * - The first timestamp that equals to the 00:00 of the first day of retention + */ + const boundary = isLastTs ? date.setHours(24, 0, 0, 0) : date.setHours(0, 0, 0, 0); + return boundary; +}; + +export function filterQueriesByTime(queries: RichHistoryQuery[], timeFilter: [number, number]) { + const filter1 = createRetentionPeriodBoundary(timeFilter[0], true); // probably the vars should have a different name + const filter2 = createRetentionPeriodBoundary(timeFilter[1], false); + return queries.filter((q) => q.ts < filter1 && q.ts > filter2); +} + +export function filterQueriesByDataSource(queries: RichHistoryQuery[], listOfDatasourceFilters: string[]) { + return listOfDatasourceFilters.length > 0 + ? queries.filter((q) => listOfDatasourceFilters.includes(q.datasourceName)) + : queries; +} + +export function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchFilter: string) { + return queries.filter((query) => { + if (query.comment.includes(searchFilter)) { + return true; + } + + const listOfMatchingQueries = query.queries.filter((query) => + // Remove fields in which we don't want to be searching + Object.values(omit(query, ['datasource', 'key', 'refId', 'hide', 'queryType'])).some((value: any) => + value?.toString().includes(searchFilter) + ) + ); + + return listOfMatchingQueries.length > 0; + }); +} + +export const sortQueries = (array: RichHistoryQuery[], sortOrder: SortOrder) => { + let sortFunc; + + if (sortOrder === SortOrder.Ascending) { + sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0); + } + if (sortOrder === SortOrder.Descending) { + sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0); + } + + if (sortOrder === SortOrder.DatasourceZA) { + sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => + a.datasourceName < b.datasourceName ? -1 : a.datasourceName > b.datasourceName ? 1 : 0; + } + + if (sortOrder === SortOrder.DatasourceAZ) { + sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => + a.datasourceName < b.datasourceName ? 1 : a.datasourceName > b.datasourceName ? -1 : 0; + } + + return array.sort(sortFunc); +}; + +export const RICH_HISTORY_SETTING_KEYS = { + retentionPeriod: 'grafana.explore.richHistory.retentionPeriod', + starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab', + activeDatasourceOnly: 'grafana.explore.richHistory.activeDatasourceOnly', + datasourceFilters: 'grafana.explore.richHistory.datasourceFilters', +}; diff --git a/public/app/core/history/richHistoryStorageProvider.ts b/public/app/core/history/richHistoryStorageProvider.ts new file mode 100644 index 00000000000..fed05c0261d --- /dev/null +++ b/public/app/core/history/richHistoryStorageProvider.ts @@ -0,0 +1,8 @@ +import RichHistoryLocalStorage from './RichHistoryLocalStorage'; +import RichHistoryStorage from './RichHistoryStorage'; + +const richHistoryLocalStorage = new RichHistoryLocalStorage(); + +export const getRichHistoryStorage = (): RichHistoryStorage => { + return richHistoryLocalStorage; +}; diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index a34d82eeeb0..62036265d2d 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -1,6 +1,5 @@ import { addToRichHistory, - getRichHistory, updateStarredInRichHistory, updateCommentInRichHistory, mapNumbertoTimeInSlider, @@ -10,11 +9,18 @@ import { deleteQueryInRichHistory, filterAndSortQueries, SortOrder, - MAX_HISTORY_ITEMS, } from './richHistory'; import store from 'app/core/store'; import { dateTime, DataQuery } from '@grafana/data'; -import { RichHistoryQuery } from '../../types'; +import RichHistoryStorage, { RichHistoryServiceError, RichHistoryStorageWarning } from '../history/RichHistoryStorage'; + +const richHistoryStorageMock: RichHistoryStorage = {} as RichHistoryStorage; + +jest.mock('../history/richHistoryStorageProvider', () => { + return { + getRichHistoryStorage: () => richHistoryStorageMock, + }; +}); const mock: any = { storedHistory: [ @@ -48,6 +54,13 @@ describe('richHistory', () => { beforeEach(() => { jest.useFakeTimers('modern'); jest.setSystemTime(new Date(1970, 0, 1)); + + richHistoryStorageMock.addToRichHistory = jest.fn().mockResolvedValue(undefined); + richHistoryStorageMock.deleteAll = jest.fn().mockResolvedValue({}); + richHistoryStorageMock.deleteRichHistory = jest.fn().mockResolvedValue({}); + richHistoryStorageMock.getRichHistory = jest.fn().mockResolvedValue({}); + richHistoryStorageMock.updateComment = jest.fn().mockResolvedValue({}); + richHistoryStorageMock.updateStarred = jest.fn().mockResolvedValue({}); }); afterEach(() => { @@ -72,9 +85,9 @@ describe('richHistory', () => { mock.storedHistory[0], ]; - it('should append query to query history', () => { + it('should append query to query history', async () => { Date.now = jest.fn(() => 2); - const { richHistory: newHistory } = addToRichHistory( + const { richHistory: newHistory } = await addToRichHistory( mock.storedHistory, mock.testDatasourceId, mock.testDatasourceName, @@ -88,10 +101,10 @@ describe('richHistory', () => { expect(newHistory).toEqual(expectedResult); }); - it('should save query history to localStorage', () => { + it('should add query history to storage', async () => { Date.now = jest.fn(() => 2); - addToRichHistory( + const { richHistory } = await addToRichHistory( mock.storedHistory, mock.testDatasourceId, mock.testDatasourceName, @@ -102,29 +115,26 @@ describe('richHistory', () => { true, true ); - expect(store.exists(key)).toBeTruthy(); - expect(store.getObject(key)).toMatchObject(expectedResult); + expect(richHistory).toMatchObject(expectedResult); + expect(richHistoryStorageMock.addToRichHistory).toBeCalledWith({ + datasourceName: mock.testDatasourceName, + datasourceId: mock.testDatasourceId, + starred: mock.testStarred, + comment: mock.testComment, + sessionName: mock.testSessionName, + queries: mock.testQueries, + ts: 2, + }); }); - it('should not append duplicated query to query history', () => { + it('should not append duplicated query to query history', async () => { Date.now = jest.fn(() => 2); - const { richHistory: newHistory } = addToRichHistory( - mock.storedHistory, - mock.storedHistory[0].datasourceId, - mock.storedHistory[0].datasourceName, - [{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery], - mock.testStarred, - mock.testComment, - mock.testSessionName, - true, - true - ); - expect(newHistory).toEqual([mock.storedHistory[0]]); - }); - it('should not save duplicated query to localStorage', () => { - Date.now = jest.fn(() => 2); - addToRichHistory( + const duplicatedEntryError = new Error(); + duplicatedEntryError.name = RichHistoryServiceError.DuplicatedEntry; + richHistoryStorageMock.addToRichHistory = jest.fn().mockRejectedValue(duplicatedEntryError); + + const { richHistory: newHistory } = await addToRichHistory( mock.storedHistory, mock.storedHistory[0].datasourceId, mock.storedHistory[0].datasourceName, @@ -135,84 +145,52 @@ describe('richHistory', () => { true, true ); - expect(store.exists(key)).toBeFalsy(); + expect(newHistory).toEqual([mock.storedHistory[0]]); }); - it('should not save more than MAX_HISTORY_ITEMS', () => { + it('it should append new items even when the limit is exceeded', async () => { Date.now = jest.fn(() => 2); - const extraItems = 100; - // the history has more than MAX - let history = []; - // history = [ { starred: true, comment: "0" }, { starred: false, comment: "1" }, ... ] - for (let i = 0; i < MAX_HISTORY_ITEMS + extraItems; i++) { - history.push({ - starred: i % 2 === 0, - comment: i.toString(), - queries: [], - ts: new Date(2019, 11, 31).getTime(), - }); - } - - const starredItemsInHistory = (MAX_HISTORY_ITEMS + extraItems) / 2; - const notStarredItemsInHistory = (MAX_HISTORY_ITEMS + extraItems) / 2; - - expect(history.filter((h) => h.starred)).toHaveLength(starredItemsInHistory); - expect(history.filter((h) => !h.starred)).toHaveLength(notStarredItemsInHistory); + richHistoryStorageMock.addToRichHistory = jest.fn().mockReturnValue({ + type: RichHistoryStorageWarning.LimitExceeded, + message: 'Limit exceeded', + }); - const { richHistory: newHistory } = addToRichHistory( - history as any as RichHistoryQuery[], - mock.storedHistory[0].datasourceId, - mock.storedHistory[0].datasourceName, - [{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery], - true, + const { richHistory, limitExceeded } = await addToRichHistory( + mock.storedHistory, + mock.testDatasourceId, + mock.testDatasourceName, + mock.testQueries, + mock.testStarred, mock.testComment, mock.testSessionName, true, true ); - - // one not starred replaced with a newly added starred item - const removedNotStarredItems = extraItems + 1; // + 1 to make space for the new item - expect(newHistory.filter((h) => h.starred)).toHaveLength(starredItemsInHistory + 1); // starred item added - expect(newHistory.filter((h) => !h.starred)).toHaveLength(starredItemsInHistory - removedNotStarredItems); + expect(richHistory).toEqual(expectedResult); + expect(limitExceeded).toBeTruthy(); }); }); describe('updateStarredInRichHistory', () => { - it('should update starred in query in history', () => { - const updatedStarred = updateStarredInRichHistory(mock.storedHistory, 1); + it('should update starred in query in history', async () => { + const updatedStarred = await updateStarredInRichHistory(mock.storedHistory, 1); expect(updatedStarred[0].starred).toEqual(false); }); - it('should update starred in localStorage', () => { - updateStarredInRichHistory(mock.storedHistory, 1); - expect(store.exists(key)).toBeTruthy(); - expect(store.getObject(key)[0].starred).toEqual(false); - }); }); describe('updateCommentInRichHistory', () => { - it('should update comment in query in history', () => { - const updatedComment = updateCommentInRichHistory(mock.storedHistory, 1, 'new comment'); + it('should update comment in query in history', async () => { + const updatedComment = await updateCommentInRichHistory(mock.storedHistory, 1, 'new comment'); expect(updatedComment[0].comment).toEqual('new comment'); }); - it('should update comment in localStorage', () => { - updateCommentInRichHistory(mock.storedHistory, 1, 'new comment'); - expect(store.exists(key)).toBeTruthy(); - expect(store.getObject(key)[0].comment).toEqual('new comment'); - }); }); describe('deleteQueryInRichHistory', () => { - it('should delete query in query in history', () => { - const deletedHistory = deleteQueryInRichHistory(mock.storedHistory, 1); + it('should delete query in query in history', async () => { + const deletedHistory = await deleteQueryInRichHistory(mock.storedHistory, 1); expect(deletedHistory).toEqual([]); }); - it('should delete query in localStorage', () => { - deleteQueryInRichHistory(mock.storedHistory, 1); - expect(store.exists(key)).toBeTruthy(); - expect(store.getObject(key)).toEqual([]); - }); }); describe('mapNumbertoTimeInSlider', () => { @@ -275,63 +253,4 @@ describe('richHistory', () => { expect(heading).toEqual(mock.storedHistory[0].datasourceName); }); }); - - describe('getRichHistory', () => { - afterEach(() => { - deleteAllFromRichHistory(); - expect(store.exists(key)).toBeFalsy(); - }); - describe('should load from localStorage data in old formats', () => { - it('should load when queries are strings', () => { - const oldHistoryItem = { ...mock.storedHistory[0], queries: ['test query 1', 'test query 2', 'test query 3'] }; - store.setObject(key, [oldHistoryItem]); - const expectedHistoryItem = { - ...mock.storedHistory[0], - queries: [ - { - expr: 'test query 1', - refId: 'A', - }, - { - expr: 'test query 2', - refId: 'B', - }, - { - expr: 'test query 3', - refId: 'C', - }, - ], - }; - - const result = getRichHistory(); - expect(result).toStrictEqual([expectedHistoryItem]); - }); - - it('should load when queries are json-encoded strings', () => { - const oldHistoryItem = { - ...mock.storedHistory[0], - queries: ['{"refId":"A","key":"key1","metrics":[]}', '{"refId":"B","key":"key2","metrics":[]}'], - }; - store.setObject(key, [oldHistoryItem]); - const expectedHistoryItem = { - ...mock.storedHistory[0], - queries: [ - { - refId: 'A', - key: 'key1', - metrics: [], - }, - { - refId: 'B', - key: 'key2', - metrics: [], - }, - ], - }; - - const result = getRichHistory(); - expect(result).toStrictEqual([expectedHistoryItem]); - }); - }); - }); }); diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 9fca596727b..c3cb698a2b2 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -1,9 +1,8 @@ // Libraries -import { isEqual, omit } from 'lodash'; +import { omit } from 'lodash'; // Services & Utils -import { DataQuery, DataSourceApi, dateTimeFormat, urlUtil, ExploreUrlState } from '@grafana/data'; -import store from 'app/core/store'; +import { DataQuery, DataSourceApi, dateTimeFormat, ExploreUrlState, urlUtil } from '@grafana/data'; import { dispatch } from 'app/store/store'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; @@ -12,31 +11,28 @@ import { createErrorNotification, createWarningNotification } from 'app/core/cop import { RichHistoryQuery } from 'app/types/explore'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { getDataSourceSrv } from '@grafana/runtime'; - -const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; - -export const RICH_HISTORY_SETTING_KEYS = { - retentionPeriod: 'grafana.explore.richHistory.retentionPeriod', - starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab', - activeDatasourceOnly: 'grafana.explore.richHistory.activeDatasourceOnly', - datasourceFilters: 'grafana.explore.richHistory.datasourceFilters', -}; - -export enum SortOrder { - Descending = 'Descending', - Ascending = 'Ascending', - DatasourceAZ = 'Datasource A-Z', - DatasourceZA = 'Datasource Z-A', -} +import { getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { + RichHistoryServiceError, + RichHistoryStorageWarning, + RichHistoryStorageWarningDetails, +} from '../history/RichHistoryStorage'; +import { + filterQueriesByDataSource, + filterQueriesBySearchFilter, + filterQueriesByTime, + sortQueries, +} from 'app/core/history/richHistoryLocalStorageUtils'; +import { SortOrder } from './richHistoryTypes'; + +export { SortOrder }; /* * Add queries to rich history. Save only queries within the retention period, or that are starred. * Side-effect: store history in local storage */ -export const MAX_HISTORY_ITEMS = 10000; - -export function addToRichHistory( +export async function addToRichHistory( richHistory: RichHistoryQuery[], datasourceId: string, datasourceName: string | null, @@ -46,102 +42,80 @@ export function addToRichHistory( sessionName: string, showQuotaExceededError: boolean, showLimitExceededWarning: boolean -): { richHistory: RichHistoryQuery[]; localStorageFull?: boolean; limitExceeded?: boolean } { +): Promise<{ richHistory: RichHistoryQuery[]; richHistoryStorageFull?: boolean; limitExceeded?: boolean }> { const ts = Date.now(); /* Save only queries, that are not falsy (e.g. empty object, null, ...) */ const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query)); - const retentionPeriod: number = store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7); - const retentionPeriodLastTs = createRetentionPeriodBoundary(retentionPeriod, false); - - /* Keep only queries, that are within the selected retention period or that are starred. - * If no queries, initialize with empty array - */ - const queriesToKeep = richHistory.filter((q) => q.ts > retentionPeriodLastTs || q.starred === true) || []; if (newQueriesToSave.length > 0) { - /* Compare queries of a new query and last saved queries. If they are the same, (except selected properties, - * which can be different) don't save it in rich history. - */ - const newQueriesToCompare = newQueriesToSave.map((q) => omit(q, ['key', 'refId'])); - const lastQueriesToCompare = - queriesToKeep.length > 0 && - queriesToKeep[0].queries.map((q) => { - return omit(q, ['key', 'refId']); - }); - - if (isEqual(newQueriesToCompare, lastQueriesToCompare)) { - return { richHistory }; - } - - // remove oldest non-starred items to give space for the recent query + const newRichHistory: RichHistoryQuery = { + queries: newQueriesToSave, + ts, + datasourceId, + datasourceName: datasourceName ?? '', + starred, + comment: comment ?? '', + sessionName, + }; + + let richHistoryStorageFull = false; let limitExceeded = false; - let current = queriesToKeep.length - 1; - while (current >= 0 && queriesToKeep.length >= MAX_HISTORY_ITEMS) { - if (!queriesToKeep[current].starred) { - queriesToKeep.splice(current, 1); - limitExceeded = true; - } - current--; - } - - let updatedHistory: RichHistoryQuery[] = [ - { - queries: newQueriesToSave, - ts, - datasourceId, - datasourceName: datasourceName ?? '', - starred, - comment: comment ?? '', - sessionName, - }, - ...queriesToKeep, - ]; + let warning: RichHistoryStorageWarningDetails | undefined; try { - showLimitExceededWarning && - limitExceeded && - dispatch( - notifyApp( - createWarningNotification( - `Query history reached the limit of ${MAX_HISTORY_ITEMS}. Old, not-starred items will be removed.` - ) - ) - ); - store.setObject(RICH_HISTORY_KEY, updatedHistory); - return { richHistory: updatedHistory, limitExceeded, localStorageFull: false }; + warning = await getRichHistoryStorage().addToRichHistory(newRichHistory); } catch (error) { - showQuotaExceededError && - dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); - return { richHistory: updatedHistory, limitExceeded, localStorageFull: error.name === 'QuotaExceededError' }; + if (error.name === RichHistoryServiceError.StorageFull) { + richHistoryStorageFull = true; + showQuotaExceededError && dispatch(notifyApp(createErrorNotification(error.message))); + } else if (error.name !== RichHistoryServiceError.DuplicatedEntry) { + dispatch(notifyApp(createErrorNotification('Rich History update failed', error.message))); + } + // Saving failed. Do not add new entry. + return { richHistory, richHistoryStorageFull, limitExceeded }; + } + + // Limit exceeded but new entry was added. Notify that old entries have been removed. + if (warning && warning.type === RichHistoryStorageWarning.LimitExceeded) { + limitExceeded = true; + showLimitExceededWarning && dispatch(notifyApp(createWarningNotification(warning.message))); } + + // Saving successful - add new entry. + return { richHistory: [newRichHistory, ...richHistory], richHistoryStorageFull, limitExceeded }; } + // Nothing to save return { richHistory }; } -export function getRichHistory(): RichHistoryQuery[] { - const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); - const transformedRichHistory = migrateRichHistory(richHistory); - return transformedRichHistory; +export async function getRichHistory(): Promise { + return await getRichHistoryStorage().getRichHistory(); } -export function deleteAllFromRichHistory() { - return store.delete(RICH_HISTORY_KEY); +export async function deleteAllFromRichHistory(): Promise { + return getRichHistoryStorage().deleteAll(); } -export function updateStarredInRichHistory(richHistory: RichHistoryQuery[], ts: number) { +export async function updateStarredInRichHistory(richHistory: RichHistoryQuery[], ts: number) { + let updatedQuery: RichHistoryQuery | undefined; + const updatedHistory = richHistory.map((query) => { /* Timestamps are currently unique - we can use them to identify specific queries */ if (query.ts === ts) { const isStarred = query.starred; - const updatedQuery = Object.assign({}, query, { starred: !isStarred }); + updatedQuery = Object.assign({}, query, { starred: !isStarred }); return updatedQuery; } return query; }); + if (!updatedQuery) { + return richHistory; + } + try { - store.setObject(RICH_HISTORY_KEY, updatedHistory); + await getRichHistoryStorage().updateStarred(ts, updatedQuery.starred); return updatedHistory; } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); @@ -149,21 +123,26 @@ export function updateStarredInRichHistory(richHistory: RichHistoryQuery[], ts: } } -export function updateCommentInRichHistory( +export async function updateCommentInRichHistory( richHistory: RichHistoryQuery[], ts: number, newComment: string | undefined ) { + let updatedQuery: RichHistoryQuery | undefined; const updatedHistory = richHistory.map((query) => { if (query.ts === ts) { - const updatedQuery = Object.assign({}, query, { comment: newComment }); + updatedQuery = Object.assign({}, query, { comment: newComment }); return updatedQuery; } return query; }); + if (!updatedQuery) { + return richHistory; + } + try { - store.setObject(RICH_HISTORY_KEY, updatedHistory); + await getRichHistoryStorage().updateComment(ts, newComment); return updatedHistory; } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); @@ -171,10 +150,13 @@ export function updateCommentInRichHistory( } } -export function deleteQueryInRichHistory(richHistory: RichHistoryQuery[], ts: number) { +export async function deleteQueryInRichHistory( + richHistory: RichHistoryQuery[], + ts: number +): Promise { const updatedHistory = richHistory.filter((query) => query.ts !== ts); try { - store.setObject(RICH_HISTORY_KEY, updatedHistory); + await getRichHistoryStorage().deleteRichHistory(ts); return updatedHistory; } catch (error) { dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); @@ -182,28 +164,21 @@ export function deleteQueryInRichHistory(richHistory: RichHistoryQuery[], ts: nu } } -export const sortQueries = (array: RichHistoryQuery[], sortOrder: SortOrder) => { - let sortFunc; - - if (sortOrder === SortOrder.Ascending) { - sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0); - } - if (sortOrder === SortOrder.Descending) { - sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0); - } - - if (sortOrder === SortOrder.DatasourceZA) { - sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => - a.datasourceName < b.datasourceName ? -1 : a.datasourceName > b.datasourceName ? 1 : 0; - } - - if (sortOrder === SortOrder.DatasourceAZ) { - sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => - a.datasourceName < b.datasourceName ? 1 : a.datasourceName > b.datasourceName ? -1 : 0; - } +export function filterAndSortQueries( + queries: RichHistoryQuery[], + sortOrder: SortOrder, + listOfDatasourceFilters: string[], + searchFilter: string, + timeFilter?: [number, number] +) { + const filteredQueriesByDs = filterQueriesByDataSource(queries, listOfDatasourceFilters); + const filteredQueriesByDsAndSearchFilter = filterQueriesBySearchFilter(filteredQueriesByDs, searchFilter); + const filteredQueriesToBeSorted = timeFilter + ? filterQueriesByTime(filteredQueriesByDsAndSearchFilter, timeFilter) + : filteredQueriesByDsAndSearchFilter; - return array.sort(sortFunc); -}; + return sortQueries(filteredQueriesToBeSorted, sortOrder); +} export const createUrlFromRichHistory = (query: RichHistoryQuery) => { const exploreState: ExploreUrlState = { @@ -243,18 +218,6 @@ export const mapNumbertoTimeInSlider = (num: number) => { return str; }; -export const createRetentionPeriodBoundary = (days: number, isLastTs: boolean) => { - const today = new Date(); - const date = new Date(today.setDate(today.getDate() - days)); - /* - * As a retention period boundaries, we consider: - * - The last timestamp equals to the 24:00 of the last day of retention - * - The first timestamp that equals to the 00:00 of the first day of retention - */ - const boundary = isLastTs ? date.setHours(24, 0, 0, 0) : date.setHours(0, 0, 0, 0); - return boundary; -}; - export function createDateStringFromTs(ts: number) { return dateTimeFormat(ts, { format: 'MMMM D', @@ -346,81 +309,3 @@ export function notEmptyQuery(query: DataQuery) { return false; } - -export function filterQueriesBySearchFilter(queries: RichHistoryQuery[], searchFilter: string) { - return queries.filter((query) => { - if (query.comment.includes(searchFilter)) { - return true; - } - - const listOfMatchingQueries = query.queries.filter((query) => - // Remove fields in which we don't want to be searching - Object.values(omit(query, ['datasource', 'key', 'refId', 'hide', 'queryType'])).some((value: any) => - value?.toString().includes(searchFilter) - ) - ); - - return listOfMatchingQueries.length > 0; - }); -} - -export function filterQueriesByDataSource(queries: RichHistoryQuery[], listOfDatasourceFilters: string[]) { - return listOfDatasourceFilters && listOfDatasourceFilters.length > 0 - ? queries.filter((q) => listOfDatasourceFilters.includes(q.datasourceName)) - : queries; -} - -export function filterQueriesByTime(queries: RichHistoryQuery[], timeFilter: [number, number]) { - return queries.filter( - (q) => - q.ts < createRetentionPeriodBoundary(timeFilter[0], true) && - q.ts > createRetentionPeriodBoundary(timeFilter[1], false) - ); -} - -export function filterAndSortQueries( - queries: RichHistoryQuery[], - sortOrder: SortOrder, - listOfDatasourceFilters: string[], - searchFilter: string, - timeFilter?: [number, number] -) { - const filteredQueriesByDs = filterQueriesByDataSource(queries, listOfDatasourceFilters); - const filteredQueriesByDsAndSearchFilter = filterQueriesBySearchFilter(filteredQueriesByDs, searchFilter); - const filteredQueriesToBeSorted = timeFilter - ? filterQueriesByTime(filteredQueriesByDsAndSearchFilter, timeFilter) - : filteredQueriesByDsAndSearchFilter; - - return sortQueries(filteredQueriesToBeSorted, sortOrder); -} - -function migrateRichHistory(richHistory: RichHistoryQuery[]) { - const transformedRichHistory = richHistory.map((query) => { - const transformedQueries: DataQuery[] = query.queries.map((q, index) => createDataQuery(query, q, index)); - return { ...query, queries: transformedQueries }; - }); - - return transformedRichHistory; -} - -function createDataQuery(query: RichHistoryQuery, individualQuery: DataQuery | string, index: number) { - const letters = 'ABCDEFGHIJKLMNOPQRSTUVXYZ'; - if (typeof individualQuery === 'object') { - // the current format - return individualQuery; - } else if (isParsable(individualQuery)) { - // ElasticSearch (maybe other datasoures too) before grafana7 - return JSON.parse(individualQuery); - } - // prometehus (maybe other datasources too) before grafana7 - return { expr: individualQuery, refId: letters[index] }; -} - -function isParsable(string: string) { - try { - JSON.parse(string); - } catch (e) { - return false; - } - return true; -} diff --git a/public/app/core/utils/richHistoryTypes.ts b/public/app/core/utils/richHistoryTypes.ts new file mode 100644 index 00000000000..2322258de13 --- /dev/null +++ b/public/app/core/utils/richHistoryTypes.ts @@ -0,0 +1,6 @@ +export enum SortOrder { + Descending = 'Descending', + Ascending = 'Ascending', + DatasourceAZ = 'Datasource A-Z', + DatasourceZA = 'Datasource Z-A', +} diff --git a/public/app/features/explore/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index e9e16546c8c..6693689335f 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -50,7 +50,7 @@ function setup(queries: DataQuery[]) { syncedTimes: false, right: undefined, richHistory: [], - localStorageFull: false, + richHistoryStorageFull: false, richHistoryLimitExceededWarningShown: false, }; const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState }); diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index 1478b3dd1d6..410ad7f7ea6 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -1,7 +1,8 @@ import React, { PureComponent } from 'react'; //Services & Utils -import { RICH_HISTORY_SETTING_KEYS, SortOrder } from 'app/core/utils/richHistory'; +import { SortOrder } from 'app/core/utils/richHistory'; +import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils'; import store from 'app/core/store'; import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx index 8ac84955b1e..c68162f9834 100644 --- a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx @@ -4,7 +4,7 @@ import { connect, ConnectedProps } from 'react-redux'; // Services & Utils import store from 'app/core/store'; -import { RICH_HISTORY_SETTING_KEYS } from 'app/core/utils/richHistory'; +import { RICH_HISTORY_SETTING_KEYS } from 'app/core/history/richHistoryLocalStorageUtils'; // Types import { ExploreItemState, StoreState } from 'app/types'; diff --git a/public/app/features/explore/RichHistory/RichHistorySettings.tsx b/public/app/features/explore/RichHistory/RichHistorySettings.tsx index f07b0c5136f..2a83c6d04be 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettings.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettings.tsx @@ -7,7 +7,7 @@ import { ShowConfirmModalEvent } from '../../../types/events'; import { dispatch } from 'app/store/store'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification } from 'app/core/copy/appNotification'; -import { MAX_HISTORY_ITEMS } from '../../../core/utils/richHistory'; +import { MAX_HISTORY_ITEMS } from 'app/core/history/RichHistoryLocalStorage'; export interface RichHistorySettingsProps { retentionPeriod: number; diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index f38a2e6616e..fdd2afde72c 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -55,6 +55,9 @@ class WrapperUnconnected extends PureComponent { const richHistory = getRichHistory(); this.props.richHistoryUpdatedAction({ richHistory }); + getRichHistory().then((richHistory) => { + this.props.richHistoryUpdatedAction({ richHistory }); + }); } componentDidUpdate(prevProps: Props) { diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 32b93b04ff2..6d1a104ba6f 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -183,7 +183,7 @@ export function initializeExplore( dispatch(runQueries(exploreId, { replaceUrl: true })); } - const richHistory = getRichHistory(); + const richHistory = await getRichHistory(); dispatch(richHistoryUpdatedAction({ richHistory })); }; } diff --git a/public/app/features/explore/state/history.ts b/public/app/features/explore/state/history.ts index ee57fd8a857..51e13c9f543 100644 --- a/public/app/features/explore/state/history.ts +++ b/public/app/features/explore/state/history.ts @@ -24,25 +24,25 @@ export const historyUpdatedAction = createAction('explore // export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult => { - return (dispatch, getState) => { + return async (dispatch, getState) => { // Side-effect: Saving rich history in localstorage let nextRichHistory; if (property === 'starred') { - nextRichHistory = updateStarredInRichHistory(getState().explore.richHistory, ts); + nextRichHistory = await updateStarredInRichHistory(getState().explore.richHistory, ts); } if (property === 'comment') { - nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); + nextRichHistory = await updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); } if (property === 'delete') { - nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts); + nextRichHistory = await deleteQueryInRichHistory(getState().explore.richHistory, ts); } dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); }; }; export const deleteRichHistory = (): ThunkResult => { - return (dispatch) => { - deleteAllFromRichHistory(); + return async (dispatch) => { + await deleteAllFromRichHistory(); dispatch(richHistoryUpdatedAction({ richHistory: [] })); }; }; diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index b3db5b9add8..6edbe74ac3c 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -20,7 +20,7 @@ export interface SyncTimesPayload { export const syncTimesAction = createAction('explore/syncTimes'); export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); -export const localStorageFullAction = createAction('explore/localStorageFullAction'); +export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction'); export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction'); /** @@ -158,7 +158,7 @@ export const initialExploreState: ExploreState = { left: initialExploreItemState, right: undefined, richHistory: [], - localStorageFull: false, + richHistoryStorageFull: false, richHistoryLimitExceededWarningShown: false, }; @@ -214,10 +214,10 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } - if (localStorageFullAction.match(action)) { + if (richHistoryStorageFullAction.match(action)) { return { ...state, - localStorageFull: true, + richHistoryStorageFull: true, }; } diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 834e0cae678..ee503c3375a 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -36,7 +36,12 @@ import { notifyApp } from '../../../core/actions'; import { runRequest } from '../../query/state/runRequest'; import { decorateData } from '../utils/decorators'; import { createErrorNotification } from '../../../core/copy/appNotification'; -import { localStorageFullAction, richHistoryLimitExceededAction, richHistoryUpdatedAction, stateSave } from './main'; +import { + richHistoryStorageFullAction, + richHistoryLimitExceededAction, + richHistoryUpdatedAction, + stateSave, +} from './main'; import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit'; import { updateTime } from './time'; import { historyUpdatedAction } from './history'; @@ -305,7 +310,7 @@ export function modifyQueries( }; } -function handleHistory( +async function handleHistory( dispatch: ThunkDispatch, state: ExploreState, history: Array>, @@ -317,9 +322,9 @@ function handleHistory( const nextHistory = updateHistory(history, datasourceId, queries); const { richHistory: nextRichHistory, - localStorageFull, + richHistoryStorageFull, limitExceeded, - } = addToRichHistory( + } = await addToRichHistory( state.richHistory || [], datasourceId, datasource.name, @@ -327,14 +332,14 @@ function handleHistory( false, '', '', - !state.localStorageFull, + !state.richHistoryStorageFull, !state.richHistoryLimitExceededWarningShown ); dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); - if (localStorageFull) { - dispatch(localStorageFullAction()); + if (richHistoryStorageFull) { + dispatch(richHistoryStorageFullAction()); } if (limitExceeded) { dispatch(richHistoryLimitExceededAction()); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 9540c2a5ce0..c75b6032efb 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -48,10 +48,10 @@ export interface ExploreState { richHistory: RichHistoryQuery[]; /** - * True if local storage quota was exceeded when a new item was added. This is to prevent showing + * True if local storage quota was exceeded when a rich history item was added. This is to prevent showing * multiple errors when local storage is full. */ - localStorageFull: boolean; + richHistoryStorageFull: boolean; /** * True if a warning message of hitting the exceeded number of items has been shown already.