浏览代码

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
David Kaltschmidt 7 年之前
父节点
当前提交
eaff7b0f68

+ 44 - 6
public/app/containers/Explore/Explore.tsx

@@ -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>

+ 43 - 2
public/app/containers/Explore/PromQueryField.tsx

@@ -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.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 };

+ 7 - 1
public/app/containers/Explore/QueryField.tsx

@@ -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;
         })

+ 4 - 3
public/app/containers/Explore/QueryRows.tsx

@@ -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 (

+ 12 - 0
public/app/core/specs/store.jest.ts

@@ -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');

+ 32 - 0
public/app/core/store.ts

@@ -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;
   }