mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #12799 from grafana/davkal/explore-history
Explore: Add history to query fields
This commit is contained in:
commit
34761205fd
@ -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 = [{ 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 }) {
|
||||
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>
|
||||
|
@ -1,4 +1,5 @@
|
||||
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[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(
|
||||
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,37 @@ 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')
|
||||
.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({
|
||||
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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user