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
This commit is contained in:
Ivana Huckova 2020-03-10 15:08:15 +01:00 committed by GitHub
parent da37f4c83f
commit 62c824e3a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1474 additions and 11 deletions

View File

@ -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",

View File

@ -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<Props> = ({
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<Props> = ({
{/** Slider tooltip's parent component is body and therefore we need Global component to do css overrides for it. */}
<Global styles={styles.tooltip} />
<RangeWithTooltip
tipProps={{ visible: true, placement: isHorizontal ? 'top' : 'right' }}
tipProps={{
visible: tooltipAlwaysVisible,
placement: isHorizontal ? 'top' : 'right',
}}
min={min}
max={max}
defaultValue={value || [min, max]}
tipFormatter={(value: number) => (formatTooltipResult ? formatTooltipResult(value) : value)}
onChange={onChange}
onAfterChange={onAfterChange}
vertical={!isHorizontal}
reverse={reverse}
/>

View File

@ -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) =>

View File

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

View File

@ -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<ExploreProps> {
export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
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<ExploreProps> {
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<ExploreProps> {
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<ExploreProps> {
<div className="gf-form">
<button
aria-label="Add row button"
className={`gf-form-label gf-form-label--btn ${styles.exploreAddButton}`}
className={`gf-form-label gf-form-label--btn ${styles.button}`}
onClick={this.onClickAddQueryRowButton}
disabled={isLive}
>
<i className={'fa fa-fw fa-plus icon-margin-right'} />
<span className="btn-title">{'\xA0' + 'Add query'}</span>
</button>
<button
aria-label="Rich history button"
className={cx(`gf-form-label gf-form-label--btn ${styles.button}`, {
['explore-active-button']: showRichHistory,
})}
onClick={this.toggleShowRichHistory}
disabled={isLive}
>
<i className={'fa fa-fw fa-history icon-margin-right '} />
<span className="btn-title">{'\xA0' + 'Query history'}</span>
</button>
</div>
<ErrorContainer queryErrors={queryResponse.error ? [queryResponse.error] : undefined} />
<AutoSizer onResize={this.onResize} disableHeight>
@ -305,7 +334,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
<main className={`m-t-2 ${styles.logsMain}`} style={{ width }}>
<ErrorBoundaryAlert>
{showStartPage && StartPage && (
<div className="grafana-info-box grafana-info-box--max-lg">
<div className={'grafana-info-box grafana-info-box--max-lg'}>
<StartPage
onClickExample={this.onClickExample}
datasource={datasourceInstance}
@ -348,6 +377,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
)}
</>
)}
{showRichHistory && <RichHistoryContainer width={width} exploreId={exploreId} />}
</ErrorBoundaryAlert>
</main>
);

View File

@ -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<RichHistoryProps, RichHistoryState> {
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<Tabs>) => {
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: (
<RichHistoryQueriesTab
queries={richHistory}
sortOrder={sortOrder}
datasourceFilters={datasourceFilters}
activeDatasourceOnly={activeDatasourceOnly}
retentionPeriod={retentionPeriod}
onChangeSortOrder={this.onChangeSortOrder}
onSelectDatasourceFilters={this.onSelectDatasourceFilters}
exploreId={exploreId}
/>
),
icon: 'fa fa-history',
};
const StarredTab = {
label: 'Starred',
value: Tabs.Starred,
content: (
<RichHistoryStarredTab
queries={richHistory}
sortOrder={sortOrder}
datasourceFilters={datasourceFilters}
activeDatasourceOnly={activeDatasourceOnly}
onChangeSortOrder={this.onChangeSortOrder}
onSelectDatasourceFilters={this.onSelectDatasourceFilters}
exploreId={exploreId}
/>
),
icon: 'fa fa-star',
};
const SettingsTab = {
label: 'Settings',
value: Tabs.Settings,
content: (
<RichHistorySettings
retentionPeriod={this.state.retentionPeriod}
starredTabAsFirstTab={this.state.starredTabAsFirstTab}
activeDatasourceOnly={this.state.activeDatasourceOnly}
onChangeRetentionPeriod={this.onChangeRetentionPeriod}
toggleStarredTabAsFirstTab={this.toggleStarredTabAsFirstTab}
toggleactiveDatasourceOnly={this.toggleactiveDatasourceOnly}
deleteRichHistory={deleteRichHistory}
/>
),
icon: 'gicon gicon-preferences',
};
let tabs = starredTabAsFirstTab ? [StarredTab, QueriesTab, SettingsTab] : [QueriesTab, StarredTab, SettingsTab];
return (
<div className={styles.container}>
<TabsBar className={styles.tabs}>
{tabs.map(t => (
<Tab
key={t.value}
label={t.label}
active={t.value === activeTab}
onChangeTab={() => this.onSelectTab(t)}
icon={t.icon}
/>
))}
</TabsBar>
<CustomScrollbar
className={css`
min-height: 100% !important;
`}
>
<TabContent className={styles.tabContent}>{tabs.find(t => t.value === activeTab)?.content}</TabContent>
</CustomScrollbar>
</div>
);
}
}
export const RichHistory = withTheme(UnThemedRichHistory);

View File

@ -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<string | undefined>(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 (
<div className={styles.queryCard}>
<div className={styles.queryCardLeft} onClick={() => onChangeQuery(query)}>
{query.queries.map((q, i) => {
return (
<div key={`${q}-${i}`} className={styles.queryRow}>
{q}
</div>
);
})}
{!activeUpdateComment && query.comment && <div className={styles.comment}>{query.comment}</div>}
{activeUpdateComment && (
<div>
<Forms.TextArea
value={comment}
placeholder={comment ? undefined : 'add comment'}
onChange={e => setComment(e.currentTarget.value)}
/>
<div className={styles.buttonRow}>
<Forms.Button
onClick={e => {
e.preventDefault();
updateRichHistory(query.ts, 'comment', comment);
toggleActiveUpdateComment();
}}
>
Save
</Forms.Button>
<Forms.Button
variant="secondary"
className={css`
margin-left: 8px;
`}
onClick={() => {
toggleActiveUpdateComment();
setComment(query.comment);
}}
>
Cancel
</Forms.Button>
</div>
</div>
)}
</div>
<div className={styles.queryCardRight}>
<i
className="fa fa-fw fa-comment-o"
onClick={() => {
toggleActiveUpdateComment();
}}
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
></i>
<i
className="fa fa-fw fa-copy"
onClick={() => {
const queries = query.queries.join('\n\n');
copyStringToClipboard(queries);
appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']);
}}
title="Copy query to clipboard"
></i>
<i
className="fa fa-fw fa-link"
onClick={() => {
const url = createUrlFromRichHistory(query);
copyStringToClipboard(url);
appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']);
}}
style={{ fontWeight: 'normal' }}
title="Copy link to clipboard"
></i>
<i
className={cx('fa fa-fw', starred ? 'fa-star starred' : 'fa-star-o')}
onClick={() => {
updateRichHistory(query.ts, 'starred');
setStared(!starred);
}}
title={query.starred ? 'Unstar query' : 'Star query'}
></i>
</div>
</div>
);
}
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));

