mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
da37f4c83f
commit
62c824e3a4
@ -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",
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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) =>
|
||||
|
253
public/app/core/utils/richHistory.ts
Normal file
253
public/app/core/utils/richHistory.ts
Normal 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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
235
public/app/features/explore/RichHistory/RichHistory.tsx
Normal file
235
public/app/features/explore/RichHistory/RichHistory.tsx
Normal 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);
|
217
public/app/features/explore/RichHistory/RichHistoryCard.tsx
Normal file
217
public/app/features/explore/RichHistory/RichHistoryCard.tsx
Normal 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));
|
145
public/app/features/explore/RichHistory/RichHistoryContainer.tsx
Normal file
145
public/app/features/explore/RichHistory/RichHistoryContainer.tsx
Normal 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));
|
@ -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>
|
||||
);
|
||||
}
|
112
public/app/features/explore/RichHistory/RichHistorySettings.tsx
Normal file
112
public/app/features/explore/RichHistory/RichHistorySettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)
|
||||
*/
|
||||
|
@ -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)) {
|
||||
|
@ -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];
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
12
yarn.lock
12
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"
|
||||
|
Loading…
Reference in New Issue
Block a user