Query History: Implement RemoteStorage methods: get all, add new (#48330)

* Load Rich History when the container is opened

* Store rich history for each pane separately

* Do not update currently opened query history when an item is added

It's impossible to figure out if the item should be added or not, because filters are applied in the backend. We don't want to replicate that filtering logic in frontend. One way to make it work could be by refreshing both panes.

* Test starring and deleting query history items when both panes are open

* Remove e2e dependency on ExploreId

* Fix unit test

* Assert exact queries

* Simplify test

* Fix e2e tests

* Fix toolbar a11y

* Reload the history after an item is added

* Fix unit test

* Remove references to Explore from generic PageToolbar component

* Update test name

* Fix test assertion

* Add issue item to TODO

* Improve test assertion

* Simplify test setup

* Move query history settings to persistence layer

* Fix test import

* Fix unit test

* Fix unit test

* Test local storage settings API

* Code formatting

* Fix linting errors

* Add an integration test

* Add missing aria role

* Fix a11y issues

* Fix a11y issues

* Use divs instead of ul/li

Otherwis,e pa11y-ci reports the error below claiming there are no children with role=tab:

Certain ARIA roles must contain particular children
   (https://dequeuniversity.com/rules/axe/4.3/aria-required-children?application=axeAPI)

   (#reactRoot > div > main > div:nth-child(3) > div > div:nth-child(1) > div >
   div:nth-child(1) > div > div > nav > div:nth-child(2) > ul)

   <ul class="css-af3vye" role="tablist"><li class="css-1ciwanz"><a href...</ul>

* Clean up settings tab

* Remove redundant aria label

* Remove redundant container

* Clean up test assertions

* Move filtering to persistence layer

* Move filtering to persistence layer

* Simplify applying filters

* Split applying filters and reloading the history

* Debounce updating filters

* Update tests

* Fix waiting for debounced results

* Clear results when switching tabs

* Improve test coverage

* Update docs

* Revert extra handling for uid (will be added when we introduce remote storage)

* Create basic plan

* Rename query history toggle

* Add list of supported features and add ds name to RichHistoryQuery object

* Clean up

Removed planned items will be addressed in upcoming prs (filtering and pagination)

* Handle data source filters

* Simplify DTO conversion

* Clean up

* Fix betterer conflicts

* Fix imports

* Fix imports

* Post-merge fixes

* Use config instead of a feature flag

* Use config instead of a feature flag

* Update converter tests

* Add tests for RichHistoryRemoteStorage

* Simplify test setup

* Simplify assertion

* Add e2e test for query history

* Remove duplicated entry

* Fix unit tests

* Improve readability

* Remove unnecessary casting

* Mock backend in integration tests

* Remove unnecessary casting

* Fix integration test

* Update betterer results

* Fix unit tests

* Simplify testing with DataSourceSrv

* Fix sorting and add to/from filtering

* Simplify testing DataSourceSettings

* Update betterer results

* Ensure previous request is canceled when getting search results

* Add loading message when results are being loaded

* Show info message only if local storage is enabled

* Fix unit test

* Reuse sort order options

* Reuse sort order options

* Fix footer spacing
pull/49071/head
Piotr Jamróz 3 years ago committed by GitHub
parent 9a0f2ec449
commit f252e89339
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .betterer.results
  2. 4
      e2e/various-suite/explore.spec.ts
  3. 4
      packages/grafana-e2e-selectors/src/selectors/components.ts
  4. 79
      public/app/core/history/RichHistoryLocalStorage.test.ts
  5. 84
      public/app/core/history/RichHistoryRemoteStorage.test.ts
  6. 107
      public/app/core/history/RichHistoryRemoteStorage.ts
  7. 25
      public/app/core/history/localStorageConverter.test.ts
  8. 14
      public/app/core/history/localStorageConverter.ts
  9. 46
      public/app/core/history/remoteStorageConverter.test.ts
  10. 19
      public/app/core/history/remoteStorageConverter.ts
  11. 25
      public/app/core/history/richHistoryStorageProvider.ts
  12. 9
      public/app/core/utils/richHistoryTypes.ts
  13. 39
      public/app/features/explore/RichHistory/RichHistory.tsx
  14. 2
      public/app/features/explore/RichHistory/RichHistoryContainer.tsx
  15. 62
      public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx
  16. 2
      public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx
  17. 45
      public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx
  18. 6
      public/app/features/explore/spec/helper/setup.tsx
  19. 15
      public/app/features/explore/state/history.ts

@ -200,7 +200,7 @@ exports[`no enzyme tests`] = {
"public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:4164297658": [ "public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:4164297658": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3933225580": [ "public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:3420464349": [
[0, 17, 13, "RegExp match", "2409514259"] [0, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/folders/FolderSettingsPage.test.tsx:1109052730": [ "public/app/features/folders/FolderSettingsPage.test.tsx:1109052730": [

@ -43,5 +43,9 @@ e2e.scenario({
const canvases = e2e().get('canvas'); const canvases = e2e().get('canvas');
canvases.should('have.length', 1); canvases.should('have.length', 1);
// Both queries above should have been run and be shown in the query history
e2e.components.QueryTab.queryHistoryButton().should('be.visible').click();
e2e.components.QueryHistory.queryText().should('have.length', 2).should('contain', 'csv_metric_values');
}, },
}); });

@ -163,8 +163,12 @@ export const Components = {
QueryTab: { QueryTab: {
content: 'Query editor tab content', content: 'Query editor tab content',
queryInspectorButton: 'Query inspector button', queryInspectorButton: 'Query inspector button',
queryHistoryButton: 'Rich history button',
addQuery: 'Query editor add query button', addQuery: 'Query editor add query button',
}, },
QueryHistory: {
queryText: 'Query text',
},
QueryEditorRows: { QueryEditorRows: {
rows: 'Query editor row', rows: 'Query editor row',
}, },

@ -2,6 +2,7 @@ import { DataQuery } from '@grafana/data';
import store from 'app/core/store'; import store from 'app/core/store';
import { afterEach, beforeEach } from '../../../test/lib/common'; import { afterEach, beforeEach } from '../../../test/lib/common';
import { DatasourceSrv } from '../../features/plugins/datasource_srv';
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
import { backendSrv } from '../services/backend_srv'; import { backendSrv } from '../services/backend_srv';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from '../utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from '../utils/richHistoryTypes';
@ -11,19 +12,21 @@ import { RichHistoryStorageWarning } from './RichHistoryStorage';
const key = 'grafana.explore.richHistory'; const key = 'grafana.explore.richHistory';
const dsMock = new DatasourceSrv();
dsMock.init(
{
// @ts-ignore
'name-of-dev-test': { uid: 'dev-test', name: 'name-of-dev-test' },
// @ts-ignore
'name-of-dev-test-2': { uid: 'dev-test-2', name: 'name-of-dev-test-2' },
},
''
);
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv, getBackendSrv: () => backendSrv,
getDataSourceSrv: () => { getDataSourceSrv: () => dsMock,
return {
getList: () => {
return [
{ uid: 'dev-test-uid', name: 'dev-test' },
{ uid: 'dev-test-2-uid', name: 'dev-test-2' },
];
},
};
},
})); }));
interface MockQuery extends DataQuery { interface MockQuery extends DataQuery {
@ -43,8 +46,8 @@ const mockItem: RichHistoryQuery<MockQuery> = {
id: '2', id: '2',
createdAt: 2, createdAt: 2,
starred: true, starred: true,
datasourceUid: 'dev-test-uid', datasourceUid: 'dev-test',
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'test', comment: 'test',
queries: [{ refId: 'ref', query: 'query-test' }], queries: [{ refId: 'ref', query: 'query-test' }],
}; };
@ -53,8 +56,8 @@ const mockItem2: RichHistoryQuery<MockQuery> = {
id: '3', id: '3',
createdAt: 3, createdAt: 3,
starred: true, starred: true,
datasourceUid: 'dev-test-2-uid', datasourceUid: 'dev-test-2',
datasourceName: 'dev-test-2', datasourceName: 'name-of-dev-test-2',
comment: 'test-2', comment: 'test-2',
queries: [{ refId: 'ref-2', query: 'query-2' }], queries: [{ refId: 'ref-2', query: 'query-2' }],
}; };
@ -130,17 +133,41 @@ describe('RichHistoryLocalStorage', () => {
describe('retention policy and max limits', () => { describe('retention policy and max limits', () => {
it('should clear old not-starred items', async () => { it('should clear old not-starred items', async () => {
const historyStarredOld = { starred: true, ts: old.getTime(), queries: [], comment: 'old starred' }; const historyStarredOld = {
const historyNotStarredOld = { starred: false, ts: old.getTime(), queries: [], comment: 'new not starred' }; starred: true,
const historyStarredNew = { starred: true, ts: now.getTime(), queries: [], comment: 'new starred' }; ts: old.getTime(),
const historyNotStarredNew = { starred: false, ts: now.getTime(), queries: [], comment: 'new not starred' }; queries: [],
comment: 'old starred',
datasourceName: 'name-of-dev-test',
};
const historyNotStarredOld = {
starred: false,
ts: old.getTime(),
queries: [],
comment: 'new not starred',
datasourceName: 'name-of-dev-test',
};
const historyStarredNew = {
starred: true,
ts: now.getTime(),
queries: [],
comment: 'new starred',
datasourceName: 'name-of-dev-test',
};
const historyNotStarredNew = {
starred: false,
ts: now.getTime(),
queries: [],
comment: 'new not starred',
datasourceName: 'name-of-dev-test',
};
const history = [historyNotStarredNew, historyStarredNew, historyStarredOld, historyNotStarredOld]; const history = [historyNotStarredNew, historyStarredNew, historyStarredOld, historyNotStarredOld];
store.setObject(key, history); store.setObject(key, history);
const historyNew = { const historyNew = {
starred: true, starred: true,
datasourceUid: 'dev-test-uid', datasourceUid: 'dev-test',
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'recently added', comment: 'recently added',
queries: [{ refId: 'ref' }], queries: [{ refId: 'ref' }],
}; };
@ -209,7 +236,7 @@ describe('RichHistoryLocalStorage', () => {
{ {
ts: 2, ts: 2,
starred: true, starred: true,
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'test', comment: 'test',
queries: ['test query 1', 'test query 2', 'test query 3'], queries: ['test query 1', 'test query 2', 'test query 3'],
}, },
@ -218,8 +245,8 @@ describe('RichHistoryLocalStorage', () => {
id: '2', id: '2',
createdAt: 2, createdAt: 2,
starred: true, starred: true,
datasourceUid: 'dev-test-uid', datasourceUid: 'dev-test',
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'test', comment: 'test',
queries: [ queries: [
{ {
@ -246,7 +273,7 @@ describe('RichHistoryLocalStorage', () => {
{ {
ts: 2, ts: 2,
starred: true, starred: true,
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'test', comment: 'test',
queries: ['{"refId":"A","key":"key1","metrics":[]}', '{"refId":"B","key":"key2","metrics":[]}'], queries: ['{"refId":"A","key":"key1","metrics":[]}', '{"refId":"B","key":"key2","metrics":[]}'],
}, },
@ -255,8 +282,8 @@ describe('RichHistoryLocalStorage', () => {
id: '2', id: '2',
createdAt: 2, createdAt: 2,
starred: true, starred: true,
datasourceUid: 'dev-test-uid', datasourceUid: 'dev-test',
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
comment: 'test', comment: 'test',
queries: [ queries: [
{ {

@ -0,0 +1,84 @@
import { of } from 'rxjs';
import { DatasourceSrv } from '../../features/plugins/datasource_srv';
import { RichHistoryQuery } from '../../types';
import { SortOrder } from '../utils/richHistoryTypes';
import RichHistoryRemoteStorage, { RichHistoryRemoteStorageDTO } from './RichHistoryRemoteStorage';
const dsMock = new DatasourceSrv();
dsMock.init(
{
// @ts-ignore
'name-of-ds1': { uid: 'ds1', name: 'name-of-ds1' },
// @ts-ignore
'name-of-ds2': { uid: 'ds2', name: 'name-of-ds2' },
},
''
);
const fetchMock = jest.fn();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
fetch: fetchMock,
}),
getDataSourceSrv: () => dsMock,
}));
describe('RichHistoryRemoteStorage', () => {
let storage: RichHistoryRemoteStorage;
beforeEach(() => {
fetchMock.mockReset();
storage = new RichHistoryRemoteStorage();
});
it('returns list of query history items', async () => {
const expectedViewModel: RichHistoryQuery<any> = {
id: '123',
createdAt: 200 * 1000,
datasourceUid: 'ds1',
datasourceName: 'name-of-ds1',
starred: true,
comment: 'comment',
queries: [{ foo: 'bar ' }],
};
const returnedDTOs: RichHistoryRemoteStorageDTO[] = [
{
uid: expectedViewModel.id,
createdAt: expectedViewModel.createdAt / 1000,
datasourceUid: expectedViewModel.datasourceUid,
starred: expectedViewModel.starred,
comment: expectedViewModel.comment,
queries: expectedViewModel.queries,
},
];
fetchMock.mockReturnValue(
of({
data: {
result: {
queryHistory: returnedDTOs,
},
},
})
);
const search = 'foo';
const datasourceFilters = ['name-of-ds1', 'name-of-ds2'];
const sortOrder = SortOrder.Descending;
const starred = true;
const from = 100;
const to = 200;
const expectedLimit = 100;
const expectedPage = 1;
const items = await storage.getRichHistory({ search, datasourceFilters, sortOrder, starred, to, from });
expect(fetchMock).toBeCalledWith({
method: 'GET',
url: `/api/query-history?datasourceUid=ds1&datasourceUid=ds2&searchString=${search}&sort=time-desc&to=now-${from}d&from=now-${to}d&limit=${expectedLimit}&page=${expectedPage}&onlyStarred=${starred}`,
requestId: 'query-history-get-all',
});
expect(items).toMatchObject([expectedViewModel]);
});
});

@ -0,0 +1,107 @@
import { lastValueFrom } from 'rxjs';
import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime';
import { RichHistoryQuery } from 'app/types/explore';
import { DataQuery } from '../../../../packages/grafana-data';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from '../utils/richHistoryTypes';
import RichHistoryStorage, { RichHistoryStorageWarningDetails } from './RichHistoryStorage';
import { fromDTO } from './remoteStorageConverter';
export type RichHistoryRemoteStorageDTO = {
uid: string;
createdAt: number;
datasourceUid: string;
starred: boolean;
comment: string;
queries: DataQuery[];
};
type RichHistoryRemoteStorageResultsPayloadDTO = {
result: {
queryHistory: RichHistoryRemoteStorageDTO[];
};
};
export default class RichHistoryRemoteStorage implements RichHistoryStorage {
async addToRichHistory(
newRichHistoryQuery: Omit<RichHistoryQuery, 'id' | 'createdAt'>
): Promise<{ warning?: RichHistoryStorageWarningDetails; richHistoryQuery: RichHistoryQuery }> {
const { result } = await getBackendSrv().post(`/api/query-history`, {
dataSourceUid: newRichHistoryQuery.datasourceUid,
queries: newRichHistoryQuery.queries,
});
return {
richHistoryQuery: fromDTO(result),
};
}
async deleteAll(): Promise<void> {
throw new Error('not supported');
}
async deleteRichHistory(id: string): Promise<void> {
throw new Error('not supported yet');
}
async getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]> {
const params = buildQueryParams(filters);
const queryHistory = await lastValueFrom(
getBackendSrv().fetch({
method: 'GET',
url: `/api/query-history?${params}`,
// to ensure any previous requests are cancelled
requestId: 'query-history-get-all',
})
);
return ((queryHistory.data as RichHistoryRemoteStorageResultsPayloadDTO).result.queryHistory || []).map(fromDTO);
}
async getSettings(): Promise<RichHistorySettings> {
return {
activeDatasourceOnly: false,
lastUsedDatasourceFilters: undefined,
retentionPeriod: 14,
starredTabAsFirstTab: false,
};
}
async updateComment(id: string, comment: string | undefined): Promise<RichHistoryQuery> {
throw new Error('not supported yet');
}
async updateSettings(settings: RichHistorySettings): Promise<void> {
throw new Error('not supported yet');
}
async updateStarred(id: string, starred: boolean): Promise<RichHistoryQuery> {
throw new Error('not supported yet');
}
}
function buildQueryParams(filters: RichHistorySearchFilters): string {
let params = `${filters.datasourceFilters
.map((datasourceName) => {
const uid = getDataSourceSrv().getInstanceSettings(datasourceName)!.uid;
return `datasourceUid=${encodeURIComponent(uid)}`;
})
.join('&')}`;
if (filters.search) {
params = params + `&searchString=${filters.search}`;
}
if (filters.sortOrder) {
params = params + `&sort=${filters.sortOrder === SortOrder.Ascending ? 'time-asc' : 'time-desc'}`;
}
const relativeFrom = filters.from === 0 ? 'now' : `now-${filters.from}d`;
const relativeTo = filters.to === 0 ? 'now' : `now-${filters.to}d`;
// TODO: Unify: remote storage from/to params are swapped comparing to frontend and local storage filters
params = params + `&to=${relativeFrom}`;
params = params + `&from=${relativeTo}`;
params = params + `&limit=100`;
params = params + `&page=1`;
if (filters.starred) {
params = params + `&onlyStarred=${filters.starred}`;
}
return params;
}

@ -1,26 +1,31 @@
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
import { backendSrv } from '../services/backend_srv'; import { backendSrv } from '../services/backend_srv';
import { RichHistoryLocalStorageDTO } from './RichHistoryLocalStorage'; import { RichHistoryLocalStorageDTO } from './RichHistoryLocalStorage';
import { fromDTO, toDTO } from './localStorageConverter'; import { fromDTO, toDTO } from './localStorageConverter';
const dsMock = new DatasourceSrv();
dsMock.init(
{
// @ts-ignore
'name-of-dev-test': { uid: 'dev-test', name: 'name-of-dev-test' },
},
''
);
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv, getBackendSrv: () => backendSrv,
getDataSourceSrv: () => { getDataSourceSrv: () => dsMock,
return {
getList: () => {
return [{ uid: 'uid', name: 'dev-test' }];
},
};
},
})); }));
const validRichHistory: RichHistoryQuery = { const validRichHistory: RichHistoryQuery = {
comment: 'comment', comment: 'comment',
createdAt: 1, createdAt: 1,
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
datasourceUid: 'uid', datasourceUid: 'dev-test',
id: '1', id: '1',
queries: [{ refId: 'A' }], queries: [{ refId: 'A' }],
starred: true, starred: true,
@ -28,7 +33,7 @@ const validRichHistory: RichHistoryQuery = {
const validDTO: RichHistoryLocalStorageDTO = { const validDTO: RichHistoryLocalStorageDTO = {
comment: 'comment', comment: 'comment',
datasourceName: 'dev-test', datasourceName: 'name-of-dev-test',
queries: [{ refId: 'A' }], queries: [{ refId: 'A' }],
starred: true, starred: true,
ts: 1, ts: 1,

@ -1,6 +1,3 @@
import { find } from 'lodash';
import { DataSourceInstanceSettings } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { RichHistoryQuery } from '../../types'; import { RichHistoryQuery } from '../../types';
@ -8,11 +5,7 @@ import { RichHistoryQuery } from '../../types';
import { RichHistoryLocalStorageDTO } from './RichHistoryLocalStorage'; import { RichHistoryLocalStorageDTO } from './RichHistoryLocalStorage';
export const fromDTO = (dto: RichHistoryLocalStorageDTO): RichHistoryQuery => { export const fromDTO = (dto: RichHistoryLocalStorageDTO): RichHistoryQuery => {
const datasource = find( const datasource = getDataSourceSrv().getInstanceSettings(dto.datasourceName);
getDataSourceSrv().getList(),
(settings: DataSourceInstanceSettings) => settings.name === dto.datasourceName
);
return { return {
id: dto.ts.toString(), id: dto.ts.toString(),
createdAt: dto.ts, createdAt: dto.ts,
@ -25,10 +18,7 @@ export const fromDTO = (dto: RichHistoryLocalStorageDTO): RichHistoryQuery => {
}; };
export const toDTO = (richHistoryQuery: RichHistoryQuery): RichHistoryLocalStorageDTO => { export const toDTO = (richHistoryQuery: RichHistoryQuery): RichHistoryLocalStorageDTO => {
const datasource = find( const datasource = getDataSourceSrv().getInstanceSettings({ uid: richHistoryQuery.datasourceUid });
getDataSourceSrv().getList(),
(settings: DataSourceInstanceSettings) => settings.uid === richHistoryQuery.datasourceUid
);
if (!datasource) { if (!datasource) {
throw new Error('Datasource not found.'); throw new Error('Datasource not found.');

@ -0,0 +1,46 @@
import { DatasourceSrv } from '../../features/plugins/datasource_srv';
import { RichHistoryQuery } from '../../types';
import { backendSrv } from '../services/backend_srv';
import { RichHistoryRemoteStorageDTO } from './RichHistoryRemoteStorage';
import { fromDTO } from './remoteStorageConverter';
const dsMock = new DatasourceSrv();
dsMock.init(
{
// @ts-ignore
'name-of-dev-test': { uid: 'dev-test', name: 'name-of-dev-test' },
},
''
);
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
getDataSourceSrv: () => dsMock,
}));
const validRichHistory: RichHistoryQuery = {
comment: 'comment',
createdAt: 1000,
datasourceName: 'name-of-dev-test',
datasourceUid: 'dev-test',
id: 'ID',
queries: [{ refId: 'A' }],
starred: true,
};
const validDTO: RichHistoryRemoteStorageDTO = {
comment: 'comment',
datasourceUid: 'dev-test',
queries: [{ refId: 'A' }],
starred: true,
uid: 'ID',
createdAt: 1,
};
describe('RemoteStorage converter', () => {
it('converts DTO to RichHistoryQuery', () => {
expect(fromDTO(validDTO)).toMatchObject(validRichHistory);
});
});

@ -0,0 +1,19 @@
import { getDataSourceSrv } from '@grafana/runtime';
import { RichHistoryQuery } from '../../types';
import { RichHistoryRemoteStorageDTO } from './RichHistoryRemoteStorage';
export const fromDTO = (dto: RichHistoryRemoteStorageDTO): RichHistoryQuery => {
const datasource = getDataSourceSrv().getInstanceSettings({ uid: dto.datasourceUid });
return {
id: dto.uid,
createdAt: dto.createdAt * 1000,
datasourceName: datasource?.name || '', // will be show on the list as coming from a removed data source
datasourceUid: dto.datasourceUid,
starred: dto.starred,
comment: dto.comment,
queries: dto.queries,
};
};

@ -1,8 +1,31 @@
import { config } from '@grafana/runtime';
import { SortOrder } from '../utils/richHistoryTypes';
import RichHistoryLocalStorage from './RichHistoryLocalStorage'; import RichHistoryLocalStorage from './RichHistoryLocalStorage';
import RichHistoryRemoteStorage from './RichHistoryRemoteStorage';
import RichHistoryStorage from './RichHistoryStorage'; import RichHistoryStorage from './RichHistoryStorage';
const richHistoryLocalStorage = new RichHistoryLocalStorage(); const richHistoryLocalStorage = new RichHistoryLocalStorage();
const richHistoryRemoteStorage = new RichHistoryRemoteStorage();
export const getRichHistoryStorage = (): RichHistoryStorage => { export const getRichHistoryStorage = (): RichHistoryStorage => {
return richHistoryLocalStorage; return config.queryHistoryEnabled ? richHistoryRemoteStorage : richHistoryLocalStorage;
};
interface RichHistorySupportedFeatures {
availableFilters: SortOrder[];
lastUsedDataSourcesAvailable: boolean;
}
export const supportedFeatures = (): RichHistorySupportedFeatures => {
return config.queryHistoryEnabled
? {
availableFilters: [SortOrder.Descending, SortOrder.Ascending],
lastUsedDataSourcesAvailable: false,
}
: {
availableFilters: [SortOrder.Descending, SortOrder.Ascending, SortOrder.DatasourceAZ, SortOrder.DatasourceZA],
lastUsedDataSourcesAvailable: true,
};
}; };

@ -1,7 +1,13 @@
export enum SortOrder { export enum SortOrder {
Descending = 'Descending', Descending = 'Descending',
Ascending = 'Ascending', Ascending = 'Ascending',
/**
* @deprecated supported only by local storage. It will be removed in the future
*/
DatasourceAZ = 'Datasource A-Z', DatasourceAZ = 'Datasource A-Z',
/**
* @deprecated supported only by local storage. It will be removed in the future
*/
DatasourceZA = 'Datasource Z-A', DatasourceZA = 'Datasource Z-A',
} }
@ -9,12 +15,13 @@ export interface RichHistorySettings {
retentionPeriod: number; retentionPeriod: number;
starredTabAsFirstTab: boolean; starredTabAsFirstTab: boolean;
activeDatasourceOnly: boolean; activeDatasourceOnly: boolean;
lastUsedDatasourceFilters: string[]; lastUsedDatasourceFilters?: string[];
} }
export type RichHistorySearchFilters = { export type RichHistorySearchFilters = {
search: string; search: string;
sortOrder: SortOrder; sortOrder: SortOrder;
/** Names of data sources (not uids) - used by local and remote storage **/
datasourceFilters: string[]; datasourceFilters: string[];
from: number; from: number;
to: number; to: number;

@ -6,6 +6,8 @@ import { Themeable, withTheme, TabbedContainer, TabConfig } from '@grafana/ui';
import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory'; import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; import { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
import { RichHistorySettingsTab } from './RichHistorySettingsTab'; import { RichHistorySettingsTab } from './RichHistorySettingsTab';
import { RichHistoryStarredTab } from './RichHistoryStarredTab'; import { RichHistoryStarredTab } from './RichHistoryStarredTab';
@ -16,12 +18,13 @@ export enum Tabs {
Settings = 'Settings', Settings = 'Settings',
} }
export const sortOrderOptions = [ export const getSortOrderOptions = () =>
{ label: 'Newest first', value: SortOrder.Descending }, [
{ label: 'Oldest first', value: SortOrder.Ascending }, { label: 'Newest first', value: SortOrder.Descending },
{ label: 'Data source A-Z', value: SortOrder.DatasourceAZ }, { label: 'Oldest first', value: SortOrder.Ascending },
{ label: 'Data source Z-A', value: SortOrder.DatasourceZA }, { label: 'Data source A-Z', value: SortOrder.DatasourceAZ },
]; { label: 'Data source Z-A', value: SortOrder.DatasourceZA },
].filter((option) => supportedFeatures().availableFilters.includes(option.value));
export interface RichHistoryProps extends Themeable { export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
@ -32,14 +35,22 @@ export interface RichHistoryProps extends Themeable {
loadRichHistory: (exploreId: ExploreId) => void; loadRichHistory: (exploreId: ExploreId) => void;
clearRichHistoryResults: (exploreId: ExploreId) => void; clearRichHistoryResults: (exploreId: ExploreId) => void;
deleteRichHistory: () => void; deleteRichHistory: () => void;
activeDatasourceInstance?: string; activeDatasourceInstance: string;
firstTab: Tabs; firstTab: Tabs;
exploreId: ExploreId; exploreId: ExploreId;
height: number; height: number;
onClose: () => void; onClose: () => void;
} }
type RichHistoryState = {
loading: boolean;
};
class UnThemedRichHistory extends PureComponent<RichHistoryProps> { class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
state: RichHistoryState = {
loading: false,
};
updateSettings = (settingsToUpdate: Partial<RichHistorySettings>) => { updateSettings = (settingsToUpdate: Partial<RichHistorySettings>) => {
this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate }); this.props.updateHistorySettings({ ...this.props.richHistorySettings, ...settingsToUpdate });
}; };
@ -59,6 +70,9 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
loadRichHistory = debounce(() => { loadRichHistory = debounce(() => {
this.props.loadRichHistory(this.props.exploreId); this.props.loadRichHistory(this.props.exploreId);
this.setState({
loading: true,
});
}, 300); }, 300);
onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => { onChangeRetentionPeriod = (retentionPeriod: SelectableValue<number>) => {
@ -73,9 +87,18 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
toggleActiveDatasourceOnly = () => toggleActiveDatasourceOnly = () =>
this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly }); this.updateSettings({ activeDatasourceOnly: !this.props.richHistorySettings.activeDatasourceOnly });
componentDidUpdate(prevProps: Readonly<RichHistoryProps>, prevState: Readonly<{}>, snapshot?: any) {
if (prevProps.richHistory !== this.props.richHistory) {
this.setState({
loading: false,
});
}
}
render() { render() {
const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab, activeDatasourceInstance } = const { richHistory, height, exploreId, deleteRichHistory, onClose, firstTab, activeDatasourceInstance } =
this.props; this.props;
const { loading } = this.state;
const QueriesTab: TabConfig = { const QueriesTab: TabConfig = {
label: 'Query history', label: 'Query history',
@ -83,6 +106,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryQueriesTab <RichHistoryQueriesTab
queries={richHistory} queries={richHistory}
loading={loading}
updateFilters={this.updateFilters} updateFilters={this.updateFilters}
clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
@ -101,6 +125,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps> {
content: ( content: (
<RichHistoryStarredTab <RichHistoryStarredTab
queries={richHistory} queries={richHistory}
loading={loading}
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
updateFilters={this.updateFilters} updateFilters={this.updateFilters}
clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)} clearRichHistoryResults={() => this.props.clearRichHistoryResults(this.props.exploreId)}

@ -34,7 +34,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
return { return {
richHistory, richHistory,
firstTab, firstTab,
activeDatasourceInstance: datasourceInstance?.name, activeDatasourceInstance: datasourceInstance!.name,
richHistorySettings, richHistorySettings,
richHistorySearchFilters, richHistorySearchFilters,
}; };

@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { FilterInput, MultiSelect, RangeSlider, Select, stylesFactory, useTheme } from '@grafana/ui'; import { FilterInput, MultiSelect, RangeSlider, Select, stylesFactory, useTheme } from '@grafana/ui';
import { import {
createDatasourcesList, createDatasourcesList,
@ -13,12 +14,13 @@ import {
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { ExploreId, RichHistoryQuery } from 'app/types/explore'; import { ExploreId, RichHistoryQuery } from 'app/types/explore';
import { sortOrderOptions } from './RichHistory'; import { getSortOrderOptions } from './RichHistory';
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
activeDatasourceInstance?: string; loading: boolean;
activeDatasourceInstance: string;
updateFilters: (filtersToUpdate?: Partial<RichHistorySearchFilters>) => void; updateFilters: (filtersToUpdate?: Partial<RichHistorySearchFilters>) => void;
clearRichHistoryResults: () => void; clearRichHistoryResults: () => void;
richHistorySettings: RichHistorySettings; richHistorySettings: RichHistorySettings;
@ -119,6 +121,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => {
export function RichHistoryQueriesTab(props: Props) { export function RichHistoryQueriesTab(props: Props) {
const { const {
queries, queries,
loading,
richHistorySearchFilters, richHistorySearchFilters,
updateFilters, updateFilters,
clearRichHistoryResults, clearRichHistoryResults,
@ -135,9 +138,9 @@ export function RichHistoryQueriesTab(props: Props) {
useEffect(() => { useEffect(() => {
const datasourceFilters = const datasourceFilters =
richHistorySettings.activeDatasourceOnly && activeDatasourceInstance richHistorySettings.activeDatasourceOnly && richHistorySettings.lastUsedDatasourceFilters
? [activeDatasourceInstance] ? richHistorySettings.lastUsedDatasourceFilters
: richHistorySettings.lastUsedDatasourceFilters; : [activeDatasourceInstance];
const filters: RichHistorySearchFilters = { const filters: RichHistorySearchFilters = {
search: '', search: '',
sortOrder: SortOrder.Descending, sortOrder: SortOrder.Descending,
@ -162,6 +165,7 @@ export function RichHistoryQueriesTab(props: Props) {
* are keys and arrays with queries that belong to that headings are values. * are keys and arrays with queries that belong to that headings are values.
*/ */
const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder); const mappedQueriesToHeadings = mapQueriesToHeadings(queries, richHistorySearchFilters.sortOrder);
const sortOrderOptions = getSortOrderOptions();
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -219,28 +223,34 @@ export function RichHistoryQueriesTab(props: Props) {
/> />
</div> </div>
</div> </div>
{Object.keys(mappedQueriesToHeadings).map((heading) => {
return ( {loading && <span>Loading results...</span>}
<div key={heading}>
<div className={styles.heading}> {!loading &&
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span> Object.keys(mappedQueriesToHeadings).map((heading) => {
return (
<div key={heading}>
<div className={styles.heading}>
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span>
</div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName);
return (
<RichHistoryCard
query={q}
key={q.id}
exploreId={exploreId}
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
isRemoved={idx === -1}
/>
);
})}
</div> </div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => { );
const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName); })}
return ( <div className={styles.footer}>
<RichHistoryCard {!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''}
query={q} </div>
key={q.id}
exploreId={exploreId}
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
isRemoved={idx === -1}
/>
);
})}
</div>
);
})}
<div className={styles.footer}>The history is local to your browser and is not shared with others.</div>
</div> </div>
</div> </div>
); );

@ -21,6 +21,8 @@ jest.mock('@grafana/runtime', () => ({
const setup = (activeDatasourceOnly = false) => { const setup = (activeDatasourceOnly = false) => {
const props: Props = { const props: Props = {
queries: [], queries: [],
loading: false,
activeDatasourceInstance: {} as any,
updateFilters: jest.fn(), updateFilters: jest.fn(),
clearRichHistoryResults: jest.fn(), clearRichHistoryResults: jest.fn(),
exploreId: ExploreId.left, exploreId: ExploreId.left,

@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory, useTheme, Select, MultiSelect, FilterInput } from '@grafana/ui'; import { stylesFactory, useTheme, Select, MultiSelect, FilterInput } from '@grafana/ui';
import { import {
createDatasourcesList, createDatasourcesList,
@ -11,12 +12,13 @@ import {
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { sortOrderOptions } from './RichHistory'; import { getSortOrderOptions } from './RichHistory';
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
export interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
activeDatasourceInstance?: string; loading: boolean;
activeDatasourceInstance: string;
updateFilters: (filtersToUpdate: Partial<RichHistorySearchFilters>) => void; updateFilters: (filtersToUpdate: Partial<RichHistorySearchFilters>) => void;
clearRichHistoryResults: () => void; clearRichHistoryResults: () => void;
richHistorySearchFilters?: RichHistorySearchFilters; richHistorySearchFilters?: RichHistorySearchFilters;
@ -75,6 +77,7 @@ export function RichHistoryStarredTab(props: Props) {
activeDatasourceInstance, activeDatasourceInstance,
richHistorySettings, richHistorySettings,
queries, queries,
loading,
richHistorySearchFilters, richHistorySearchFilters,
exploreId, exploreId,
} = props; } = props;
@ -86,9 +89,9 @@ export function RichHistoryStarredTab(props: Props) {
useEffect(() => { useEffect(() => {
const datasourceFilters = const datasourceFilters =
richHistorySettings.activeDatasourceOnly && activeDatasourceInstance richHistorySettings.activeDatasourceOnly && richHistorySettings.lastUsedDatasourceFilters
? [activeDatasourceInstance] ? richHistorySettings.lastUsedDatasourceFilters
: richHistorySettings.lastUsedDatasourceFilters; : [activeDatasourceInstance];
const filters: RichHistorySearchFilters = { const filters: RichHistorySearchFilters = {
search: '', search: '',
sortOrder: SortOrder.Descending, sortOrder: SortOrder.Descending,
@ -108,6 +111,8 @@ export function RichHistoryStarredTab(props: Props) {
return <span>Loading...</span>; return <span>Loading...</span>;
} }
const sortOrderOptions = getSortOrderOptions();
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.containerContent}> <div className={styles.containerContent}>
@ -142,19 +147,23 @@ export function RichHistoryStarredTab(props: Props) {
/> />
</div> </div>
</div> </div>
{queries.map((q) => { {loading && <span>Loading results...</span>}
const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName); {!loading &&
return ( queries.map((q) => {
<RichHistoryCard const idx = listOfDatasources.findIndex((d) => d.name === q.datasourceName);
query={q} return (
key={q.id} <RichHistoryCard
exploreId={exploreId} query={q}
dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl} key={q.id}
isRemoved={idx === -1} exploreId={exploreId}
/> dsImg={idx === -1 ? 'public/img/icn-datasource.svg' : listOfDatasources[idx].imgUrl}
); isRemoved={idx === -1}
})} />
<div className={styles.footer}>The history is local to your browser and is not shared with others.</div> );
})}
<div className={styles.footer}>
{!config.queryHistoryEnabled ? 'The history is local to your browser and is not shared with others.' : ''}
</div>
</div> </div>
</div> </div>
); );

@ -5,7 +5,7 @@ import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Route, Router } from 'react-router-dom'; import { Route, Router } from 'react-router-dom';
import { DataSourceApi, DataSourceInstanceSettings, QueryEditorProps, ScopedVars } from '@grafana/data'; import { DataSourceApi, DataSourceInstanceSettings, DataSourceRef, QueryEditorProps, ScopedVars } from '@grafana/data';
import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime'; import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo'; import { Echo } from 'app/core/services/echo/Echo';
@ -51,8 +51,8 @@ export function setupExplore(options?: SetupOptions): {
getList(): DataSourceInstanceSettings[] { getList(): DataSourceInstanceSettings[] {
return dsSettings.map((d) => d.settings); return dsSettings.map((d) => d.settings);
}, },
getInstanceSettings(name: string) { getInstanceSettings(ref: DataSourceRef) {
return dsSettings.map((d) => d.settings).find((x) => x.name === name || x.uid === name); return dsSettings.map((d) => d.settings).find((x) => x.name === ref || x.uid === ref || x.uid === ref.uid);
}, },
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> { get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
return Promise.resolve( return Promise.resolve(

@ -13,6 +13,7 @@ import {
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types'; import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery, ThunkResult } from 'app/types';
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes'; import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/utils/richHistoryTypes';
import { import {
@ -164,12 +165,14 @@ export const updateHistorySearchFilters = (
return async (dispatch, getState) => { return async (dispatch, getState) => {
await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } })); await dispatch(richHistorySearchFiltersUpdatedAction({ exploreId, filters: { ...filters } }));
const currentSettings = getState().explore.richHistorySettings!; const currentSettings = getState().explore.richHistorySettings!;
await dispatch( if (supportedFeatures().lastUsedDataSourcesAvailable) {
updateHistorySettings({ await dispatch(
...currentSettings, updateHistorySettings({
lastUsedDatasourceFilters: filters.datasourceFilters, ...currentSettings,
}) lastUsedDatasourceFilters: filters.datasourceFilters,
); })
);
}
}; };
}; };

Loading…
Cancel
Save