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>
This commit is contained in:
co-authored by
Giordano Ricci
parent
9e52361c1e
commit
6bb6b13379
@@ -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 {
|
||||
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,13 +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(
|
||||
|
||||
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,
|
||||
@@ -122,97 +148,49 @@ describe('richHistory', () => {
|
||||
expect(newHistory).toEqual([mock.storedHistory[0]]);
|
||||
});
|
||||
|
||||
it('should not save duplicated query to localStorage', () => {
|
||||
it('it should append new items even when the limit is exceeded', async () => {
|
||||
Date.now = jest.fn(() => 2);
|
||||
addToRichHistory(
|
||||
|
||||
richHistoryStorageMock.addToRichHistory = jest.fn().mockReturnValue({
|
||||
type: RichHistoryStorageWarning.LimitExceeded,
|
||||
message: 'Limit exceeded',
|
||||
});
|
||||
|
||||
const { richHistory, limitExceeded } = await 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.testDatasourceId,
|
||||
mock.testDatasourceName,
|
||||
mock.testQueries,
|
||||
mock.testStarred,
|
||||
mock.testComment,
|
||||
mock.testSessionName,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(store.exists(key)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not save more than MAX_HISTORY_ITEMS', () => {
|
||||
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);
|
||||
|
||||
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,
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
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';
|
||||
|
||||
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',
|
||||
}
|
||||
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']);
|
||||
});
|
||||
const newRichHistory: RichHistoryQuery = {
|
||||
queries: newQueriesToSave,
|
||||
ts,
|
||||
datasourceId,
|
||||
datasourceName: datasourceName ?? '',
|
||||
starred,
|
||||
comment: comment ?? '',
|
||||
sessionName,
|
||||
};
|
||||
|
||||
if (isEqual(newQueriesToCompare, lastQueriesToCompare)) {
|
||||
return { richHistory };
|
||||
}
|
||||
|
||||
// remove oldest non-starred items to give space for the recent query
|
||||
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<RichHistoryQuery[]> {
|
||||
return await getRichHistoryStorage().getRichHistory();
|
||||
}
|
||||
|
||||
export function deleteAllFromRichHistory() {
|
||||
return store.delete(RICH_HISTORY_KEY);
|
||||
export async function deleteAllFromRichHistory(): Promise<void> {
|
||||
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<RichHistoryQuery[]> {
|
||||
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;
|
||||
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;
|
||||
|
||||
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);
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
right: undefined,
|
||||
richHistory: [],
|
||||
localStorageFull: false,
|
||||
richHistoryStorageFull: false,
|
||||
richHistoryLimitExceededWarningShown: false,
|
||||
};
|
||||
const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState });
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,9 @@ class WrapperUnconnected extends PureComponent<Props> {
|
||||
|
||||
const richHistory = getRichHistory();
|
||||
this.props.richHistoryUpdatedAction({ richHistory });
|
||||
getRichHistory().then((richHistory) => {
|
||||
this.props.richHistoryUpdatedAction({ richHistory });
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
|
||||
@@ -183,7 +183,7 @@ export function initializeExplore(
|
||||
dispatch(runQueries(exploreId, { replaceUrl: true }));
|
||||
}
|
||||
|
||||
const richHistory = getRichHistory();
|
||||
const richHistory = await getRichHistory();
|
||||
dispatch(richHistoryUpdatedAction({ richHistory }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,25 +24,25 @@ export const historyUpdatedAction = createAction<HistoryUpdatedPayload>('explore
|
||||
//
|
||||
|
||||
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
|
||||
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<void> => {
|
||||
return (dispatch) => {
|
||||
deleteAllFromRichHistory();
|
||||
return async (dispatch) => {
|
||||
await deleteAllFromRichHistory();
|
||||
dispatch(richHistoryUpdatedAction({ richHistory: [] }));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface SyncTimesPayload {
|
||||
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
|
||||
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HistoryItem<DataQuery>>,
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user