Bladeren bron

feat(Explore): make sure Loki labels are up to date (#16131)

* Migrated loki syntax and labels logic to useLokiSyntax hook

* Enable loki labels  refresh after specified interval has passed

* Enable periodic loki labels refresh when labels selector is opened

* Fix prettier

* Add react-hooks-testing-library and disable lib check on typecheck

* Add tests for loki syntax/label hooks

* Move tsc's skipLibCheck option to tsconfig for webpack to pick it up

* Set log labels refresh marker variable when log labels fetch start

* Fix prettier issues

* Fix type on activeOption in useLokiLabel hook

* Typo fixes and types in useLokiSyntax hook test fixes

* Make sure effect's setState is not performed on unmounted component

* Extract logic for checking if is component mounted to a separate hook
Dominik Prokop 6 jaren geleden
bovenliggende
commit
c2e9daad1e

+ 2 - 1
package.json

@@ -20,6 +20,7 @@
     "@types/angular": "^1.6.6",
     "@types/chalk": "^2.2.0",
     "@types/classnames": "^2.2.6",
+    "@types/clipboard": "^2.0.1",
     "@types/commander": "^2.12.2",
     "@types/d3": "^4.10.1",
     "@types/enzyme": "^3.1.13",
@@ -34,7 +35,6 @@
     "@types/react-select": "^2.0.4",
     "@types/react-transition-group": "^2.0.15",
     "@types/react-virtualized": "^9.18.12",
-    "@types/clipboard": "^2.0.1",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^9.4.10",
     "axios": "^0.18.0",
@@ -95,6 +95,7 @@
     "postcss-loader": "^3.0.0",
     "postcss-reporter": "^6.0.1",
     "prettier": "1.16.4",
+    "react-hooks-testing-library": "^0.3.7",
     "react-hot-loader": "^4.3.6",
     "react-test-renderer": "^16.5.0",
     "redux-mock-store": "^1.5.3",

+ 12 - 0
public/app/core/hooks/useRefMounted.ts

@@ -0,0 +1,12 @@
+import { useRef, useEffect } from 'react';
+
+export const useRefMounted = () => {
+  const refMounted = useRef(false);
+  useEffect(() => {
+    refMounted.current = true;
+    return () => {
+      refMounted.current = false;
+    };
+  });
+  return refMounted;
+};

+ 24 - 264
public/app/plugins/datasource/loki/components/LokiQueryField.tsx

@@ -1,266 +1,26 @@
-// Libraries
-import React from 'react';
-import Cascader from 'rc-cascader';
-import PluginPrism from 'slate-prism';
-import Prism from 'prismjs';
-
-// Components
-import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
-
-// Utils & Services
-// dom also includes Element polyfills
-import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
-import BracesPlugin from 'app/features/explore/slate-plugins/braces';
-import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
-
-// Types
-import { LokiQuery } from '../types';
-import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
-import { makePromiseCancelable, CancelablePromise } from 'app/core/utils/CancelablePromise';
-import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
-
-const PRISM_SYNTAX = 'promql';
-
-function getChooserText(hasSytax, hasLogLabels) {
-  if (!hasSytax) {
-    return 'Loading labels...';
-  }
-  if (!hasLogLabels) {
-    return '(No labels found)';
-  }
-  return 'Log labels';
-}
-
-export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
-  // Modify suggestion based on context
-  switch (typeaheadContext) {
-    case 'context-labels': {
-      const nextChar = getNextCharacter();
-      if (!nextChar || nextChar === '}' || nextChar === ',') {
-        suggestion += '=';
-      }
-      break;
-    }
-
-    case 'context-label-values': {
-      // Always add quotes and remove existing ones instead
-      if (!typeaheadText.match(/^(!?=~?"|")/)) {
-        suggestion = `"${suggestion}`;
-      }
-      if (getNextCharacter() !== '"') {
-        suggestion = `${suggestion}"`;
-      }
-      break;
-    }
-
-    default:
-  }
-  return suggestion;
-}
-
-interface CascaderOption {
-  label: string;
-  value: string;
-  children?: CascaderOption[];
-  disabled?: boolean;
-}
-
-interface LokiQueryFieldProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> {
-  history: HistoryItem[];
-}
-
-interface LokiQueryFieldState {
-  logLabelOptions: any[];
-  syntaxLoaded: boolean;
-}
-
-export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
-  plugins: any[];
-  pluginsSearch: any[];
-  languageProvider: any;
-  modifiedSearch: string;
-  modifiedQuery: string;
-  languageProviderInitializationPromise: CancelablePromise<any>;
-
-  constructor(props: LokiQueryFieldProps, context) {
-    super(props, context);
-
-    if (props.datasource.languageProvider) {
-      this.languageProvider = props.datasource.languageProvider;
-    }
-
-    this.plugins = [
-      BracesPlugin(),
-      RunnerPlugin({ handler: props.onExecuteQuery }),
-      PluginPrism({
-        onlyIn: node => node.type === 'code_block',
-        getSyntax: node => 'promql',
-      }),
-    ];
-
-    this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })];
-
-    this.state = {
-      logLabelOptions: [],
-      syntaxLoaded: false,
-    };
-  }
-
-  componentDidMount() {
-    if (this.languageProvider) {
-      this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start());
-
-      this.languageProviderInitializationPromise.promise
-        .then(remaining => {
-          remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
-        })
-        .then(() => this.onUpdateLanguage())
-        .catch(({ isCanceled }) => {
-          if (isCanceled) {
-            console.warn('LokiQueryField has unmounted, language provider intialization was canceled');
-          }
-        });
-    }
-  }
-
-  componentWillUnmount() {
-    if (this.languageProviderInitializationPromise) {
-      this.languageProviderInitializationPromise.cancel();
-    }
-  }
-
-  loadOptions = (selectedOptions: CascaderOption[]) => {
-    const targetOption = selectedOptions[selectedOptions.length - 1];
-
-    this.setState(state => {
-      const nextOptions = state.logLabelOptions.map(option => {
-        if (option.value === targetOption.value) {
-          return {
-            ...option,
-            loading: true,
-          };
-        }
-        return option;
-      });
-      return { logLabelOptions: nextOptions };
-    });
-
-    this.languageProvider
-      .fetchLabelValues(targetOption.value)
-      .then(this.onUpdateLanguage)
-      .catch(() => {});
-  };
-
-  onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
-    if (selectedOptions.length === 2) {
-      const key = selectedOptions[0].value;
-      const value = selectedOptions[1].value;
-      const query = `{${key}="${value}"}`;
-      this.onChangeQuery(query, true);
-    }
-  };
-
-  onChangeQuery = (value: string, override?: boolean) => {
-    // Send text change to parent
-    const { query, onQueryChange, onExecuteQuery } = this.props;
-    if (onQueryChange) {
-      const nextQuery = { ...query, expr: value };
-      onQueryChange(nextQuery);
-
-      if (override && onExecuteQuery) {
-        onExecuteQuery();
-      }
-    }
-  };
-
-  onClickHintFix = () => {
-    const { hint, onExecuteHint } = this.props;
-    if (onExecuteHint && hint && hint.fix) {
-      onExecuteHint(hint.fix.action);
-    }
-  };
-
-  onUpdateLanguage = () => {
-    Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
-    const { logLabelOptions } = this.languageProvider;
-    this.setState({
-      logLabelOptions,
-      syntaxLoaded: true,
-    });
-  };
-
-  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
-    if (!this.languageProvider) {
-      return { suggestions: [] };
-    }
-
-    const { history } = this.props;
-    const { prefix, text, value, wrapperNode } = typeahead;
-
-    // Get DOM-dependent context
-    const wrapperClasses = Array.from(wrapperNode.classList);
-    const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
-    const labelKey = labelKeyNode && labelKeyNode.textContent;
-    const nextChar = getNextCharacter();
-
-    const result = this.languageProvider.provideCompletionItems(
-      { text, value, prefix, wrapperClasses, labelKey },
-      { history }
-    );
-
-    console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
-
-    return result;
-  };
-
-  render() {
-    const { error, hint, query } = this.props;
-    const { logLabelOptions, syntaxLoaded } = this.state;
-    const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
-    const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
-    const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
-
-    return (
-      <>
-        <div className="gf-form-inline">
-          <div className="gf-form">
-            <Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}>
-              <button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
-                {chooserText} <i className="fa fa-caret-down" />
-              </button>
-            </Cascader>
-          </div>
-          <div className="gf-form gf-form--grow">
-            <QueryField
-              additionalPlugins={this.plugins}
-              cleanText={cleanText}
-              initialQuery={query.expr}
-              onTypeahead={this.onTypeahead}
-              onWillApplySuggestion={willApplySuggestion}
-              onQueryChange={this.onChangeQuery}
-              onExecuteQuery={this.props.onExecuteQuery}
-              placeholder="Enter a Loki query"
-              portalOrigin="loki"
-              syntaxLoaded={syntaxLoaded}
-            />
-          </div>
-        </div>
-        <div>
-          {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
-          {hint ? (
-            <div className="prom-query-field-info text-warning">
-              {hint.label}{' '}
-              {hint.fix ? (
-                <a className="text-link muted" onClick={this.onClickHintFix}>
-                  {hint.fix.label}
-                </a>
-              ) : null}
-            </div>
-          ) : null}
-        </div>
-      </>
-    );
-  }
-}
+import React, { FunctionComponent } from 'react';
+import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
+import { useLokiSyntax } from './useLokiSyntax';
+
+const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => {
+  const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider);
+
+  return (
+    <LokiQueryFieldForm
+      datasource={datasource}
+      syntaxLoaded={isSyntaxReady}
+      /**
+       * setActiveOption name is intentional. Because of the way rc-cascader requests additional data
+       * https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
+       * we are notyfing useLokiSyntax hook, what the active option is, and then it's up to the hook logic
+       * to fetch data of options that aren't fetched yet
+       */
+      onLoadOptions={setActiveOption}
+      onLabelsRefresh={refreshLabels}
+      {...syntaxProps}
+      {...otherProps}
+    />
+  );
+};
 
 export default LokiQueryField;

