mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
dc60828407
commit
eaff7b0f68
@ -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';
|
|||||||
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 {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 }) {
|
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> {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
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> {
|
|||||||
</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> {
|
|||||||
{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> {
|
|||||||
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>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
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, {
|
|||||||
|
|
||||||
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 => {
|
|||||||
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.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(
|
export function willApplySuggestion(
|
||||||
suggestion: string,
|
suggestion: string,
|
||||||
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
|
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
|
||||||
@ -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,38 @@ 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')
|
||||||
|
.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({
|
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 {
|
|||||||
* 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
|
|||||||
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';
|
|||||||
|
|
||||||
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> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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> {
|
|||||||
<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> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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', () => {
|
|||||||
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 {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user