View File

@ -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 = (
<div className={styles.handle}>
<hr />
</div>
);
return (
<Resizable
className={cx(styles.container, visible ? styles.drawerActive : styles.drawerNotActive)}
defaultSize={{ width: drawerWidth, height: '400px' }}
enable={{
top: true,
right: false,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
maxHeight="100vh"
maxWidth={drawerWidth}
minWidth={drawerWidth}
>
{drawerHandle}
<RichHistory
richHistory={richHistory}
firstTab={firstTab}
activeDatasourceInstance={activeDatasourceInstance}
exploreId={exploreId}
deleteRichHistory={deleteRichHistory}
/>
</Resizable>
);
}
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));

View File

@ -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 (
<div className={styles.container}>
<div className={styles.containerSlider}>
<div className={styles.slider}>
<div className="label-slider">
Filter history <br />
between
</div>
<div className="label-slider">{mapNumbertoTimeInSlider(sliderRetentionFilter[0])}</div>
<div className="slider">
<Slider
tooltipAlwaysVisible={false}
min={0}
max={retentionPeriod}
value={sliderRetentionFilter}
orientation="vertical"
formatTooltipResult={mapNumbertoTimeInSlider}
reverse={true}
onAfterChange={setSliderRetentionFilter as () => number[]}
/>
</div>
<div className="label-slider">{mapNumbertoTimeInSlider(sliderRetentionFilter[1])}</div>
</div>
</div>
<div className={styles.containerContent}>
<div className={styles.selectors}>
{!activeDatasourceOnly && (
<div className={styles.multiselect}>
<Select
isMulti={true}
options={datasources}
value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)"
onChange={onSelectDatasourceFilters}
/>
</div>
)}
<div className={styles.sort}>
<Select
options={sortOrderOptions}
placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)}
/>
</div>
</div>
{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) => (
<RichHistoryCard query={q} key={q.ts} exploreId={exploreId} />
))}
</div>
);
})}
<div className={styles.feedback}>
Query history is a beta feature. The history is local to your browser and is not shared with others.
<a href="https://github.com/grafana/grafana/issues/new/choose">Feedback?</a>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className={styles.container}>
<Forms.Field
label="History time span"
description="Select the period of time for which Grafana will save your query history"
className="space-between"
>
<div className={styles.input}>
<Forms.Select
value={selectedOption}
options={retentionPeriodOptions}
onChange={onChangeRetentionPeriod}
></Forms.Select>
</div>
</Forms.Field>
<Forms.Field label="Default active tab" description=" " className="space-between">
<div className={styles.switch}>
<Forms.Switch value={starredTabAsFirstTab} onChange={toggleStarredTabAsFirstTab}></Forms.Switch>
<div className={styles.label}>Change the default active tab from Query history to Starred</div>
</div>
</Forms.Field>
<Forms.Field label="Datasource behaviour" description=" " className="space-between">
<div className={styles.switch}>
<Forms.Switch value={activeDatasourceOnly} onChange={toggleactiveDatasourceOnly}></Forms.Switch>
<div className={styles.label}>Only show queries for datasource currently active in Explore</div>
</div>
</Forms.Field>
<div
className={css`
font-weight: ${theme.typography.weight.bold};
`}
>
Clear query history
</div>
<div
className={css`
margin-bottom: ${theme.spacing.sm};
`}
>
Delete all of your query history, permanently.
</div>
<Forms.Button
variant="destructive"
onClick={() => {
deleteRichHistory();
appEvents.emit(AppEvents.alertSuccess, ['Query history deleted']);
}}
>
Clear query history
</Forms.Button>
</div>
);
}