+ 217 - 0
public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx

@@ -0,0 +1,217 @@
+// Libraries
+import React from 'react';
+import Cascader from 'rc-cascader';
+import PluginPrism from 'slate-prism';
+
+// Components
+import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
+
+// Utils & Services
+// dom also includes Element polyfills
+import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
+import BracesPlugin from 'app/features/explore/slate-plugins/braces';
+import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
+
+// Types
+import { LokiQuery } from '../types';
+import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
+import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
+
+function getChooserText(hasSytax, hasLogLabels) {
+  if (!hasSytax) {
+    return 'Loading labels...';
+  }
+  if (!hasLogLabels) {
+    return '(No labels found)';
+  }
+  return 'Log labels';
+}
+
+function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
+  // Modify suggestion based on context
+  switch (typeaheadContext) {
+    case 'context-labels': {
+      const nextChar = getNextCharacter();
+      if (!nextChar || nextChar === '}' || nextChar === ',') {
+        suggestion += '=';
+      }
+      break;
+    }
+
+    case 'context-label-values': {
+      // Always add quotes and remove existing ones instead
+      if (!typeaheadText.match(/^(!?=~?"|")/)) {
+        suggestion = `"${suggestion}`;
+      }
+      if (getNextCharacter() !== '"') {
+        suggestion = `${suggestion}"`;
+      }
+      break;
+    }
+
+    default:
+  }
+  return suggestion;
+}
+
+export interface CascaderOption {
+  label: string;
+  value: string;
+  children?: CascaderOption[];
+  disabled?: boolean;
+}
+
+export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> {
+  history: HistoryItem[];
+  syntax: any;
+  logLabelOptions: any[];
+  syntaxLoaded: any;
+  onLoadOptions: (selectedOptions: CascaderOption[]) => void;
+  onLabelsRefresh?: () => void;
+}
+
+export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps> {
+  plugins: any[];
+  pluginsSearch: any[];
+  modifiedSearch: string;
+  modifiedQuery: string;
+
+  constructor(props: LokiQueryFieldFormProps, context) {
+    super(props, context);
+
+    this.plugins = [
+      BracesPlugin(),
+      RunnerPlugin({ handler: props.onExecuteQuery }),
+      PluginPrism({
+        onlyIn: node => node.type === 'code_block',
+        getSyntax: node => 'promql',
+      }),
+    ];
+
+    this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })];
+  }
+
+  loadOptions = (selectedOptions: CascaderOption[]) => {
+    this.props.onLoadOptions(selectedOptions);
+  };
+
+  onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
+    if (selectedOptions.length === 2) {
+      const key = selectedOptions[0].value;
+      const value = selectedOptions[1].value;
+      const query = `{${key}="${value}"}`;
+      this.onChangeQuery(query, true);
+    }
+  };
+
+  onChangeQuery = (value: string, override?: boolean) => {
+    // Send text change to parent
+    const { query, onQueryChange, onExecuteQuery } = this.props;
+    if (onQueryChange) {
+      const nextQuery = { ...query, expr: value };
+      onQueryChange(nextQuery);
+
+      if (override && onExecuteQuery) {
+        onExecuteQuery();
+      }
+    }
+  };
+
+  onClickHintFix = () => {
+    const { hint, onExecuteHint } = this.props;
+    if (onExecuteHint && hint && hint.fix) {
+      onExecuteHint(hint.fix.action);
+    }
+  };
+
+  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+    const { datasource } = this.props;
+    if (!datasource.languageProvider) {
+      return { suggestions: [] };
+    }
+
+    const { history } = this.props;
+    const { prefix, text, value, wrapperNode } = typeahead;
+
+    // Get DOM-dependent context
+    const wrapperClasses = Array.from(wrapperNode.classList);
+    const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
+    const labelKey = labelKeyNode && labelKeyNode.textContent;
+    const nextChar = getNextCharacter();
+
+    const result = datasource.languageProvider.provideCompletionItems(
+      { text, value, prefix, wrapperClasses, labelKey },
+      { history }
+    );
+
+    console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
+
+    return result;
+  };
+
+  render() {
+    const {
+      error,
+      hint,
+      query,
+      syntaxLoaded,
+      logLabelOptions,
+      onLoadOptions,
+      onLabelsRefresh,
+      datasource,
+    } = this.props;
+    const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
+    const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
+    const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
+
+    return (
+      <>
+        <div className="gf-form-inline">
+          <div className="gf-form">
+            <Cascader
+              options={logLabelOptions}
+              onChange={this.onChangeLogLabels}
+              loadData={onLoadOptions}
+              onPopupVisibleChange={isVisible => {
+                if (isVisible && onLabelsRefresh) {
+                  onLabelsRefresh();
+                }
+              }}
+            >
+              <button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
+                {chooserText} <i className="fa fa-caret-down" />
+              </button>
+            </Cascader>
+          </div>
+          <div className="gf-form gf-form--grow">
+            <QueryField
+              additionalPlugins={this.plugins}
+              cleanText={cleanText}
+              initialQuery={query.expr}
+              onTypeahead={this.onTypeahead}
+              onWillApplySuggestion={willApplySuggestion}
+              onQueryChange={this.onChangeQuery}
+              onExecuteQuery={this.props.onExecuteQuery}
+              placeholder="Enter a Loki query"
+              portalOrigin="loki"
+              syntaxLoaded={syntaxLoaded}
+            />
+          </div>
+        </div>
+        <div>
+          {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
+          {hint ? (
+            <div className="prom-query-field-info text-warning">
+              {hint.label}{' '}
+              {hint.fix ? (
+                <a className="text-link muted" onClick={this.onClickHintFix}>
+                  {hint.fix.label}
+                </a>
+              ) : null}
+            </div>
+          ) : null}
+        </div>
+      </>
+    );
+  }
+}

