mirror of
https://github.com/grafana/grafana.git
synced 2025-08-03 02:42:22 +08:00

* 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 * Add migration for local storage query history * Test query history migration * Simplify testing DataSourceSettings * Skip redundant migrations * Revert error logging test * Fix tests * Update betterer results * Change notification message after migration * 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 * Post-merge fixes * Fix intergration tests * Fix incorrect filtering
275 lines
8.8 KiB
TypeScript
275 lines
8.8 KiB
TypeScript
import { omit } from 'lodash';
|
|
|
|
import { DataQuery, DataSourceApi, dateTimeFormat, ExploreUrlState, urlUtil } from '@grafana/data';
|
|
import { serializeStateToUrlParam } from '@grafana/data/src/utils/url';
|
|
import { getDataSourceSrv } from '@grafana/runtime';
|
|
import { notifyApp } from 'app/core/actions';
|
|
import {
|
|
createErrorNotification,
|
|
createSuccessNotification,
|
|
createWarningNotification,
|
|
} from 'app/core/copy/appNotification';
|
|
import { dispatch } from 'app/store/store';
|
|
import { RichHistoryQuery } from 'app/types/explore';
|
|
|
|
import RichHistoryLocalStorage from '../history/RichHistoryLocalStorage';
|
|
import RichHistoryRemoteStorage from '../history/RichHistoryRemoteStorage';
|
|
import {
|
|
RichHistoryServiceError,
|
|
RichHistoryStorageWarning,
|
|
RichHistoryStorageWarningDetails,
|
|
} from '../history/RichHistoryStorage';
|
|
import { getRichHistoryStorage } from '../history/richHistoryStorageProvider';
|
|
|
|
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from './richHistoryTypes';
|
|
|
|
export { RichHistorySearchFilters, RichHistorySettings, 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 async function addToRichHistory(
|
|
datasourceUid: string,
|
|
datasourceName: string | null,
|
|
queries: DataQuery[],
|
|
starred: boolean,
|
|
comment: string | null,
|
|
showQuotaExceededError: boolean,
|
|
showLimitExceededWarning: boolean
|
|
): Promise<{ richHistoryStorageFull?: boolean; limitExceeded?: boolean }> {
|
|
/* Save only queries, that are not falsy (e.g. empty object, null, ...) */
|
|
const newQueriesToSave: DataQuery[] = queries && queries.filter((query) => notEmptyQuery(query));
|
|
|
|
if (newQueriesToSave.length > 0) {
|
|
let richHistoryStorageFull = false;
|
|
let limitExceeded = false;
|
|
let warning: RichHistoryStorageWarningDetails | undefined;
|
|
|
|
try {
|
|
const result = await getRichHistoryStorage().addToRichHistory({
|
|
datasourceUid: datasourceUid,
|
|
datasourceName: datasourceName ?? '',
|
|
queries: newQueriesToSave,
|
|
starred,
|
|
comment: comment ?? '',
|
|
});
|
|
warning = result.warning;
|
|
} catch (error) {
|
|
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 { 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)));
|
|
}
|
|
|
|
return { richHistoryStorageFull, limitExceeded };
|
|
}
|
|
|
|
// Nothing to change
|
|
return {};
|
|
}
|
|
|
|
export async function getRichHistory(filters: RichHistorySearchFilters): Promise<RichHistoryQuery[]> {
|
|
return await getRichHistoryStorage().getRichHistory(filters);
|
|
}
|
|
|
|
export async function updateRichHistorySettings(settings: RichHistorySettings): Promise<void> {
|
|
await getRichHistoryStorage().updateSettings(settings);
|
|
}
|
|
|
|
export async function getRichHistorySettings(): Promise<RichHistorySettings> {
|
|
return await getRichHistoryStorage().getSettings();
|
|
}
|
|
|
|
export async function deleteAllFromRichHistory(): Promise<void> {
|
|
return getRichHistoryStorage().deleteAll();
|
|
}
|
|
|
|
export async function updateStarredInRichHistory(id: string, starred: boolean) {
|
|
try {
|
|
return await getRichHistoryStorage().updateStarred(id, starred);
|
|
} catch (error) {
|
|
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message)));
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function updateCommentInRichHistory(id: string, newComment: string | undefined) {
|
|
try {
|
|
return await getRichHistoryStorage().updateComment(id, newComment);
|
|
} catch (error) {
|
|
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message)));
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function deleteQueryInRichHistory(id: string) {
|
|
try {
|
|
await getRichHistoryStorage().deleteRichHistory(id);
|
|
return id;
|
|
} catch (error) {
|
|
dispatch(notifyApp(createErrorNotification('Saving rich history failed', error.message)));
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export enum LocalStorageMigrationStatus {
|
|
Successful = 'successful',
|
|
Failed = 'failed',
|
|
NotNeeded = 'not-needed',
|
|
}
|
|
|
|
export async function migrateQueryHistoryFromLocalStorage(): Promise<LocalStorageMigrationStatus> {
|
|
const richHistoryLocalStorage = new RichHistoryLocalStorage();
|
|
const richHistoryRemoteStorage = new RichHistoryRemoteStorage();
|
|
|
|
try {
|
|
const richHistory: RichHistoryQuery[] = await richHistoryLocalStorage.getRichHistory({
|
|
datasourceFilters: [],
|
|
from: 0,
|
|
search: '',
|
|
sortOrder: SortOrder.Descending,
|
|
starred: false,
|
|
to: 14,
|
|
});
|
|
if (richHistory.length === 0) {
|
|
return LocalStorageMigrationStatus.NotNeeded;
|
|
}
|
|
await richHistoryRemoteStorage.migrate(richHistory);
|
|
dispatch(notifyApp(createSuccessNotification('Query history successfully migrated from local storage')));
|
|
return LocalStorageMigrationStatus.Successful;
|
|
} catch (error) {
|
|
dispatch(notifyApp(createWarningNotification(`Query history migration failed. ${error.message}`)));
|
|
return LocalStorageMigrationStatus.Failed;
|
|
}
|
|
}
|
|
|
|
export const createUrlFromRichHistory = (query: RichHistoryQuery) => {
|
|
const exploreState: ExploreUrlState = {
|
|
/* Default range, as we are not saving timerange in rich history */
|
|
range: { from: 'now-1h', to: 'now' },
|
|
datasource: query.datasourceName,
|
|
queries: query.queries,
|
|
context: 'explore',
|
|
};
|
|
|
|
const serializedState = serializeStateToUrlParam(exploreState);
|
|
const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0];
|
|
const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState });
|
|
return url;
|
|
};
|
|
|
|
/* Needed for slider in Rich history to map numerical values to meaningful strings */
|
|
export const mapNumbertoTimeInSlider = (num: number) => {
|
|
let str;
|
|
switch (num) {
|
|
case 0:
|
|
str = 'today';
|
|
break;
|
|
case 1:
|
|
str = 'yesterday';
|
|
break;
|
|
case 7:
|
|
str = 'a week ago';
|
|
break;
|
|
case 14:
|
|
str = 'two weeks ago';
|
|
break;
|
|
default:
|
|
str = `${num} days ago`;
|
|
}
|
|
|
|
return str;
|
|
};
|
|
|
|
export function createDateStringFromTs(ts: number) {
|
|
return dateTimeFormat(ts, {
|
|
format: 'MMMM D',
|
|
});
|
|
}
|
|
|
|
export function getQueryDisplayText(query: DataQuery): string {
|
|
/* If datasource doesn't have getQueryDisplayText, create query display text by
|
|
* stringifying query that was stripped of key, refId and datasource for nicer
|
|
* formatting and improved readability
|
|
*/
|
|
const strippedQuery = omit(query, ['key', 'refId', 'datasource']);
|
|
return JSON.stringify(strippedQuery);
|
|
}
|
|
|
|
export function createQueryHeading(query: RichHistoryQuery, sortOrder: SortOrder) {
|
|
let heading = '';
|
|
if (sortOrder === SortOrder.DatasourceAZ || sortOrder === SortOrder.DatasourceZA) {
|
|
heading = query.datasourceName;
|
|
} else {
|
|
heading = createDateStringFromTs(query.createdAt);
|
|
}
|
|
return heading;
|
|
}
|
|
|
|
export function createQueryText(query: DataQuery, queryDsInstance: DataSourceApi | undefined) {
|
|
/* query DatasourceInstance is necessary because we use its getQueryDisplayText method
|
|
* to format query text
|
|
*/
|
|
if (queryDsInstance?.getQueryDisplayText) {
|
|
return queryDsInstance.getQueryDisplayText(query);
|
|
}
|
|
|
|
return getQueryDisplayText(query);
|
|
}
|
|
|
|
export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortOrder) {
|
|
let mappedQueriesToHeadings: any = {};
|
|
|
|
query.forEach((q) => {
|
|
let heading = createQueryHeading(q, sortOrder);
|
|
if (!(heading in mappedQueriesToHeadings)) {
|
|
mappedQueriesToHeadings[heading] = [q];
|
|
} else {
|
|
mappedQueriesToHeadings[heading] = [...mappedQueriesToHeadings[heading], q];
|
|
}
|
|
});
|
|
|
|
return mappedQueriesToHeadings;
|
|
}
|
|
|
|
/*
|
|
* Create a list of all available data sources
|
|
*/
|
|
export function createDatasourcesList() {
|
|
return getDataSourceSrv()
|
|
.getList()
|
|
.map((dsSettings) => {
|
|
return {
|
|
name: dsSettings.name,
|
|
uid: dsSettings.uid,
|
|
imgUrl: dsSettings.meta.info.logos.small,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function notEmptyQuery(query: DataQuery) {
|
|
/* Check if query has any other properties besides key, refId and datasource.
|
|
* If not, then we consider it empty query.
|
|
*/
|
|
const strippedQuery = omit(query, ['key', 'refId', 'datasource']);
|
|
const queryKeys = Object.keys(strippedQuery);
|
|
|
|
if (queryKeys.length > 0) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|