Commit eaff7b0f by David Kaltschmidt

Explore: Add history to query fields

- queries are saved to localstorage history array
- one history per datasource type (plugin ID)
- 100 items kept with timestamps
- history suggestions can be pulled up with Ctrl-SPACE
parent dc608284
......@@ -4,6 +4,7 @@ import Select from 'react-select';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2';
import { decodePathComponent } from 'app/core/utils/location_util';
import { parse as parseDate } from 'app/core/utils/datemath';
......@@ -16,6 +17,8 @@ import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100;
function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || [];
......@@ -56,6 +59,7 @@ interface IExploreState {
datasourceLoading: boolean | null;
datasourceMissing: boolean;
graphResult: any;
history: any[];
initialDatasource?: string;
latency: number;
loading: any;
......@@ -86,6 +90,7 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceMissing: false,
graphResult: null,
initialDatasource: datasource,
history: [],
latency: 0,
loading: false,
logsResult: null,
......@@ -138,6 +143,7 @@ export class Explore extends React.Component<any, IExploreState> {
const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics;
const datasourceId = datasource.meta.id;
let datasourceError = null;
try {
......@@ -147,10 +153,14 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceError = (error && error.statusText) || error;
}
const historyKey = `grafana.explore.history.${datasourceId}`;
const history = store.getObject(historyKey, []);
this.setState(
{
datasource,
datasourceError,
history,
supportsGraph,
supportsLogs,
supportsTable,
......@@ -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 = [...history, { query, ts }];
});
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(history.length - 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 }) {
const { datasource, queries, range } = this.state;
const resolution = this.el.offsetWidth;
......@@ -301,6 +332,7 @@ export class Explore extends React.Component<any, IExploreState> {
const result = makeTimeSeriesList(res.data, options);
const latency = Date.now() - now;
this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
......@@ -324,6 +356,7 @@ export class Explore extends React.Component<any, IExploreState> {
const tableModel = res.data[0];
const latency = Date.now() - now;
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
......@@ -347,6 +380,7 @@ export class Explore extends React.Component<any, IExploreState> {
const logsData = res.data;
const latency = Date.now() - now;
this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
......@@ -367,6 +401,7 @@ export class Explore extends React.Component<any, IExploreState> {
datasourceLoading,
datasourceMissing,
graphResult,
history,
latency,
loading,
logsResult,
......@@ -405,12 +440,12 @@ export class Explore extends React.Component<any, IExploreState> {
</a>
</div>
) : (
<div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
Close Split
<div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
Close Split
</button>
</div>
)}
</div>
)}
{!datasourceMissing ? (
<div className="navbar-buttons">
<Select
......@@ -470,6 +505,7 @@ export class Explore extends React.Component<any, IExploreState> {
{datasource && !datasourceError ? (
<div className="explore-container">
<QueryRows
history={history}
queries={queries}
request={this.request}
onAddQueryRow={this.handleAddQueryRow}
......@@ -488,7 +524,9 @@ export class Explore extends React.Component<any, IExploreState> {
split={split}
/>
) : 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}
</main>
</div>
......
import _ from 'lodash';
import moment from 'moment';
import React from 'react';
import { Value } from 'slate';
......@@ -19,6 +20,8 @@ import TypeaheadField, {
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric';
const PRISM_LANGUAGE = 'promql';
......@@ -28,6 +31,22 @@ export const setFunctionMove = (suggestion: Suggestion): 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.pop();
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(
suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
......@@ -59,6 +78,7 @@ export function willApplySuggestion(
}
interface PromQueryFieldProps {
history?: any[];
initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
......@@ -162,17 +182,38 @@ class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryField
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.takeRight(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.reverse()
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (this.state.metrics) {
if (metrics) {
suggestions.push({
label: 'Metrics',
items: this.state.metrics.map(wrapLabel),
items: metrics.map(wrapLabel),
});
}
return { suggestions };
......
......@@ -97,6 +97,10 @@ export interface SuggestionGroup {
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface TypeaheadFieldProps {
......@@ -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 = _.sortBy(group.items, item => item.sortText || item.label);
if (!group.skipSort) {
group.items = _.sortBy(group.items, item => item.sortText || item.label);
}
}
return group;
})
......
......@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import QueryField from './PromQueryField';
class QueryRow extends PureComponent<any, any> {
class QueryRow extends PureComponent<any, {}> {
handleChangeQuery = value => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
......@@ -32,7 +32,7 @@ class QueryRow extends PureComponent<any, any> {
};
render() {
const { request, query, edited } = this.props;
const { edited, history, query, request } = this.props;
return (
<div className="query-row">
<div className="query-row-tools">
......@@ -46,6 +46,7 @@ class QueryRow extends PureComponent<any, any> {
<div className="slate-query-field-wrapper">
<QueryField
initialQuery={edited ? null : query}
history={history}
portalPrefix="explore"
onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery}
......@@ -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() {
const { className = '', queries, ...handlers } = this.props;
return (
......
......@@ -32,6 +32,18 @@ describe('store', () => {
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', () => {
store.set('key6', '123');
store.delete('key6');
......
......@@ -14,6 +14,38 @@ export class Store {
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) {
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