+ 24 - 0
public/app/plugins/datasource/loki/components/useLokiLabels.test.ts

@@ -0,0 +1,24 @@
+import { renderHook, act } from 'react-hooks-testing-library';
+import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
+import { useLokiLabels } from './useLokiLabels';
+
+describe('useLokiLabels hook', () => {
+  const datasource = {
+    metadataRequest: () => ({ data: { data: [] } }),
+  };
+  const languageProvider = new LanguageProvider(datasource);
+  const logLabelOptionsMock = ['Holy mock!'];
+
+  languageProvider.refreshLogLabels = () => {
+    languageProvider.logLabelOptions = logLabelOptionsMock;
+    return Promise.resolve();
+  };
+
+  it('should refresh labels', async () => {
+    const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, true, []));
+    act(() => result.current.refreshLabels());
+    expect(result.current.logLabelOptions).toEqual([]);
+    await waitForNextUpdate();
+    expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock);
+  });
+});

+ 79 - 0
public/app/plugins/datasource/loki/components/useLokiLabels.ts

@@ -0,0 +1,79 @@
+import { useState, useEffect } from 'react';
+import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
+import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
+import { useRefMounted } from 'app/core/hooks/useRefMounted';
+
+/**
+ *
+ * @param languageProvider
+ * @param languageProviderInitialised
+ * @param activeOption rc-cascader provided option used to fetch option's values that hasn't been loaded yet
+ *
+ * @description Fetches missing labels and enables labels refresh
+ */
+export const useLokiLabels = (
+  languageProvider: LokiLanguageProvider,
+  languageProviderInitialised: boolean,
+  activeOption: CascaderOption[]
+) => {
+  const mounted = useRefMounted();
+
+  // State
+  const [logLabelOptions, setLogLabelOptions] = useState([]);
+  const [shouldTryRefreshLabels, setRefreshLabels] = useState(false);
+
+  // Async
+  const fetchOptionValues = async option => {
+    await languageProvider.fetchLabelValues(option);
+    if (mounted.current) {
+      setLogLabelOptions(languageProvider.logLabelOptions);
+    }
+  };
+
+  const tryLabelsRefresh = async () => {
+    await languageProvider.refreshLogLabels();
+    if (mounted.current) {
+      setRefreshLabels(false);
+      setLogLabelOptions(languageProvider.logLabelOptions);
+    }
+  };
+
+  // Effects
+
+  // This effect performs loading of options that hasn't been loaded yet
+  // It's a subject of activeOption state change only. This is because of specific behavior or rc-cascader
+  // https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
+  useEffect(() => {
+    if (languageProviderInitialised) {
+      const targetOption = activeOption[activeOption.length - 1];
+      if (targetOption) {
+        const nextOptions = logLabelOptions.map(option => {
+          if (option.value === targetOption.value) {
+            return {
+              ...option,
+              loading: true,
+            };
+          }
+          return option;
+        });
+        setLogLabelOptions(nextOptions); // to set loading
+        fetchOptionValues(targetOption.value);
+      }
+    }
+  }, [activeOption]);
+
+  // This effect is performed on shouldTryRefreshLabels state change only.
+  // Since shouldTryRefreshLabels is reset AFTER the labels are refreshed we are secured in case of trying to refresh
+  // when previous refresh hasn't finished yet
+  useEffect(() => {
+    if (shouldTryRefreshLabels) {
+      tryLabelsRefresh();
+    }
+  }, [shouldTryRefreshLabels]);
+
+  return {
+    logLabelOptions,
+    setLogLabelOptions,
+    refreshLabels: () => setRefreshLabels(true),
+  };
+};

