Query History: Refactor persistence layer (#44545)

* Extract Rich History storage into two separate implementations

Implement getting all entries and adding a new entry

* Add deleting rich history items

* Add editing rich history

* Simplify RichHistoryStorage API

* Reorganize methods

* Rename variable

* Remove feature toggle

* Fix TS errors

* Fix tests

* Clean up

* Clean up only when adding new entry

* Fix showing a warning message

* Use enum instead of a string

* Update public/app/core/history/richHistoryLocalStorage.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/core/history/richHistoryLocalStorage.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Improve readability

* Rename files and remove inferred return types

* Use const over a var

* Remove unsed files

* Remove redundant null check

* Update public/app/core/history/richHistoryLocalStorageUtils.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Fix linting issues

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
pull/44870/head^2
Piotr Jamróz 3 years ago committed by GitHub
parent 9e52361c1e
commit 6bb6b13379
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 204
      public/app/core/history/RichHistoryLocalStorage.test.ts
  2. 168
      public/app/core/history/RichHistoryLocalStorage.ts
  3. 40
      public/app/core/history/RichHistoryStorage.ts
  4. 80
      public/app/core/history/richHistoryLocalStorageUtils.ts
  5. 8
      public/app/core/history/richHistoryStorageProvider.ts
  6. 191
      public/app/core/utils/richHistory.test.ts
  7. 283
      public/app/core/utils/richHistory.ts
  8. 6
      public/app/core/utils/richHistoryTypes.ts
  9. 2
      public/app/features/explore/QueryRows.test.tsx
  10. 3
      public/app/features/explore/RichHistory/RichHistory.tsx
  11. 2
      public/app/features/explore/RichHistory/RichHistoryContainer.tsx
  12. 2
      public/app/features/explore/RichHistory/RichHistorySettings.tsx
  13. 3
      public/app/features/explore/Wrapper.tsx
  14. 2
      public/app/features/explore/state/explorePane.ts
  15. 12
      public/app/features/explore/state/history.ts
  16. 8
      public/app/features/explore/state/main.ts
  17. 19
      public/app/features/explore/state/query.ts
  18. 4
      public/app/types/explore.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]);
});
});
});
});

@ -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;
}

@ -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<RichHistoryQuery[]>;
addToRichHistory(richHistoryQuery: RichHistoryQuery): Promise<RichHistoryStorageWarningDetails | undefined>;
deleteAll(): Promise<void>;
deleteRichHistory(id: number): Promise<void>;
updateStarred(id: number, starred: boolean): Promise<void>;
updateComment(id: number, comment: string | undefined): Promise<void>;
}

@ -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',
};

@ -0,0 +1,8 @@
import RichHistoryLocalStorage from './RichHistoryLocalStorage';
import RichHistoryStorage from './RichHistoryStorage';
const richHistoryLocalStorage = new RichHistoryLocalStorage();
export const getRichHistoryStorage = (): RichHistoryStorage => {
return richHistoryLocalStorage;
};

