From 62c824e3a49c7ac507c24a4b921f1100e031e1c7 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Tue, 10 Mar 2020 15:08:15 +0100 Subject: [PATCH] Explore: Rich History (#22570) * Explore: Refactor active buttons css * Explore: Add query history button * WIP: Creating drawer * WIP: Create custom drawer (for now) * Revert changes to Drawer.tsx * WIP: Layout finished * Rich History: Set up boilerplate for Settings * WIP: Query history cards * Refactor, split components * Add resizability, interactivity * Save history to local storage * Visualise queries from queryhistory local storage * Set up query history settings * Refactor * Create link, un-refactored verison * Copyable url * WIP: Add slider * Commenting feature * Slider filtration * Add headings * Hide Rich history behind feature toggle * Cleaning up, refactors * Update tests * Implement getQueryDisplayText * Update lockfile for new dependencies * Update heading based on sorting * Fix typescript strinctNullCheck errors * Fix Forms, new forms * Fixes based on provided feedback * Fixes, splitting component into two * Add tooltips, add delete history button * Clicking on card adds queries to query rows * Delete history, width of drawers * UI/UX changes, updates * Add number of queries to headings, box shadows * Fix slider, remove feature toggle * Fix typo in the beta announcement * Fix how the rich history state is rendered when initialization * Move updateFilters when activeDatasourceFilter onlyto RichHistory, remove duplicated code * Fix typescript strictnull errors, not used variables errors --- package.json | 1 + .../src/components/Slider/Slider.tsx | 10 +- public/app/core/utils/explore.ts | 2 + public/app/core/utils/richHistory.ts | 253 ++++++++++++++++++ public/app/features/explore/Explore.tsx | 46 +++- .../explore/RichHistory/RichHistory.tsx | 235 ++++++++++++++++ .../explore/RichHistory/RichHistoryCard.tsx | 217 +++++++++++++++ .../RichHistory/RichHistoryContainer.tsx | 145 ++++++++++ .../RichHistory/RichHistoryQueriesTab.tsx | 227 ++++++++++++++++ .../RichHistory/RichHistorySettings.tsx | 112 ++++++++ .../RichHistory/RichHistoryStarredTab.tsx | 138 ++++++++++ .../app/features/explore/state/actionTypes.ts | 2 + public/app/features/explore/state/actions.ts | 51 ++++ public/app/features/explore/state/reducers.ts | 11 +- .../app/plugins/datasource/loki/datasource.ts | 4 + public/app/types/explore.ts | 15 ++ public/test/mocks/mockExploreState.ts | 4 +- yarn.lock | 12 + 18 files changed, 1474 insertions(+), 11 deletions(-) create mode 100644 public/app/core/utils/richHistory.ts create mode 100644 public/app/features/explore/RichHistory/RichHistory.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryCard.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryContainer.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistorySettings.tsx create mode 100644 public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx diff --git a/package.json b/package.json index df313dcdd09..981cbe6ceed 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,7 @@ "prismjs": "1.16.0", "prop-types": "15.7.2", "rc-cascader": "0.17.5", + "re-resizable": "^6.2.0", "react": "16.12.0", "react-dom": "16.12.0", "react-grid-layout": "0.17.1", diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index 585e0e0e037..5848d3ff211 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -14,8 +14,10 @@ export interface Props { /** Set current positions of handle(s). If only 1 value supplied, only 1 handle displayed. */ value?: number[]; reverse?: boolean; + tooltipAlwaysVisible?: boolean; formatTooltipResult?: (value: number) => number | string; onChange?: (values: number[]) => void; + onAfterChange?: (values: number[]) => void; } const getStyles = stylesFactory((theme: GrafanaTheme, isHorizontal: boolean) => { @@ -98,10 +100,12 @@ export const Slider: FunctionComponent = ({ min, max, onChange, + onAfterChange, orientation = 'horizontal', reverse, formatTooltipResult, value, + tooltipAlwaysVisible = true, }) => { const isHorizontal = orientation === 'horizontal'; const theme = useTheme(); @@ -112,12 +116,16 @@ export const Slider: FunctionComponent = ({ {/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */} (formatTooltipResult ? formatTooltipResult(value) : value)} onChange={onChange} + onAfterChange={onAfterChange} vertical={!isHorizontal} reverse={reverse} /> diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 9ca47ffbc5e..8a44a585908 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -500,6 +500,8 @@ const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { export enum SortOrder { Descending = 'Descending', Ascending = 'Ascending', + DatasourceAZ = 'Datasource A-Z', + DatasourceZA = 'Datasource Z-A', } export const refreshIntervalToSortOrder = (refreshInterval?: string) => diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts new file mode 100644 index 00000000000..76d038c07bf --- /dev/null +++ b/public/app/core/utils/richHistory.ts @@ -0,0 +1,253 @@ +// Libraries +import _ from 'lodash'; + +// Services & Utils +import { DataQuery, ExploreMode } from '@grafana/data'; +import { renderUrl } from 'app/core/utils/url'; +import store from 'app/core/store'; +import { serializeStateToUrlParam, SortOrder } from './explore'; + +// Types +import { ExploreUrlState, RichHistoryQuery } from 'app/types/explore'; + +const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; + +export const RICH_HISTORY_SETTING_KEYS = { + retentionPeriod: `${RICH_HISTORY_KEY}.retentionPeriod`, + starredTabAsFirstTab: `${RICH_HISTORY_KEY}.starredTabAsFirstTab`, + activeDatasourceOnly: `${RICH_HISTORY_KEY}.activeDatasourceOnly`, +}; + +/* + * Add queries to rich history. Save only queries within the retention period, or that are starred. + * Side-effect: store history in local storage + */ + +export function addToRichHistory( + richHistory: RichHistoryQuery[], + datasourceId: string, + datasourceName: string | null, + queries: string[], + starred: boolean, + comment: string | null, + sessionName: string +): any { + const ts = Date.now(); + /* Save only queries, that are not falsy (e.g. empty strings, null) */ + const queriesToSave = queries.filter(expr => Boolean(expr)); + + const retentionPeriod = 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 exmpty array + */ + const queriesToKeep = richHistory.filter(q => q.ts > retentionPeriodLastTs || q.starred === true) || []; + + if (queriesToSave.length > 0) { + if ( + /* Don't save duplicated queries for the same datasource */ + queriesToKeep.length > 0 && + JSON.stringify(queriesToSave) === JSON.stringify(queriesToKeep[0].queries) && + JSON.stringify(datasourceName) === JSON.stringify(queriesToKeep[0].datasourceName) + ) { + return richHistory; + } + + let newHistory = [ + { queries: queriesToSave, ts, datasourceId, datasourceName, starred, comment, sessionName }, + ...queriesToKeep, + ]; + + /* Combine all queries of a datasource type into one rich history */ + store.setObject(RICH_HISTORY_KEY, newHistory); + return newHistory; + } + + return richHistory; +} + +export function getRichHistory() { + return store.getObject(RICH_HISTORY_KEY, []); +} + +export function deleteAllFromRichHistory() { + return store.delete(RICH_HISTORY_KEY); +} + +export function updateStarredInRichHistory(richHistory: RichHistoryQuery[], ts: number) { + const updatedQueries = 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 }); + return updatedQuery; + } + return query; + }); + + store.setObject(RICH_HISTORY_KEY, updatedQueries); + return updatedQueries; +} + +export function updateCommentInRichHistory( + richHistory: RichHistoryQuery[], + ts: number, + newComment: string | undefined +) { + const updatedQueries = richHistory.map(query => { + if (query.ts === ts) { + const updatedQuery = Object.assign({}, query, { comment: newComment }); + return updatedQuery; + } + return query; + }); + + store.setObject(RICH_HISTORY_KEY, updatedQueries); + return updatedQueries; +} + +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 copyStringToClipboard = (string: string) => { + const el = document.createElement('textarea'); + el.value = string; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); +}; + +export const createUrlFromRichHistory = (query: RichHistoryQuery) => { + const queries = query.queries.map(query => ({ expr: query })); + const exploreState: ExploreUrlState = { + /* Default range, as we are not saving timerange in rich history */ + range: { from: 'now-1h', to: 'now' }, + datasource: query.datasourceName, + queries, + /* Default mode. In the future, we can also save the query mode */ + mode: query.datasourceId === 'loki' ? ExploreMode.Logs : ExploreMode.Metrics, + ui: { + showingGraph: true, + showingLogs: true, + showingTable: true, + }, + context: 'explore', + }; + + const serializedState = serializeStateToUrlParam(exploreState, true); + const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)[0]; + const url = 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 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) { + const date = new Date(ts); + const month = date.toLocaleString('default', { month: 'long' }); + const day = date.getDate(); + return `${month} ${day}`; +} + +export function getQueryDisplayText(query: DataQuery): string { + return JSON.stringify(query); +} + +export function createQueryHeading(query: RichHistoryQuery, sortOrder: SortOrder) { + let heading = ''; + if (sortOrder === SortOrder.DatasourceAZ || sortOrder === SortOrder.DatasourceZA) { + heading = query.datasourceName; + } else { + heading = createDateStringFromTs(query.ts); + } + return heading; +} + +export function isParsable(string: string) { + try { + JSON.parse(string); + } catch (e) { + return false; + } + return true; +} + +export function createDataQuery(query: RichHistoryQuery, queryString: string, index: number) { + let dataQuery; + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + isParsable(queryString) + ? (dataQuery = JSON.parse(queryString)) + : (dataQuery = { expr: queryString, refId: letters[index], datasource: query.datasourceName }); + + return dataQuery; +} + +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; +} diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 4ee83f48020..58004b67cfa 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,18 +1,20 @@ // Libraries import React from 'react'; import { hot } from 'react-hot-loader'; -import { css } from 'emotion'; +import { css, cx } from 'emotion'; import { connect } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; import memoizeOne from 'memoize-one'; // Services & Utils import store from 'app/core/store'; + // Components -import { ErrorBoundaryAlert } from '@grafana/ui'; +import { ErrorBoundaryAlert, stylesFactory } from '@grafana/ui'; import LogsContainer from './LogsContainer'; import QueryRows from './QueryRows'; import TableContainer from './TableContainer'; +import RichHistoryContainer from './RichHistory/RichHistoryContainer'; // Actions import { changeSize, @@ -57,15 +59,15 @@ import { ErrorContainer } from './ErrorContainer'; import { scanStopAction } from './state/actionTypes'; import { ExploreGraphPanel } from './ExploreGraphPanel'; -const getStyles = memoizeOne(() => { +const getStyles = stylesFactory(() => { return { logsMain: css` label: logsMain; // Is needed for some transition animations to work. position: relative; `, - exploreAddButton: css` - margin-top: 1em; + button: css` + margin: 1em 4px 0 0; `, }; }); @@ -108,6 +110,10 @@ interface ExploreProps { addQueryRow: typeof addQueryRow; } +interface ExploreState { + showRichHistory: boolean; +} + /** * Explore provides an area for quick query iteration for a given datasource. * Once a datasource is selected it populates the query section at the top. @@ -132,13 +138,16 @@ interface ExploreProps { * The result viewers determine some of the query options sent to the datasource, e.g., * `format`, to indicate eventual transformations by the datasources' result transformers. */ -export class Explore extends React.PureComponent { +export class Explore extends React.PureComponent { el: any; exploreEvents: Emitter; constructor(props: ExploreProps) { super(props); this.exploreEvents = new Emitter(); + this.state = { + showRichHistory: false, + }; } componentDidMount() { @@ -237,6 +246,14 @@ export class Explore extends React.PureComponent { updateTimeRange({ exploreId, absoluteRange }); }; + toggleShowRichHistory = () => { + this.setState(state => { + return { + showRichHistory: !state.showRichHistory, + }; + }); + }; + refreshExplore = () => { const { exploreId, update } = this.props; @@ -271,6 +288,7 @@ export class Explore extends React.PureComponent { syncedTimes, isLive, } = this.props; + const { showRichHistory } = this.state; const exploreClass = split ? 'explore explore-split' : 'explore'; const styles = getStyles(); const StartPage = datasourceInstance?.components?.ExploreStartPage; @@ -286,13 +304,24 @@ export class Explore extends React.PureComponent {
+
@@ -305,7 +334,7 @@ export class Explore extends React.PureComponent {
{showStartPage && StartPage && ( -
+
{ )} )} + {showRichHistory && }
); diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx new file mode 100644 index 00000000000..699a50457a2 --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -0,0 +1,235 @@ +import React, { PureComponent } from 'react'; +import { css } from 'emotion'; + +//Services & Utils +import { SortOrder } from 'app/core/utils/explore'; +import { RICH_HISTORY_SETTING_KEYS } from 'app/core/utils/richHistory'; +import store from 'app/core/store'; +import { stylesFactory, withTheme } from '@grafana/ui'; + +//Types +import { RichHistoryQuery, ExploreId } from 'app/types/explore'; +import { SelectableValue, GrafanaTheme } from '@grafana/data'; +import { TabsBar, Tab, TabContent, Themeable, CustomScrollbar } from '@grafana/ui'; + +//Components +import { RichHistorySettings } from './RichHistorySettings'; +import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; +import { RichHistoryStarredTab } from './RichHistoryStarredTab'; + +export enum Tabs { + RichHistory = 'Query history', + Starred = 'Starred', + Settings = 'Settings', +} + +export const sortOrderOptions = [ + { label: 'Time ascending', value: SortOrder.Ascending }, + { label: 'Time descending', value: SortOrder.Descending }, + { label: 'Data source A-Z', value: SortOrder.DatasourceAZ }, + { label: 'Data source Z-A', value: SortOrder.DatasourceZA }, +]; + +interface RichHistoryProps extends Themeable { + richHistory: RichHistoryQuery[]; + activeDatasourceInstance: string; + firstTab: Tabs; + exploreId: ExploreId; + deleteRichHistory: () => void; +} + +interface RichHistoryState { + activeTab: Tabs; + sortOrder: SortOrder; + retentionPeriod: number; + starredTabAsFirstTab: boolean; + activeDatasourceOnly: boolean; + datasourceFilters: SelectableValue[] | null; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6; + const tabBarBg = theme.isLight ? theme.colors.white : theme.colors.black; + const tabContentBg = theme.isLight ? theme.colors.gray7 : theme.colors.dark2; + return { + container: css` + height: 100%; + background-color: ${tabContentBg}; + `, + tabContent: css` + background-color: ${tabContentBg}; + padding: ${theme.spacing.md}; + `, + tabs: css` + background-color: ${tabBarBg}; + padding-top: ${theme.spacing.sm}; + border-color: ${borderColor}; + ul { + margin-left: ${theme.spacing.md}; + } + `, + }; +}); + +class UnThemedRichHistory extends PureComponent { + constructor(props: RichHistoryProps) { + super(props); + this.state = { + activeTab: this.props.firstTab, + datasourceFilters: null, + sortOrder: SortOrder.Descending, + retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7), + starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false), + activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false), + }; + } + + onChangeRetentionPeriod = (retentionPeriod: { label: string; value: number }) => { + this.setState({ + retentionPeriod: retentionPeriod.value, + }); + store.set(RICH_HISTORY_SETTING_KEYS.retentionPeriod, retentionPeriod.value); + }; + + toggleStarredTabAsFirstTab = () => { + const starredTabAsFirstTab = !this.state.starredTabAsFirstTab; + this.setState({ + starredTabAsFirstTab, + }); + store.set(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, starredTabAsFirstTab); + }; + + toggleactiveDatasourceOnly = () => { + const activeDatasourceOnly = !this.state.activeDatasourceOnly; + this.setState({ + activeDatasourceOnly, + }); + store.set(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, activeDatasourceOnly); + }; + + onSelectDatasourceFilters = (value: SelectableValue[] | null) => { + this.setState({ datasourceFilters: value }); + }; + + onSelectTab = (item: SelectableValue) => { + this.setState({ activeTab: item.value! }); + }; + + onChangeSortOrder = (sortOrder: SortOrder) => this.setState({ sortOrder }); + + /* If user selects activeDatasourceOnly === true, set datasource filter to currently active datasource. + * Filtering based on datasource won't be available. Otherwise set to null, as filtering will be + * available for user. + */ + updateFilters() { + this.state.activeDatasourceOnly && this.props.activeDatasourceInstance + ? this.onSelectDatasourceFilters([ + { label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance }, + ]) + : this.onSelectDatasourceFilters(null); + } + + componentDidMount() { + this.updateFilters(); + } + componentDidUpdate(prevProps: RichHistoryProps, prevState: RichHistoryState) { + if ( + this.props.activeDatasourceInstance !== prevProps.activeDatasourceInstance || + this.state.activeDatasourceOnly !== prevState.activeDatasourceOnly + ) { + this.updateFilters(); + } + } + + render() { + const { + datasourceFilters, + sortOrder, + activeTab, + starredTabAsFirstTab, + activeDatasourceOnly, + retentionPeriod, + } = this.state; + const { theme, richHistory, exploreId, deleteRichHistory } = this.props; + const styles = getStyles(theme); + + const QueriesTab = { + label: 'Query history', + value: Tabs.RichHistory, + content: ( + + ), + icon: 'fa fa-history', + }; + + const StarredTab = { + label: 'Starred', + value: Tabs.Starred, + content: ( + + ), + icon: 'fa fa-star', + }; + + const SettingsTab = { + label: 'Settings', + value: Tabs.Settings, + content: ( + + ), + icon: 'gicon gicon-preferences', + }; + + let tabs = starredTabAsFirstTab ? [StarredTab, QueriesTab, SettingsTab] : [QueriesTab, StarredTab, SettingsTab]; + + return ( +
+ + {tabs.map(t => ( + this.onSelectTab(t)} + icon={t.icon} + /> + ))} + + + {tabs.find(t => t.value === activeTab)?.content} + +
+ ); + } +} + +export const RichHistory = withTheme(UnThemedRichHistory); diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx new file mode 100644 index 00000000000..602e0cfa82f --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { css, cx } from 'emotion'; +import { stylesFactory, useTheme, Forms } from '@grafana/ui'; +import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data'; +import { RichHistoryQuery, ExploreId } from 'app/types/explore'; +import { copyStringToClipboard, createUrlFromRichHistory, createDataQuery } from 'app/core/utils/richHistory'; +import appEvents from 'app/core/app_events'; +import { StoreState } from 'app/types'; + +import { changeQuery, changeDatasource, clearQueries, updateRichHistory } from '../state/actions'; +interface Props { + query: RichHistoryQuery; + changeQuery: typeof changeQuery; + changeDatasource: typeof changeDatasource; + clearQueries: typeof clearQueries; + updateRichHistory: typeof updateRichHistory; + exploreId: ExploreId; + datasourceInstance: DataSourceApi; +} + +const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => { + const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; + const cardColor = theme.isLight ? theme.colors.white : theme.colors.dark7; + const cardBottomPadding = hasComment ? theme.spacing.sm : theme.spacing.xs; + const cardBoxShadow = theme.isLight ? `0px 2px 2px ${bgColor}` : `0px 2px 4px black`; + return { + queryCard: css` + display: flex; + border: 1px solid ${bgColor}; + padding: ${theme.spacing.sm} ${theme.spacing.sm} ${cardBottomPadding}; + margin: ${theme.spacing.sm} 0; + box-shadow: ${cardBoxShadow}; + background-color: ${cardColor}; + border-radius: ${theme.border.radius}; + .starred { + color: ${theme.colors.blue77}; + } + `, + queryCardLeft: css` + padding-right: 10px; + width: calc(100% - 150px); + cursor: pointer; + `, + queryCardRight: css` + width: 150px; + display: flex; + justify-content: flex-end; + i { + font-size: ${theme.typography.size.lg}; + font-weight: ${theme.typography.weight.bold}; + margin: 3px; + cursor: pointer; + } + `, + queryRow: css` + border-top: 1px solid ${bgColor}; + font-weight: ${theme.typography.weight.bold}; + word-break: break-all; + padding: 4px 2px; + :first-child { + border-top: none; + padding: 0 0 4px 0; + } + `, + buttonRow: css` + > * { + margin-right: ${theme.spacing.xs}; + } + `, + comment: css` + overflow-wrap: break-word; + font-size: ${theme.typography.size.sm}; + margin-top: ${theme.spacing.xs}; + `, + }; +}); + +export function RichHistoryCard(props: Props) { + const { + query, + updateRichHistory, + changeQuery, + changeDatasource, + exploreId, + clearQueries, + datasourceInstance, + } = props; + const [starred, setStared] = useState(query.starred); + const [activeUpdateComment, setActiveUpdateComment] = useState(false); + const [comment, setComment] = useState(query.comment); + + const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment); + const theme = useTheme(); + const styles = getStyles(theme, Boolean(query.comment)); + + const changeQueries = () => { + query.queries.forEach((q, i) => { + const dataQuery = createDataQuery(query, q, i); + changeQuery(exploreId, dataQuery, i); + }); + }; + + const onChangeQuery = async (query: RichHistoryQuery) => { + if (query.datasourceName !== datasourceInstance?.name) { + await changeDatasource(exploreId, query.datasourceName); + changeQueries(); + } else { + clearQueries(exploreId); + changeQueries(); + } + }; + + return ( +
+
onChangeQuery(query)}> + {query.queries.map((q, i) => { + return ( +
+ {q} +
+ ); + })} + {!activeUpdateComment && query.comment &&
{query.comment}
} + {activeUpdateComment && ( +
+ setComment(e.currentTarget.value)} + /> +
+ { + e.preventDefault(); + updateRichHistory(query.ts, 'comment', comment); + toggleActiveUpdateComment(); + }} + > + Save + + { + toggleActiveUpdateComment(); + setComment(query.comment); + }} + > + Cancel + +
+
+ )} +
+
+ { + toggleActiveUpdateComment(); + }} + title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'} + > + { + const queries = query.queries.join('\n\n'); + copyStringToClipboard(queries); + appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']); + }} + title="Copy query to clipboard" + > + { + const url = createUrlFromRichHistory(query); + copyStringToClipboard(url); + appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']); + }} + style={{ fontWeight: 'normal' }} + title="Copy link to clipboard" + > + { + updateRichHistory(query.ts, 'starred'); + setStared(!starred); + }} + title={query.starred ? 'Unstar query' : 'Star query'} + > +
+
+ ); +} + +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { + const explore = state.explore; + const { datasourceInstance } = explore[exploreId]; + // @ts-ignore + const item: ExploreItemState = explore[exploreId]; + return { + exploreId, + datasourceInstance, + }; +} + +const mapDispatchToProps = { + changeQuery, + changeDatasource, + clearQueries, + updateRichHistory, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(RichHistoryCard)); diff --git a/public/app/features/explore/RichHistory/RichHistoryContainer.tsx b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx new file mode 100644 index 00000000000..95dd5e38b21 --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistoryContainer.tsx @@ -0,0 +1,145 @@ +// Libraries +import React, { useState, useEffect } from 'react'; +import { Resizable } from 're-resizable'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { css, cx } from 'emotion'; + +// Services & Utils +import store from 'app/core/store'; +import { stylesFactory, useTheme } from '@grafana/ui'; +import { RICH_HISTORY_SETTING_KEYS } from 'app/core/utils/richHistory'; + +// Types +import { StoreState } from 'app/types'; +import { GrafanaTheme } from '@grafana/data'; +import { ExploreId, RichHistoryQuery } from 'app/types/explore'; + +// Components, enums +import { RichHistory, Tabs } from './RichHistory'; + +//Actions +import { deleteRichHistory } from '../state/actions'; + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; + const bg = theme.isLight ? theme.colors.gray7 : theme.colors.dark2; + const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6; + const handleShadow = theme.isLight ? `0px 2px 2px ${bgColor}` : `0px 2px 4px black`; + return { + container: css` + position: fixed !important; + bottom: 0; + background: ${bg}; + border-top: 1px solid ${borderColor}; + margin: 0px; + margin-right: -${theme.spacing.md}; + margin-left: -${theme.spacing.md}; + `, + drawerActive: css` + opacity: 1; + transition: transform 0.3s ease-in; + `, + drawerNotActive: css` + opacity: 0; + transform: translateY(150px); + `, + handle: css` + background-color: ${borderColor}; + height: 10px; + width: 202px; + border-radius: 10px; + position: absolute; + top: -5px; + left: calc(50% - 101px); + padding: ${theme.spacing.xs}; + box-shadow: ${handleShadow}; + cursor: grab; + hr { + border-top: 2px dotted ${theme.colors.gray70}; + margin: 0; + } + `, + }; +}); + +interface Props { + width: number; + exploreId: ExploreId; + activeDatasourceInstance: string; + richHistory: RichHistoryQuery[]; + firstTab: Tabs; + deleteRichHistory: typeof deleteRichHistory; +} + +function RichHistoryContainer(props: Props) { + const [visible, setVisible] = useState(false); + + /* To create sliding animation for rich history drawer */ + useEffect(() => { + const timer = setTimeout(() => setVisible(true), 100); + return () => clearTimeout(timer); + }, []); + + const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory } = props; + const theme = useTheme(); + const styles = getStyles(theme); + const drawerWidth = `${width + 31.5}px`; + + const drawerHandle = ( +
+
+
+ ); + + return ( + + {drawerHandle} + + + ); +} + +function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { + const explore = state.explore; + // @ts-ignore + const item: ExploreItemState = explore[exploreId]; + const { datasourceInstance } = item; + const firstTab = store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false) + ? Tabs.Starred + : Tabs.RichHistory; + const { richHistory } = explore; + return { + richHistory, + firstTab, + activeDatasourceInstance: datasourceInstance?.name, + }; +} + +const mapDispatchToProps = { + deleteRichHistory, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(RichHistoryContainer)); diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx new file mode 100644 index 00000000000..7a4e581b1a7 --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { uniqBy } from 'lodash'; + +// Types +import { RichHistoryQuery, ExploreId } from 'app/types/explore'; + +// Utils +import { stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, SelectableValue } from '@grafana/data'; +import { getExploreDatasources } from '../state/selectors'; + +import { SortOrder } from 'app/core/utils/explore'; +import { + sortQueries, + mapNumbertoTimeInSlider, + createRetentionPeriodBoundary, + mapQueriesToHeadings, +} from 'app/core/utils/richHistory'; + +// Components +import RichHistoryCard from './RichHistoryCard'; +import { sortOrderOptions } from './RichHistory'; +import { Select, Slider } from '@grafana/ui'; + +interface Props { + queries: RichHistoryQuery[]; + sortOrder: SortOrder; + activeDatasourceOnly: boolean; + datasourceFilters: SelectableValue[] | null; + retentionPeriod: number; + exploreId: ExploreId; + onChangeSortOrder: (sortOrder: SortOrder) => void; + onSelectDatasourceFilters: (value: SelectableValue[] | null) => void; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; + + /* 134px is based on the width of the Query history tabs bar, so the content is aligned to right side of the tab */ + const cardWidth = '100% - 134px'; + return { + container: css` + display: flex; + .label-slider { + font-size: ${theme.typography.size.sm}; + &:last-of-type { + margin-top: ${theme.spacing.lg}; + } + &:first-of-type { + font-weight: ${theme.typography.weight.semibold}; + margin-bottom: ${theme.spacing.md}; + } + } + `, + containerContent: css` + width: calc(${cardWidth}); + `, + containerSlider: css` + width: 127px; + margin-right: ${theme.spacing.sm}; + .slider { + bottom: 10px; + height: 200px; + width: 127px; + padding: ${theme.spacing.xs} 0; + } + `, + slider: css` + position: fixed; + `, + selectors: css` + display: flex; + justify-content: space-between; + `, + multiselect: css` + width: 60%; + .gf-form-select-box__multi-value { + background-color: ${bgColor}; + padding: ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.xxs} ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + } + `, + sort: css` + width: 170px; + `, + sessionName: css` + display: flex; + align-items: flex-start; + justify-content: flex-start; + margin-top: ${theme.spacing.lg}; + h4 { + margin: 0 10px 0 0; + } + `, + heading: css` + font-size: ${theme.typography.heading.h4}; + margin: ${theme.spacing.md} ${theme.spacing.xxs} ${theme.spacing.sm} ${theme.spacing.xxs}; + `, + feedback: css` + height: 60px; + margin-top: ${theme.spacing.lg}; + display: flex; + justify-content: center; + font-weight: ${theme.typography.weight.light}; + font-size: ${theme.typography.size.sm}; + a { + font-weight: ${theme.typography.weight.semibold}; + margin-left: ${theme.spacing.xxs}; + } + `, + queries: css` + font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.weight.regular}; + margin-left: ${theme.spacing.xs}; + `, + }; +}); + +export function RichHistoryQueriesTab(props: Props) { + const { + datasourceFilters, + onSelectDatasourceFilters, + queries, + onChangeSortOrder, + sortOrder, + activeDatasourceOnly, + retentionPeriod, + exploreId, + } = props; + + const [sliderRetentionFilter, setSliderRetentionFilter] = useState<[number, number]>([0, retentionPeriod]); + + const theme = useTheme(); + const styles = getStyles(theme); + const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName); + + /* Display only explore datasoources, that have saved queries */ + const datasources = getExploreDatasources() + ?.filter(ds => listOfDsNamesWithQueries.includes(ds.name)) + .map(d => { + return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small }; + }); + + const listOfDatasourceFilters = datasourceFilters?.map(d => d.value); + const filteredQueriesByDatasource = datasourceFilters + ? queries?.filter(q => listOfDatasourceFilters?.includes(q.datasourceName)) + : queries; + + const sortedQueries = sortQueries(filteredQueriesByDatasource, sortOrder); + const queriesWithinSelectedTimeline = sortedQueries?.filter( + q => + q.ts < createRetentionPeriodBoundary(sliderRetentionFilter[0], true) && + q.ts > createRetentionPeriodBoundary(sliderRetentionFilter[1], false) + ); + + /* mappedQueriesToHeadings is an object where query headings (stringified dates/data sources) + * are keys and arrays with queries that belong to that headings are values. + */ + let mappedQueriesToHeadings = mapQueriesToHeadings(queriesWithinSelectedTimeline, sortOrder); + + return ( +
+
+
+
+ Filter history
+ between +
+
{mapNumbertoTimeInSlider(sliderRetentionFilter[0])}
+
+ number[]} + /> +
+
{mapNumbertoTimeInSlider(sliderRetentionFilter[1])}
+
+
+ +
+
+ {!activeDatasourceOnly && ( +
+ onChangeSortOrder(e.value as SortOrder)} + /> +
+
+ {Object.keys(mappedQueriesToHeadings).map(heading => { + return ( +
+
+ {heading} {mappedQueriesToHeadings[heading].length} queries +
+ {mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => ( + + ))} +
+ ); + })} +
+ Query history is a beta feature. The history is local to your browser and is not shared with others. + Feedback? +
+
+
+ ); +} diff --git a/public/app/features/explore/RichHistory/RichHistorySettings.tsx b/public/app/features/explore/RichHistory/RichHistorySettings.tsx new file mode 100644 index 00000000000..55dfff5d47f --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistorySettings.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { css } from 'emotion'; +import { stylesFactory, useTheme, Forms } from '@grafana/ui'; +import { GrafanaTheme, AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; + +interface RichHistorySettingsProps { + retentionPeriod: number; + starredTabAsFirstTab: boolean; + activeDatasourceOnly: boolean; + onChangeRetentionPeriod: (option: { label: string; value: number }) => void; + toggleStarredTabAsFirstTab: () => void; + toggleactiveDatasourceOnly: () => void; + deleteRichHistory: () => void; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + container: css` + padding-left: ${theme.spacing.sm}; + font-size: ${theme.typography.size.sm}; + .space-between { + margin-bottom: ${theme.spacing.lg}; + } + `, + input: css` + max-width: 200px; + `, + switch: css` + display: flex; + align-items: center; + `, + label: css` + margin-left: ${theme.spacing.md}; + `, + }; +}); + +const retentionPeriodOptions = [ + { value: 2, label: '2 days' }, + { value: 5, label: '5 days' }, + { value: 7, label: '1 week' }, + { value: 14, label: '2 weeks' }, +]; + +export function RichHistorySettings(props: RichHistorySettingsProps) { + const { + retentionPeriod, + starredTabAsFirstTab, + activeDatasourceOnly, + onChangeRetentionPeriod, + toggleStarredTabAsFirstTab, + toggleactiveDatasourceOnly, + deleteRichHistory, + } = props; + const theme = useTheme(); + const styles = getStyles(theme); + const selectedOption = retentionPeriodOptions.find(v => v.value === retentionPeriod); + + return ( +
+ +
+ +
+
+ +
+ +
Change the default active tab from “Query history” to “Starred”
+
+
+ +
+ +
Only show queries for datasource currently active in Explore
+
+
+
+ Clear query history +
+
+ Delete all of your query history, permanently. +
+ { + deleteRichHistory(); + appEvents.emit(AppEvents.alertSuccess, ['Query history deleted']); + }} + > + Clear query history + +
+ ); +} diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx new file mode 100644 index 00000000000..96274a5a30b --- /dev/null +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { css } from 'emotion'; +import { uniqBy } from 'lodash'; + +// Types +import { RichHistoryQuery, ExploreId } from 'app/types/explore'; + +// Utils +import { stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme, SelectableValue } from '@grafana/data'; +import { getExploreDatasources } from '../state/selectors'; + +import { SortOrder } from '../../../core/utils/explore'; +import { sortQueries } from '../../../core/utils/richHistory'; + +// Components +import RichHistoryCard from './RichHistoryCard'; +import { sortOrderOptions } from './RichHistory'; +import { Select } from '@grafana/ui'; + +interface Props { + queries: RichHistoryQuery[]; + sortOrder: SortOrder; + activeDatasourceOnly: boolean; + datasourceFilters: SelectableValue[] | null; + exploreId: ExploreId; + onChangeSortOrder: (sortOrder: SortOrder) => void; + onSelectDatasourceFilters: (value: SelectableValue[] | null) => void; +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; + return { + container: css` + display: flex; + .label-slider { + font-size: ${theme.typography.size.sm}; + &:last-of-type { + margin-top: ${theme.spacing.lg}; + } + &:first-of-type { + margin-top: ${theme.spacing.sm}; + font-weight: ${theme.typography.weight.semibold}; + margin-bottom: ${theme.spacing.xs}; + } + } + `, + containerContent: css` + width: 100%; + `, + selectors: css` + display: flex; + justify-content: space-between; + `, + multiselect: css` + width: 60%; + .gf-form-select-box__multi-value { + background-color: ${bgColor}; + padding: ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.xxs} ${theme.spacing.sm}; + border-radius: ${theme.border.radius.sm}; + } + `, + sort: css` + width: 170px; + `, + sessionName: css` + display: flex; + align-items: flex-start; + justify-content: flex-start; + margin-top: ${theme.spacing.lg}; + h4 { + margin: 0 10px 0 0; + } + `, + heading: css` + font-size: ${theme.typography.heading.h4}; + margin: ${theme.spacing.md} ${theme.spacing.xxs} ${theme.spacing.sm} ${theme.spacing.xxs}; + `, + }; +}); + +export function RichHistoryStarredTab(props: Props) { + const { + datasourceFilters, + onSelectDatasourceFilters, + queries, + onChangeSortOrder, + sortOrder, + activeDatasourceOnly, + exploreId, + } = props; + + const theme = useTheme(); + const styles = getStyles(theme); + const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName); + const exploreDatasources = getExploreDatasources() + ?.filter(ds => listOfDsNamesWithQueries.includes(ds.name)) + .map(d => { + return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small }; + }); + const listOfDatasourceFilters = datasourceFilters?.map(d => d.value); + + const starredQueries = queries.filter(q => q.starred === true); + const starredQueriesFilteredByDatasource = datasourceFilters + ? starredQueries?.filter(q => listOfDatasourceFilters?.includes(q.datasourceName)) + : starredQueries; + const sortedStarredQueries = sortQueries(starredQueriesFilteredByDatasource, sortOrder); + + return ( +
+
+
+ {!activeDatasourceOnly && ( +
+ onChangeSortOrder(e.value as SortOrder)} + /> +
+
+ {sortedStarredQueries.map(q => { + return ; + })} +
+
+ ); +} diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index b5777ed6554..4d7b57c58d5 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -296,6 +296,8 @@ export const splitCloseAction = createAction('explore/s export const splitOpenAction = createAction('explore/splitOpen'); export const syncTimesAction = createAction('explore/syncTimes'); + +export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); /** * Update state of Explores UI elements (panels visiblity and deduplication strategy) */ diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index c024e604793..c1b72208cfb 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -38,6 +38,14 @@ import { stopQueryState, updateHistory, } from 'app/core/utils/explore'; +import { + addToRichHistory, + deleteAllFromRichHistory, + updateStarredInRichHistory, + updateCommentInRichHistory, + getQueryDisplayText, + getRichHistory, +} from 'app/core/utils/richHistory'; // Types import { ExploreItemState, ExploreUrlState, ThunkResult } from 'app/types'; @@ -53,6 +61,7 @@ import { ChangeSizePayload, clearQueriesAction, historyUpdatedAction, + richHistoryUpdatedAction, initializeExploreAction, loadDatasourceMissingAction, loadDatasourcePendingAction, @@ -281,6 +290,8 @@ export function initializeExplore( }) ); dispatch(updateTime({ exploreId })); + const richHistory = getRichHistory(); + dispatch(richHistoryUpdatedAction({ richHistory })); }; } @@ -399,6 +410,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { return (dispatch, getState) => { dispatch(updateTime({ exploreId })); + const richHistory = getState().explore.richHistory; const exploreItemState = getState().explore[exploreId]; const { datasourceInstance, @@ -441,6 +453,8 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { }; const datasourceId = datasourceInstance.meta.id; + const datasourceName = exploreItemState.requestedDatasourceName; + const transaction = buildQueryTransaction(queries, queryOptions, range, scanning); let firstResponse = true; @@ -457,7 +471,23 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { if (!data.error && firstResponse) { // Side-effect: Saving history in localstorage const nextHistory = updateHistory(history, datasourceId, queries); + const arrayOfStringifiedQueries = queries.map(query => + datasourceInstance?.getQueryDisplayText + ? datasourceInstance.getQueryDisplayText(query) + : getQueryDisplayText(query) + ); + + const nextRichHistory = addToRichHistory( + richHistory || [], + datasourceId, + datasourceName, + arrayOfStringifiedQueries, + false, + '', + '' + ); dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); + dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); // We save queries to the URL here so that only successfully run queries change the URL. dispatch(stateSave()); @@ -484,6 +514,27 @@ export const runQueries = (exploreId: ExploreId): ThunkResult => { }; }; +export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult => { + return (dispatch, getState) => { + // Side-effect: Saving rich history in localstorage + let nextRichHistory; + if (property === 'starred') { + nextRichHistory = updateStarredInRichHistory(getState().explore.richHistory, ts); + } + if (property === 'comment') { + nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); + } + dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); + }; +}; + +export const deleteRichHistory = (): ThunkResult => { + return dispatch => { + deleteAllFromRichHistory(); + dispatch(richHistoryUpdatedAction({ richHistory: [] })); + }; +}; + const toRawTimeRange = (range: TimeRange): RawTimeRange => { let from = range.raw.from; if (isDateTime(from)) { diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index c2ea581a753..bda74b8869f 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -38,6 +38,7 @@ import { clearQueriesAction, highlightLogsExpressionAction, historyUpdatedAction, + richHistoryUpdatedAction, initializeExploreAction, loadDatasourceMissingAction, loadDatasourcePendingAction, @@ -132,10 +133,11 @@ export const createEmptyQueryResponse = (): PanelData => ({ */ export const initialExploreItemState = makeExploreItemState(); export const initialExploreState: ExploreState = { - split: null, + split: false, syncedTimes: false, left: initialExploreItemState, right: initialExploreItemState, + richHistory: [], }; /** @@ -639,6 +641,13 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): return { ...state, syncedTimes: action.payload.syncedTimes }; } + if (richHistoryUpdatedAction.match(action)) { + return { + ...state, + richHistory: action.payload.richHistory, + }; + } + if (resetExploreAction.match(action)) { const payload: ResetExplorePayload = action.payload; const leftState = state[ExploreId.left]; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 0934a06b0b2..0b57030d2d6 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -384,6 +384,10 @@ export class LokiDatasource extends DataSourceApi { return expandedQueries; } + getQueryDisplayText(query: LokiQuery) { + return query.expr; + } + async importQueries(queries: LokiQuery[], originMeta: PluginMeta): Promise { return this.languageProvider.importQueries(queries, originMeta.id); } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index cd6b9bbb34a..fdaa14f1c8a 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -44,6 +44,10 @@ export interface ExploreState { * Explore state of the right area in split view. */ right: ExploreItemState; + /** + * History of all queries + */ + richHistory: RichHistoryQuery[]; } export interface ExploreItemState { @@ -230,3 +234,14 @@ export interface QueryTransaction { result?: any; // Table model / Timeseries[] / Logs scanning?: boolean; } + +export type RichHistoryQuery = { + ts: number; + datasourceName: string; + datasourceId: string; + starred: boolean; + comment: string; + queries: string[]; + sessionName: string; + timeRange?: string; +}; diff --git a/public/test/mocks/mockExploreState.ts b/public/test/mocks/mockExploreState.ts index e92d9c23453..c632827a189 100644 --- a/public/test/mocks/mockExploreState.ts +++ b/public/test/mocks/mockExploreState.ts @@ -1,4 +1,4 @@ -import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; +import { ExploreId, ExploreItemState, ExploreState, RichHistoryQuery } from 'app/types/explore'; import { makeExploreItemState } from 'app/features/explore/state/reducers'; import { StoreState, UserState } from 'app/types'; import { TimeRange, dateTime, DataSourceApi } from '@grafana/data'; @@ -71,11 +71,13 @@ export const mockExploreState = (options: any = {}) => { }; const split: boolean = options.split || false; const syncedTimes: boolean = options.syncedTimes || false; + const richHistory: RichHistoryQuery[] = []; const explore: ExploreState = { left, right, syncedTimes, split, + richHistory, }; const user: UserState = { diff --git a/yarn.lock b/yarn.lock index a0d17f59e87..90da25b6754 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11740,6 +11740,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.4, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d" + integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g== + fast-safe-stringify@^1.0.8, fast-safe-stringify@^1.2.1: version "1.2.3" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-1.2.3.tgz#9fe22c37fb2f7f86f06b8f004377dbf8f1ee7bc1" @@ -20509,6 +20514,13 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-resizable@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.2.0.tgz#7b42aee4df6de4e1f602c78828052d41f642bc94" + integrity sha512-3bi0yTzub/obnqoTPs9C8A1ecrgt5OSWlKdHDJ6gBPiEiEIG5LO0PqbwWTpABfzAzdE4kldOG2MQDQEaJJNYkQ== + dependencies: + fast-memoize "^2.5.1" + react-addons-create-fragment@^15.6.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react-addons-create-fragment/-/react-addons-create-fragment-15.6.2.tgz#a394de7c2c7becd6b5475ba1b97ac472ce7c74f8"