+ 66 - 0
public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts

@@ -0,0 +1,66 @@
+import { renderHook, act } from 'react-hooks-testing-library';
+import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
+import { useLokiSyntax } from './useLokiSyntax';
+import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
+
+describe('useLokiSyntax hook', () => {
+  const datasource = {
+    metadataRequest: () => ({ data: { data: [] } }),
+  };
+  const languageProvider = new LanguageProvider(datasource);
+  const logLabelOptionsMock = ['Holy mock!'];
+  const logLabelOptionsMock2 = ['Mock the hell?!'];
+  const logLabelOptionsMock3 = ['Oh my mock!'];
+
+  languageProvider.refreshLogLabels = () => {
+    languageProvider.logLabelOptions = logLabelOptionsMock;
+    return Promise.resolve();
+  };
+
+  languageProvider.fetchLogLabels = () => {
+    languageProvider.logLabelOptions = logLabelOptionsMock2;
+    return Promise.resolve([]);
+  };
+
+  const activeOptionMock: CascaderOption = {
+    label: '',
+    value: '',
+  };
+
+  it('should provide Loki syntax when used', async () => {
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    expect(result.current.syntax).toEqual(null);
+
+    await waitForNextUpdate();
+
+    expect(result.current.syntax).toEqual(languageProvider.getSyntax());
+  });
+
+  it('should fetch labels on first call', async () => {
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    expect(result.current.isSyntaxReady).toBeFalsy();
+    expect(result.current.logLabelOptions).toEqual([]);
+
+    await waitForNextUpdate();
+
+    expect(result.current.isSyntaxReady).toBeTruthy();
+    expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
+  });
+
+  it('should try to fetch missing options when active option changes', async () => {
+    const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
+    await waitForNextUpdate();
+    expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
+
+    languageProvider.fetchLabelValues = (key: string) => {
+      languageProvider.logLabelOptions = logLabelOptionsMock3;
+      return Promise.resolve();
+    };
+
+    act(() => result.current.setActiveOption([activeOptionMock]));
+
+    await waitForNextUpdate();
+
+    expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock3);
+  });
+});

+ 57 - 0
public/app/plugins/datasource/loki/components/useLokiSyntax.ts

@@ -0,0 +1,57 @@
+import { useState, useEffect } from 'react';
+import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
+import Prism from 'prismjs';
+import { useLokiLabels } from 'app/plugins/datasource/loki/components/useLokiLabels';
+import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
+import { useRefMounted } from 'app/core/hooks/useRefMounted';
+
+const PRISM_SYNTAX = 'promql';
+
+/**
+ *
+ * @param languageProvider
+ * @description Initializes given language provider, exposes Loki syntax and enables loading label option values
+ */
+export const useLokiSyntax = (languageProvider: LokiLanguageProvider) => {
+  const mounted = useRefMounted();
+  // State
+  const [languageProviderInitialized, setLanguageProviderInitilized] = useState(false);
+  const [syntax, setSyntax] = useState(null);
+
+  /**
+   * Holds information about currently selected option from rc-cascader to perform effect
+   * that loads option values not fetched yet. Based on that useLokiLabels hook decides whether or not
+   * the option requires additional data fetching
+   */
+  const [activeOption, setActiveOption] = useState<CascaderOption[]>();
+
+  const { logLabelOptions, setLogLabelOptions, refreshLabels } = useLokiLabels(
+    languageProvider,
+    languageProviderInitialized,
+    activeOption
+  );
+
+  // Async
+  const initializeLanguageProvider = async () => {
+    await languageProvider.start();
+    Prism.languages[PRISM_SYNTAX] = languageProvider.getSyntax();
+    if (mounted.current) {
+      setLogLabelOptions(languageProvider.logLabelOptions);
+      setSyntax(languageProvider.getSyntax());
+      setLanguageProviderInitilized(true);
+    }
+  };
+
+  // Effects
+  useEffect(() => {
+    initializeLanguageProvider();
+  }, []);
+
+  return {
+    isSyntaxReady: languageProviderInitialized,
+    syntax,
+    logLabelOptions,
+    setActiveOption,
+    refreshLabels,
+  };
+};

+ 33 - 1
public/app/plugins/datasource/loki/language_provider.test.ts

@@ -1,6 +1,8 @@
 import Plain from 'slate-plain-serializer';
 
-import LanguageProvider from './language_provider';
+import LanguageProvider, { LABEL_REFRESH_INTERVAL } from './language_provider';
+import { advanceTo, clear, advanceBy } from 'jest-date-mock';
+import { beforeEach } from 'test/lib/common';
 
 describe('Language completion provider', () => {
   const datasource = {
@@ -133,3 +135,33 @@ describe('Query imports', () => {
     });
   });
 });
+
+describe('Labels refresh', () => {
+  const datasource = {
+    metadataRequest: () => ({ data: { data: [] } }),
+  };
+  const instance = new LanguageProvider(datasource);
+
+  beforeEach(() => {
+    instance.fetchLogLabels = jest.fn();
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+    clear();
+  });
+  it("should not refresh labels if refresh interval hasn't passed", () => {
+    advanceTo(new Date(2019, 1, 1, 0, 0, 0));
+    instance.logLabelFetchTs = Date.now();
+    advanceBy(LABEL_REFRESH_INTERVAL / 2);
+    instance.refreshLogLabels();
+    expect(instance.fetchLogLabels).not.toBeCalled();
+  });
+  it('should refresh labels if refresh interval passed', () => {
+    advanceTo(new Date(2019, 1, 1, 0, 0, 0));
+    instance.logLabelFetchTs = Date.now();
+    advanceBy(LABEL_REFRESH_INTERVAL + 1);
+    instance.refreshLogLabels();
+    expect(instance.fetchLogLabels).toBeCalled();
+  });
+});