View File

@ -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 (
<div className={styles.container}>
<div className={styles.containerContent}>
<div className={styles.selectors}>
{!activeDatasourceOnly && (
<div className={styles.multiselect}>
<Select
isMulti={true}
options={exploreDatasources}
value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)"
onChange={onSelectDatasourceFilters}
/>
</div>
)}
<div className={styles.sort}>
<Select
options={sortOrderOptions}
placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)}
/>
</div>
</div>
{sortedStarredQueries.map(q => {
return <RichHistoryCard query={q} key={q.ts} exploreId={exploreId} />;
})}
</div>
</div>
);
}

View File

@ -296,6 +296,8 @@ export const splitCloseAction = createAction<SplitCloseActionPayload>('explore/s
export const splitOpenAction = createAction<SplitOpenPayload>('explore/splitOpen');
export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes');
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
/**
* Update state of Explores UI elements (panels visiblity and deduplication strategy)
*/

View File

@ -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<void> => {
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<void> => {
};
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<void> => {
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<void> => {
};
};
export const updateRichHistory = (ts: number, property: string, updatedProperty?: string): ThunkResult<void> => {
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<void> => {
return dispatch => {
deleteAllFromRichHistory();
dispatch(richHistoryUpdatedAction({ richHistory: [] }));
};
};
const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from;
if (isDateTime(from)) {

View File

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

View File

@ -384,6 +384,10 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return expandedQueries;
}
getQueryDisplayText(query: LokiQuery) {
return query.expr;
}
async importQueries(queries: LokiQuery[], originMeta: PluginMeta): Promise<LokiQuery[]> {
return this.languageProvider.importQueries(queries, originMeta.id);
}

View File

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

View File

@ -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 = {

View File

@ -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"