@ -1,6 +1,5 @@
import { import {
addToRichHistory, addToRichHistory,
getRichHistory,
updateStarredInRichHistory, updateStarredInRichHistory,
updateCommentInRichHistory, updateCommentInRichHistory,
mapNumbertoTimeInSlider, mapNumbertoTimeInSlider,
@ -10,11 +9,18 @@ import {
deleteQueryInRichHistory, deleteQueryInRichHistory,
filterAndSortQueries, filterAndSortQueries,
SortOrder, SortOrder,
MAX_HISTORY_ITEMS,
} from './richHistory'; } from './richHistory';
import store from 'app/core/store'; import store from 'app/core/store';
import { dateTime, DataQuery } from '@grafana/data'; 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 = { const mock: any = {
storedHistory: [ storedHistory: [
@ -48,6 +54,13 @@ describe('richHistory', () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
jest.setSystemTime(new Date(1970, 0, 1)); 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(() => { afterEach(() => {
@ -72,9 +85,9 @@ describe('richHistory', () => {
mock.storedHistory[0], mock.storedHistory[0],
]; ];
it('should append query to query history', () => { it('should append query to query history', async () => {
Date.now = jest.fn(() => 2); Date.now = jest.fn(() => 2);
const { richHistory: newHistory } = addToRichHistory( const { richHistory: newHistory } = await addToRichHistory(
mock.storedHistory, mock.storedHistory,
mock.testDatasourceId, mock.testDatasourceId,
mock.testDatasourceName, mock.testDatasourceName,
@ -88,10 +101,10 @@ describe('richHistory', () => {
expect(newHistory).toEqual(expectedResult); expect(newHistory).toEqual(expectedResult);
}); });
it('should save query history to localStorage', () => { it('should add query history to storage', async () => {
Date.now = jest.fn(() => 2); Date.now = jest.fn(() => 2);
addToRichHistory( const { richHistory } = await addToRichHistory(
mock.storedHistory, mock.storedHistory,
mock.testDatasourceId, mock.testDatasourceId,
mock.testDatasourceName, mock.testDatasourceName,
@ -102,29 +115,26 @@ describe('richHistory', () => {
true, true,
true true
); );
expect(store.exists(key)).toBeTruthy(); expect(richHistory).toMatchObject(expectedResult);
expect(store.getObject(key)).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', () => {
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', () => { it('should not append duplicated query to query history', async () => {
Date.now = jest.fn(() => 2); 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,
mock.storedHistory[0].datasourceId, mock.storedHistory[0].datasourceId,
mock.storedHistory[0].datasourceName, mock.storedHistory[0].datasourceName,
@ -135,84 +145,52 @@ describe('richHistory', () => {
true, true,
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); Date.now = jest.fn(() => 2);
const extraItems = 100;
// the history has more than MAX richHistoryStorageMock.addToRichHistory = jest.fn().mockReturnValue({
let history = []; type: RichHistoryStorageWarning.LimitExceeded,
// history = [ { starred: true, comment: "0" }, { starred: false, comment: "1" }, ... ] message: 'Limit exceeded',
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);
const { richHistory: newHistory } = addToRichHistory( const { richHistory, limitExceeded } = await addToRichHistory(
history as any as RichHistoryQuery[], mock.storedHistory,
mock.storedHistory[0].datasourceId, mock.testDatasourceId,
mock.storedHistory[0].datasourceName, mock.testDatasourceName,
[{ expr: 'query1', maxLines: null, refId: 'A' } as DataQuery, { expr: 'query2', refId: 'B' } as DataQuery], mock.testQueries,
true, mock.testStarred,
mock.testComment, mock.testComment,
mock.testSessionName, mock.testSessionName,
true, true,
true true
); );
expect(richHistory).toEqual(expectedResult);
// one not starred replaced with a newly added starred item expect(limitExceeded).toBeTruthy();
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);
}); });
}); });
describe('updateStarredInRichHistory', () => { describe('updateStarredInRichHistory', () => {
it('should update starred in query in history', () => { it('should update starred in query in history', async () => {
const updatedStarred = updateStarredInRichHistory(mock.storedHistory, 1); const updatedStarred = await updateStarredInRichHistory(mock.storedHistory, 1);
expect(updatedStarred[0].starred).toEqual(false); 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', () => { describe('updateCommentInRichHistory', () => {
it('should update comment in query in history', () => { it('should update comment in query in history', async () => {
const updatedComment = updateCommentInRichHistory(mock.storedHistory, 1, 'new comment'); const updatedComment = await updateCommentInRichHistory(mock.storedHistory, 1, 'new comment');
expect(updatedComment[0].comment).toEqual('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', () => { describe('deleteQueryInRichHistory', () => {
it('should delete query in query in history', () => { it('should delete query in query in history', async () => {
const deletedHistory = deleteQueryInRichHistory(mock.storedHistory, 1); const deletedHistory = await deleteQueryInRichHistory(mock.storedHistory, 1);
expect(deletedHistory).toEqual([]); 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', () => { describe('mapNumbertoTimeInSlider', () => {
@ -275,63 +253,4 @@ describe('richHistory', () => {
expect(heading).toEqual(mock.storedHistory[0].datasourceName); 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]);
});
});
});
}); });

@ -1,9 +1,8 @@
// Libraries // Libraries
import { isEqual, omit } from 'lodash'; import { omit } from 'lodash';
// Services & Utils // Services & Utils
import { DataQuery, DataSourceApi, dateTimeFormat, urlUtil, ExploreUrlState } from '@grafana/data'; import { DataQuery, DataSourceApi, dateTimeFormat, ExploreUrlState, urlUtil } from '@grafana/data';
import store from 'app/core/store';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; 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 { RichHistoryQuery } from 'app/types/explore';
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { getRichHistoryStorage } from '../history/richHistoryStorageProvider';
const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; import {
RichHistoryServiceError,
export const RICH_HISTORY_SETTING_KEYS = { RichHistoryStorageWarning,
retentionPeriod: 'grafana.explore.richHistory.retentionPeriod', RichHistoryStorageWarningDetails,
starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab', } from '../history/RichHistoryStorage';
activeDatasourceOnly: 'grafana.explore.richHistory.activeDatasourceOnly', import {
datasourceFilters: 'grafana.explore.richHistory.datasourceFilters', filterQueriesByDataSource,
}; filterQueriesBySearchFilter,
filterQueriesByTime,
export enum SortOrder { sortQueries,
Descending = 'Descending', } from 'app/core/history/richHistoryLocalStorageUtils';
Ascending = 'Ascending', import { SortOrder } from './richHistoryTypes';
DatasourceAZ = 'Datasource A-Z',
DatasourceZA = 'Datasource Z-A', export { SortOrder };
}
/* /*
* Add queries to rich history. Save only queries within the retention period, or that are starred. * Add queries to rich history. Save only queries within the retention period, or that are starred.
* Side-effect: store history in local storage * Side-effect: store history in local storage
*/ */
export const MAX_HISTORY_ITEMS = 10000; export async function addToRichHistory(
export function addToRichHistory(
richHistory: RichHistoryQuery[], richHistory: RichHistoryQuery[],
datasourceId: string, datasourceId: string,
datasourceName: string | null, datasourceName: string | null,
@ -46,46 +42,13 @@ export function addToRichHistory(
sessionName: string, sessionName: string,
showQuotaExceededError: boolean, showQuotaExceededError: boolean,
showLimitExceededWarning: boolean showLimitExceededWarning: boolean
): { richHistory: RichHistoryQuery[]; localStorageFull?: boolean; limitExceeded?: boolean } { ): Promise<{ richHistory: RichHistoryQuery[]; richHistoryStorageFull?: boolean; limitExceeded?: boolean }> {
const ts = Date.now(); const ts = Date.now();
/* 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));
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) { if (newQueriesToSave.length > 0) {
/* Compare queries of a new query and last saved queries. If they are the same, (except selected properties, const newRichHistory: RichHistoryQuery = {
* 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
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, queries: newQueriesToSave,
ts, ts,
datasourceId, datasourceId,
@ -93,55 +56,66 @@ export function addToRichHistory(
starred, starred,
comment: comment ?? '', comment: comment ?? '',
sessionName, sessionName,
}, };
...queriesToKeep,
]; let richHistoryStorageFull = false;
let limitExceeded = false;
let warning: RichHistoryStorageWarningDetails | undefined;
try { try {
showLimitExceededWarning && warning = await getRichHistoryStorage().addToRichHistory(newRichHistory);
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 };
} catch (error) { } catch (error) {
showQuotaExceededError && if (error.name === RichHistoryServiceError.StorageFull) {
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); richHistoryStorageFull = true;
return { richHistory: updatedHistory, limitExceeded, localStorageFull: error.name === 'QuotaExceededError' }; 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 }; return { richHistory };
} }
export function getRichHistory(): RichHistoryQuery[] { export async function getRichHistory(): Promise<RichHistoryQuery[]> {
const richHistory: RichHistoryQuery[] = store.getObject(RICH_HISTORY_KEY, []); return await getRichHistoryStorage().getRichHistory();
const transformedRichHistory = migrateRichHistory(richHistory);
return transformedRichHistory;
} }
export function deleteAllFromRichHistory() { export async function deleteAllFromRichHistory(): Promise<void> {
return store.delete(RICH_HISTORY_KEY); 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) => { const updatedHistory = richHistory.map((query) => {
/* Timestamps are currently unique - we can use them to identify specific queries */ /* Timestamps are currently unique - we can use them to identify specific queries */
if (query.ts === ts) { if (query.ts === ts) {
const isStarred = query.starred; const isStarred = query.starred;
const updatedQuery = Object.assign({}, query, { starred: !isStarred }); updatedQuery = Object.assign({}, query, { starred: !isStarred });
return updatedQuery; return updatedQuery;
} }
return query; return query;
}); });
if (!updatedQuery) {
return richHistory;
}
try { try {
store.setObject(RICH_HISTORY_KEY, updatedHistory); await getRichHistoryStorage().updateStarred(ts, updatedQuery.starred);
return updatedHistory; return updatedHistory;
} catch (error) { } catch (error) {
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); 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[], richHistory: RichHistoryQuery[],
ts: number, ts: number,
newComment: string | undefined newComment: string | undefined
) { ) {
let updatedQuery: RichHistoryQuery | undefined;
const updatedHistory = richHistory.map((query) => { const updatedHistory = richHistory.map((query) => {
if (query.ts === ts) { if (query.ts === ts) {
const updatedQuery = Object.assign({}, query, { comment: newComment }); updatedQuery = Object.assign({}, query, { comment: newComment });
return updatedQuery; return updatedQuery;
} }
return query; return query;
}); });
if (!updatedQuery) {
return richHistory;
}
try { try {
store.setObject(RICH_HISTORY_KEY, updatedHistory); await getRichHistoryStorage().updateComment(ts, newComment);
return updatedHistory; return updatedHistory;
} catch (error) { } catch (error) {
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); 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<RichHistoryQuery[]> {
const updatedHistory = richHistory.filter((query) => query.ts !== ts); const updatedHistory = richHistory.filter((query) => query.ts !== ts);
try { try {
store.setObject(RICH_HISTORY_KEY, updatedHistory); await getRichHistoryStorage().deleteRichHistory(ts);
return updatedHistory; return updatedHistory;
} catch (error) { } catch (error) {
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message))); 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) => { export function filterAndSortQueries(
let sortFunc; queries: RichHistoryQuery[],
sortOrder: SortOrder,
if (sortOrder === SortOrder.Ascending) { listOfDatasourceFilters: string[],
sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0); searchFilter: string,
} timeFilter?: [number, number]
if (sortOrder === SortOrder.Descending) { ) {
sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0); const filteredQueriesByDs = filterQueriesByDataSource(queries, listOfDatasourceFilters);
} const filteredQueriesByDsAndSearchFilter = filterQueriesBySearchFilter(filteredQueriesByDs, searchFilter);
const filteredQueriesToBeSorted = timeFilter
if (sortOrder === SortOrder.DatasourceZA) { ? filterQueriesByTime(filteredQueriesByDsAndSearchFilter, timeFilter)
sortFunc = (a: RichHistoryQuery, b: RichHistoryQuery) => : filteredQueriesByDsAndSearchFilter;
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); return sortQueries(filteredQueriesToBeSorted, sortOrder);
}; }
export const createUrlFromRichHistory = (query: RichHistoryQuery) => { export const createUrlFromRichHistory = (query: RichHistoryQuery) => {
const exploreState: ExploreUrlState = { const exploreState: ExploreUrlState = {
@ -243,18 +218,6 @@ export const mapNumbertoTimeInSlider = (num: number) => {
return str; 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) { export function createDateStringFromTs(ts: number) {
return dateTimeFormat(ts, { return dateTimeFormat(ts, {
format: 'MMMM D', format: 'MMMM D',
@ -346,81 +309,3 @@ export function notEmptyQuery(query: DataQuery) {
return false; 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;
}

@ -0,0 +1,6 @@
export enum SortOrder {
Descending = 'Descending',
Ascending = 'Ascending',
DatasourceAZ = 'Datasource A-Z',
DatasourceZA = 'Datasource Z-A',
}

@ -50,7 +50,7 @@ function setup(queries: DataQuery[]) {
syncedTimes: false, syncedTimes: false,
right: undefined, right: undefined,
richHistory: [], richHistory: [],
localStorageFull: false, richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false, richHistoryLimitExceededWarningShown: false,
}; };
const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState }); const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState });

@ -1,7 +1,8 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
//Services & Utils //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 store from 'app/core/store';
import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui';

@ -4,7 +4,7 @@ import { connect, ConnectedProps } from 'react-redux';
// Services & Utils // Services & Utils
import store from 'app/core/store'; 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 // Types
import { ExploreItemState, StoreState } from 'app/types'; import { ExploreItemState, StoreState } from 'app/types';

@ -7,7 +7,7 @@ import { ShowConfirmModalEvent } from '../../../types/events';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification'; 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 { export interface RichHistorySettingsProps {
retentionPeriod: number; retentionPeriod: number;

@ -55,6 +55,9 @@ class WrapperUnconnected extends PureComponent<Props> {
const richHistory = getRichHistory(); const richHistory = getRichHistory();
this.props.richHistoryUpdatedAction({ richHistory }); this.props.richHistoryUpdatedAction({ richHistory });
getRichHistory().then((richHistory) => {
this.props.richHistoryUpdatedAction({ richHistory });
});
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {

@ -183,7 +183,7 @@ export function initializeExplore(
dispatch(runQueries(exploreId, { replaceUrl: true })); dispatch(runQueries(exploreId, { replaceUrl: true }));
} }
const richHistory = getRichHistory(); const richHistory = await getRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory })); dispatch(richHistoryUpdatedAction({ richHistory }));
}; };
} }

@ -24,25 +24,25 @@ export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore
// //
export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult<void> => { export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult<void> => {
return (dispatch, getState) => { return async (dispatch, getState) => {
// Side-effect: Saving rich history in localstorage // Side-effect: Saving rich history in localstorage
let nextRichHistory; let nextRichHistory;
if (property === 'starred') { if (property === 'starred') {
nextRichHistory = updateStarredInRichHistory(getState().explore.richHistory, ts); nextRichHistory = await updateStarredInRichHistory(getState().explore.richHistory, ts);
} }
if (property === 'comment') { if (property === 'comment') {
nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); nextRichHistory = await updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty);
} }
if (property === 'delete') { if (property === 'delete') {
nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts); nextRichHistory = await deleteQueryInRichHistory(getState().explore.richHistory, ts);
} }
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
}; };
}; };
export const deleteRichHistory = (): ThunkResult<void> => { export const deleteRichHistory = (): ThunkResult<void> => {
return (dispatch) => { return async (dispatch) => {
deleteAllFromRichHistory(); await deleteAllFromRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory: [] })); dispatch(richHistoryUpdatedAction({ richHistory: [] }));
}; };
}; };

@ -20,7 +20,7 @@ export interface SyncTimesPayload {
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes'); export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated'); export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
export const localStorageFullAction = createAction('explore/localStorageFullAction'); export const richHistoryStorageFullAction = createAction('explore/richHistoryStorageFullAction');
export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction'); export const richHistoryLimitExceededAction = createAction('explore/richHistoryLimitExceededAction');
/** /**
@ -158,7 +158,7 @@ export const initialExploreState: ExploreState = {
left: initialExploreItemState, left: initialExploreItemState,
right: undefined, right: undefined,
richHistory: [], richHistory: [],
localStorageFull: false, richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false, richHistoryLimitExceededWarningShown: false,
}; };
@ -214,10 +214,10 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
}; };
} }
if (localStorageFullAction.match(action)) { if (richHistoryStorageFullAction.match(action)) {
return { return {
...state, ...state,
localStorageFull: true, richHistoryStorageFull: true,
}; };
} }

@ -36,7 +36,12 @@ import { notifyApp } from '../../../core/actions';
import { runRequest } from '../../query/state/runRequest'; import { runRequest } from '../../query/state/runRequest';
import { decorateData } from '../utils/decorators'; import { decorateData } from '../utils/decorators';
import { createErrorNotification } from '../../../core/copy/appNotification'; 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 { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
import { updateTime } from './time'; import { updateTime } from './time';
import { historyUpdatedAction } from './history'; import { historyUpdatedAction } from './history';
@ -305,7 +310,7 @@ export function modifyQueries(
}; };
} }
function handleHistory( async function handleHistory(
dispatch: ThunkDispatch, dispatch: ThunkDispatch,
state: ExploreState, state: ExploreState,
history: Array<HistoryItem<DataQuery>>, history: Array<HistoryItem<DataQuery>>,
@ -317,9 +322,9 @@ function handleHistory(
const nextHistory = updateHistory(history, datasourceId, queries); const nextHistory = updateHistory(history, datasourceId, queries);
const { const {
richHistory: nextRichHistory, richHistory: nextRichHistory,
localStorageFull, richHistoryStorageFull,
limitExceeded, limitExceeded,
} = addToRichHistory( } = await addToRichHistory(
state.richHistory || [], state.richHistory || [],
datasourceId, datasourceId,
datasource.name, datasource.name,
@ -327,14 +332,14 @@ function handleHistory(
false, false,
'', '',
'', '',
!state.localStorageFull, !state.richHistoryStorageFull,
!state.richHistoryLimitExceededWarningShown !state.richHistoryLimitExceededWarningShown
); );
dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); dispatch(historyUpdatedAction({ exploreId, history: nextHistory }));
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
if (localStorageFull) { if (richHistoryStorageFull) {
dispatch(localStorageFullAction()); dispatch(richHistoryStorageFullAction());
} }
if (limitExceeded) { if (limitExceeded) {
dispatch(richHistoryLimitExceededAction()); dispatch(richHistoryLimitExceededAction());

@ -48,10 +48,10 @@ export interface ExploreState {
richHistory: RichHistoryQuery[]; 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. * 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. * True if a warning message of hitting the exceeded number of items has been shown already.

Loading…
Cancel
Save