+ 12 - 1
public/app/plugins/datasource/loki/language_provider.ts

@@ -21,6 +21,7 @@ const DEFAULT_KEYS = ['job', 'namespace'];
 const EMPTY_SELECTOR = '{}';
 const HISTORY_ITEM_COUNT = 10;
 const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
+export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
 
 const wrapLabel = (label: string) => ({ label });
 
@@ -46,6 +47,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
   labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
   labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   logLabelOptions: any[];
+  logLabelFetchTs?: number;
   started: boolean;
 
   constructor(datasource: any, initialValues?: any) {
@@ -226,6 +228,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
   async fetchLogLabels() {
     const url = '/api/prom/label';
     try {
+      this.logLabelFetchTs = Date.now();
       const res = await this.request(url);
       const body = await (res.data || res.json());
       const labelKeys = body.data.slice().sort();
@@ -236,13 +239,21 @@ export default class LokiLanguageProvider extends LanguageProvider {
       this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
 
       // Pre-load values for default labels
-      return labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key));
+      return Promise.all(
+        labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key))
+      );
     } catch (e) {
       console.error(e);
     }
     return [];
   }
 
+  async refreshLogLabels() {
+    if (this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) {
+      await this.fetchLogLabels();
+    }
+  }
+
   async fetchLabelValues(key: string) {
     const url = `/api/prom/label/${key}/values`;
     try {

+ 2 - 1
tsconfig.json

@@ -31,7 +31,8 @@
     "paths": {
       "app": ["app"],
       "sass": ["sass"]
-    }
+    },
+    "skipLibCheck": true
   },
   "include": ["public/app/**/*.ts", "public/app/**/*.tsx", "public/test/**/*.ts", "public/vendor/**/*.ts"]
 }

+ 41 - 0
yarn.lock

@@ -6424,6 +6424,16 @@ dom-serializer@0, dom-serializer@~0.1.0:
     domelementtype "^1.3.0"
     entities "^1.1.1"
 
+dom-testing-library@^3.13.1:
+  version "3.18.2"
+  resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-3.18.2.tgz#07d65166743ad3299b7bee5b488e9622c31241bc"
+  integrity sha512-+nYUgGhHarrCY8kLVmyHlgM+IGwBXXrYsWIJB6vpAx2ne9WFgKfwMGcOkkTKQhuAro0sP6RIuRGfm5NF3+ccmQ==
+  dependencies:
+    "@babel/runtime" "^7.3.4"
+    "@sheerun/mutationobserver-shim" "^0.3.2"
+    pretty-format "^24.5.0"
+    wait-for-expect "^1.1.0"
+
 dom-walk@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
@@ -13304,6 +13314,16 @@ pretty-format@^24.5.0:
     ansi-styles "^3.2.0"
     react-is "^16.8.4"
 
+pretty-format@^24.5.0:
+  version "24.5.0"
+  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.5.0.tgz#cc69a0281a62cd7242633fc135d6930cd889822d"
+  integrity sha512-/3RuSghukCf8Riu5Ncve0iI+BzVkbRU5EeUoArKARZobREycuH5O4waxvaNIloEXdb0qwgmEAed5vTpX1HNROQ==
+  dependencies:
+    "@jest/types" "^24.5.0"
+    ansi-regex "^4.0.0"
+    ansi-styles "^3.2.0"
+    react-is "^16.8.4"
+
 pretty-hrtime@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@@ -13841,6 +13861,14 @@ react-highlight-words@0.11.0:
     highlight-words-core "^1.2.0"
     prop-types "^15.5.8"
 
+react-hooks-testing-library@^0.3.7:
+  version "0.3.7"
+  resolved "https://registry.yarnpkg.com/react-hooks-testing-library/-/react-hooks-testing-library-0.3.7.tgz#583d6b9026e458c6cdc28874b952b2359647867f"
+  integrity sha512-SjmPBb0ars9sh37n0MBYz3VZC5QuzUFF6/8LZlprKgsg0YRNXGKsKbuAV8k7dqX8qmprMKzXQqfZmZDFbvZkVg==
+  dependencies:
+    "@babel/runtime" "^7.3.4"
+    react-testing-library "^6.0.0"
+
 react-hot-loader@^4.3.6:
   version "4.8.0"
   resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.8.0.tgz#0b7c7dd9407415e23eb8246fdd28b0b839f54cb6"
@@ -13983,6 +14011,14 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.5.0, react-test-renderer@
     react-is "^16.8.4"
     scheduler "^0.13.4"
 
+react-testing-library@^6.0.0:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-6.0.1.tgz#0ddf155cb609529e37359a82cc63eb3f830397fd"
+  integrity sha512-Asyrmdj059WnD8q4pVsKoPtvWfXEk+OCCNKSo9bh5tZ0pb80iXvkr4oppiL8H2qWL+MJUV2PTMneHYxsTeAa/A==
+  dependencies:
+    "@babel/runtime" "^7.3.1"
+    dom-testing-library "^3.13.1"
+
 react-textarea-autosize@^7.0.4:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.0.tgz#3132cb77e65d94417558d37c0bfe415a5afd3445"
@@ -16995,6 +17031,11 @@ w3c-hr-time@^1.0.1:
   dependencies:
     browser-process-hrtime "^0.1.2"
 
+wait-for-expect@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.1.0.tgz#6607375c3f79d32add35cd2c87ce13f351a3d453"
+  integrity sha512-vQDokqxyMyknfX3luCDn16bSaRcOyH6gGuUXMIbxBLeTo6nWuEWYqMTT9a+44FmW8c2m6TRWBdNvBBjA1hwEKg==
+
 walkdir@^0.0.11:
   version "0.0.11"
   resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532"