Commit 34761205 by David Committed by GitHub

Merge pull request #12799 from grafana/davkal/explore-history

Explore: Add history to query fields
parents a73fc4a6 cda3b017
...@@ -4,6 +4,7 @@ import Select from 'react-select'; ...@@ -4,6 +4,7 @@ import Select from 'react-select';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
import { decodePathComponent } from 'app/core/utils/location_util'; import { decodePathComponent } from 'app/core/utils/location_util';
import { parse as parseDate } from 'app/core/utils/datemath'; import { parse as parseDate } from 'app/core/utils/datemath';
...@@ -16,6 +17,8 @@ import Table from './Table'; ...@@ -16,6 +17,8 @@ import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker'; import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100;
function makeTimeSeriesList(dataList, options) { function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => { return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || []; const datapoints = seriesData.datapoints || [];
...@@ -56,6 +59,7 @@ interface IExploreState { ...@@ -56,6 +59,7 @@ interface IExploreState {
datasourceLoading: boolean | null; datasourceLoading: boolean | null;
datasourceMissing: boolean; datasourceMissing: boolean;
graphResult: any; graphResult: any;
history: any[];
initialDatasource?: string; initialDatasource?: string;
latency: number; latency: number;
loading: any; loading: any;
...@@ -86,6 +90,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -86,6 +90,7 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceMissing: false, datasourceMissing: false,
graphResult: null, graphResult: null,
initialDatasource: datasource, initialDatasource: datasource,
history: [],
latency: 0, latency: 0,
loading: false, loading: false,
logsResult: null, logsResult: null,
...@@ -138,6 +143,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -138,6 +143,7 @@ export class Explore extends React.Component<any, IExploreState> {
const supportsGraph = datasource.meta.metrics; const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs; const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics; const supportsTable = datasource.meta.metrics;
const datasourceId = datasource.meta.id;
let datasourceError = null; let datasourceError = null;
try { try {
...@@ -147,10 +153,14 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -147,10 +153,14 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceError = (error && error.statusText) || error; datasourceError = (error && error.statusText) || error;
} }
const historyKey = `grafana.explore.history.${datasourceId}`;
const history = store.getObject(historyKey, []);
this.setState( this.setState(
{ {
datasource, datasource,
datasourceError, datasourceError,
history,
supportsGraph, supportsGraph,
supportsLogs, supportsLogs,
supportsTable, supportsTable,
...@@ -269,6 +279,27 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -269,6 +279,27 @@ export class Explore extends React.Component<any, IExploreState> {
} }
}; };
onQuerySuccess(datasourceId: string, queries: any[]): void {
// save queries to history
let { datasource, history } = this.state;
if (datasource.meta.id !== datasourceId) {
// Navigated away, queries did not matter
return;
}
const ts = Date.now();
queries.forEach(q => {
const { query } = q;
history = [{ query, ts }, ...history];
});
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
// Combine all queries of a datasource type into one history
const historyKey = `grafana.explore.history.${datasourceId}`;
store.setObject(historyKey, history);
this.setState({ history });
}
buildQueryOptions(targetOptions: { format: string; instant?: boolean }) { buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
const { datasource, queries, range } = this.state; const { datasource, queries, range } = this.state;
const resolution = this.el.offsetWidth; const resolution = this.el.offsetWidth;
...@@ -301,6 +332,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -301,6 +332,7 @@ export class Explore extends React.Component<any, IExploreState> {
const result = makeTimeSeriesList(res.data, options); const result = makeTimeSeriesList(res.data, options);
const latency = Date.now() - now; const latency = Date.now() - now;
this.setState({ latency, loading: false, graphResult: result, requestOptions: options }); this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) { } catch (response) {
console.error(response); console.error(response);
const queryError = response.data ? response.data.error : response; const queryError = response.data ? response.data.error : response;
...@@ -324,6 +356,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -324,6 +356,7 @@ export class Explore extends React.Component<any, IExploreState> {
const tableModel = res.data[0]; const tableModel = res.data[0];
const latency = Date.now() - now; const latency = Date.now() - now;
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) { } catch (response) {
console.error(response); console.error(response);
const queryError = response.data ? response.data.error : response; const queryError = response.data ? response.data.error : response;
...@@ -347,6 +380,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -347,6 +380,7 @@ export class Explore extends React.Component<any, IExploreState> {
const logsData = res.data; const logsData = res.data;
const latency = Date.now() - now; const latency = Date.now() - now;
this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options }); this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) { } catch (response) {
console.error(response); console.error(response);
const queryError = response.data ? response.data.error : response; const queryError = response.data ? response.data.error : response;
...@@ -367,6 +401,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -367,6 +401,7 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceLoading, datasourceLoading,
datasourceMissing, datasourceMissing,
graphResult, graphResult,
history,
latency, latency,
loading, loading,
logsResult, logsResult,
...@@ -405,12 +440,12 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -405,12 +440,12 @@ export class Explore extends React.Component<any, IExploreState> {
</a> </a>
</div> </div>
) : ( ) : (
<div className="navbar-buttons explore-first-button"> <div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.handleClickCloseSplit}> <button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
Close Split Close Split
</button> </button>
</div> </div>
)} )}
{!datasourceMissing ? ( {!datasourceMissing ? (
<div className="navbar-buttons"> <div className="navbar-buttons">
<Select <Select
...@@ -470,6 +505,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -470,6 +505,7 @@ export class Explore extends React.Component<any, IExploreState> {
{datasource && !datasourceError ? ( {datasource && !datasourceError ? (
<div className="explore-container"> <div className="explore-container">
<QueryRows <QueryRows
history={history}
queries={queries} queries={queries}
request={this.request} request={this.request}
onAddQueryRow={this.handleAddQueryRow} onAddQueryRow={this.handleAddQueryRow}
...@@ -488,7 +524,9 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -488,7 +524,9 @@ export class Explore extends React.Component<any, IExploreState> {
split={split} split={split}
/> />
) : null} ) : null}
{supportsTable && showingTable ? <Table data={tableResult} onClickCell={this.onClickTableCell} className="m-t-3" /> : null} {supportsTable && showingTable ? (
<Table data={tableResult} onClickCell={this.onClickTableCell} className="m-t-3" />
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} /> : null} {supportsLogs && showingLogs ? <Logs data={logsResult} /> : null}
</main> </main>
</div> </div>
......
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment';
import React from 'react'; import React from 'react';
import { Value } from 'slate'; import { Value } from 'slate';
...@@ -19,6 +20,8 @@ import TypeaheadField, { ...@@ -19,6 +20,8 @@ import TypeaheadField, {
const DEFAULT_KEYS = ['job', 'instance']; const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}'; const EMPTY_SELECTOR = '{}';
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric'; const METRIC_MARK = 'metric';
const PRISM_LANGUAGE = 'promql'; const PRISM_LANGUAGE = 'promql';
...@@ -28,6 +31,22 @@ export const setFunctionMove = (suggestion: Suggestion): Suggestion => { ...@@ -28,6 +31,22 @@ export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
return suggestion; return suggestion;
}; };
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export function willApplySuggestion( export function willApplySuggestion(
suggestion: string, suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState { typeaheadContext, typeaheadText }: TypeaheadFieldState
...@@ -59,6 +78,7 @@ export function willApplySuggestion( ...@@ -59,6 +78,7 @@ export function willApplySuggestion(
} }
interface PromQueryFieldProps { interface PromQueryFieldProps {
history?: any[];
initialQuery?: string | null; initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
...@@ -162,17 +182,37 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField ...@@ -162,17 +182,37 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
} }
getEmptyTypeahead(): TypeaheadOutput { getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = []; const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({ suggestions.push({
prefixMatch: true, prefixMatch: true,
label: 'Functions', label: 'Functions',
items: FUNCTIONS.map(setFunctionMove), items: FUNCTIONS.map(setFunctionMove),
}); });
if (this.state.metrics) { if (metrics) {
suggestions.push({ suggestions.push({
label: 'Metrics', label: 'Metrics',
items: this.state.metrics.map(wrapLabel), items: metrics.map(wrapLabel),
}); });
} }
return { suggestions }; return { suggestions };
......
...@@ -97,6 +97,10 @@ export interface SuggestionGroup { ...@@ -97,6 +97,10 @@ export interface SuggestionGroup {
* If true, do not filter items in this group based on the search. * If true, do not filter items in this group based on the search.
*/ */
skipFilter?: boolean; skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
} }
interface TypeaheadFieldProps { interface TypeaheadFieldProps {
...@@ -244,7 +248,9 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat ...@@ -244,7 +248,9 @@ class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldStat
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
} }
group.items = _.sortBy(group.items, item => item.sortText || item.label); if (!group.skipSort) {
group.items = _.sortBy(group.items, item => item.sortText || item.label);
}
} }
return group; return group;
}) })
......
...@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; ...@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import QueryField from './PromQueryField'; import QueryField from './PromQueryField';
class QueryRow extends PureComponent<any, any> { class QueryRow extends PureComponent<any, {}> {
handleChangeQuery = value => { handleChangeQuery = value => {
const { index, onChangeQuery } = this.props; const { index, onChangeQuery } = this.props;
if (onChangeQuery) { if (onChangeQuery) {
...@@ -32,7 +32,7 @@ class QueryRow extends PureComponent<any, any> { ...@@ -32,7 +32,7 @@ class QueryRow extends PureComponent<any, any> {
}; };
render() { render() {
const { request, query, edited } = this.props; const { edited, history, query, request } = this.props;
return ( return (
<div className="query-row"> <div className="query-row">
<div className="query-row-tools"> <div className="query-row-tools">
...@@ -46,6 +46,7 @@ class QueryRow extends PureComponent<any, any> { ...@@ -46,6 +46,7 @@ class QueryRow extends PureComponent<any, any> {
<div className="slate-query-field-wrapper"> <div className="slate-query-field-wrapper">
<QueryField <QueryField
initialQuery={edited ? null : query} initialQuery={edited ? null : query}
history={history}
portalPrefix="explore" portalPrefix="explore"
onPressEnter={this.handlePressEnter} onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery} onQueryChange={this.handleChangeQuery}
...@@ -57,7 +58,7 @@ class QueryRow extends PureComponent<any, any> { ...@@ -57,7 +58,7 @@ class QueryRow extends PureComponent<any, any> {
} }
} }
export default class QueryRows extends PureComponent<any, any> { export default class QueryRows extends PureComponent<any, {}> {
render() { render() {
const { className = '', queries, ...handlers } = this.props; const { className = '', queries, ...handlers } = this.props;
return ( return (
......
...@@ -32,6 +32,18 @@ describe('store', () => { ...@@ -32,6 +32,18 @@ describe('store', () => {
expect(store.getBool('key5', false)).toBe(true); expect(store.getBool('key5', false)).toBe(true);
}); });
it('gets an object', () => {
expect(store.getObject('object1')).toBeUndefined();
expect(store.getObject('object1', [])).toEqual([]);
store.setObject('object1', [1]);
expect(store.getObject('object1')).toEqual([1]);
});
it('sets an object', () => {
expect(store.setObject('object2', { a: 1 })).toBe(true);
expect(store.getObject('object2')).toEqual({ a: 1 });
});
it('key should be deleted', () => { it('key should be deleted', () => {
store.set('key6', '123'); store.set('key6', '123');
store.delete('key6'); store.delete('key6');
......
...@@ -14,6 +14,38 @@ export class Store { ...@@ -14,6 +14,38 @@ export class Store {
return window.localStorage[key] === 'true'; return window.localStorage[key] === 'true';
} }
getObject(key: string, def?: any) {
let ret = def;
if (this.exists(key)) {
const json = window.localStorage[key];
try {
ret = JSON.parse(json);
} catch (error) {
console.error(`Error parsing store object: ${key}. Returning default: ${def}. [${error}]`);
}
}
return ret;
}
// Returns true when successfully stored
setObject(key: string, value: any): boolean {
let json;
try {
json = JSON.stringify(value);
} catch (error) {
console.error(`Could not stringify object: ${key}. [${error}]`);
return false;
}
try {
this.set(key, json);
} catch (error) {
// Likely hitting storage quota
console.error(`Could not save item in localStorage: ${key}. [${error}]`);
return false;
}
return true;
}
exists(key) { exists(key) {
return window.localStorage[key] !== void 0; return window.localStorage[key] !== void 0;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment