Browse Source

Explore: Fix auto completion on label values for Loki (#18988)

Andrej Ocenas 6 năm trước cách đây
mục cha
commit
bc4ba64a24

+ 4 - 9
public/app/plugins/datasource/loki/components/useLokiLabels.test.ts

@@ -3,12 +3,11 @@ import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { useLokiLabels } from './useLokiLabels';
 import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
 import { AbsoluteTimeRange } from '@grafana/data';
+import { makeMockLokiDatasource } from '../mocks';
 
 describe('useLokiLabels hook', () => {
   it('should refresh labels', async () => {
-    const datasource = {
-      metadataRequest: () => ({ data: { data: [] as any[] } }),
-    };
+    const datasource = makeMockLokiDatasource({});
     const languageProvider = new LanguageProvider(datasource);
     const logLabelOptionsMock = ['Holy mock!'];
     const rangeMock: AbsoluteTimeRange = {
@@ -31,9 +30,7 @@ describe('useLokiLabels hook', () => {
   });
 
   it('should force refresh labels after a disconnect', () => {
-    const datasource = {
-      metadataRequest: () => ({ data: { data: [] as any[] } }),
-    };
+    const datasource = makeMockLokiDatasource({});
 
     const rangeMock: AbsoluteTimeRange = {
       from: 1560153109000,
@@ -52,9 +49,7 @@ describe('useLokiLabels hook', () => {
   });
 
   it('should not force refresh labels after a connect', () => {
-    const datasource = {
-      metadataRequest: () => ({ data: { data: [] as any[] } }),
-    };
+    const datasource = makeMockLokiDatasource({});
 
     const rangeMock: AbsoluteTimeRange = {
       from: 1560153109000,

+ 2 - 3
public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts

@@ -5,11 +5,10 @@ import { AbsoluteTimeRange } from '@grafana/data';
 import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
 import { useLokiSyntax } from './useLokiSyntax';
 import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
+import { makeMockLokiDatasource } from '../mocks';
 
 describe('useLokiSyntax hook', () => {
-  const datasource = {
-    metadataRequest: () => ({ data: { data: [] as any[] } }),
-  };
+  const datasource = makeMockLokiDatasource({});
   const languageProvider = new LanguageProvider(datasource);
   const logLabelOptionsMock = ['Holy mock!'];
   const logLabelOptionsMock2 = ['Mock the hell?!'];

+ 80 - 70
public/app/plugins/datasource/loki/language_provider.test.ts

@@ -1,16 +1,16 @@
 // @ts-ignore
 import Plain from 'slate-plain-serializer';
 
-import LanguageProvider, { LABEL_REFRESH_INTERVAL, rangeToParams } from './language_provider';
+import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider';
 import { AbsoluteTimeRange } from '@grafana/data';
 import { advanceTo, clear, advanceBy } from 'jest-date-mock';
 import { beforeEach } from 'test/lib/common';
-import { DataQueryResponseData } from '@grafana/ui';
+import { DataSourceApi } from '@grafana/ui';
+import { TypeaheadInput } from '../../../types';
+import { makeMockLokiDatasource } from './mocks';
 
 describe('Language completion provider', () => {
-  const datasource = {
-    metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }),
-  };
+  const datasource = makeMockLokiDatasource({});
 
   const rangeMock: AbsoluteTimeRange = {
     from: 1560153109000,
@@ -30,9 +30,10 @@ describe('Language completion provider', () => {
     it('returns default suggestions with history on empty context when history was provided', () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('');
-      const history = [
+      const history: LokiHistoryItem[] = [
         {
           query: { refId: '1', expr: '{app="foo"}' },
+          ts: 1,
         },
       ];
       const result = instance.provideCompletionItems(
@@ -55,25 +56,14 @@ describe('Language completion provider', () => {
 
     it('returns no suggestions within regexp', () => {
       const instance = new LanguageProvider(datasource);
-      const value = Plain.deserialize('{} ()');
-      const range = value.selection.merge({
-        anchorOffset: 4,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const history = [
+      const input = createTypeaheadInput('{} ()', '', undefined, 4, []);
+      const history: LokiHistoryItem[] = [
         {
           query: { refId: '1', expr: '{app="foo"}' },
+          ts: 1,
         },
       ];
-      const result = instance.provideCompletionItems(
-        {
-          text: '',
-          prefix: '',
-          value: valueWithSelection,
-          wrapperClasses: [],
-        },
-        { history }
-      );
+      const result = instance.provideCompletionItems(input, { history });
       expect(result.context).toBeUndefined();
       expect(result.refresher).toBeUndefined();
       expect(result.suggestions.length).toEqual(0);
@@ -83,23 +73,35 @@ describe('Language completion provider', () => {
   describe('label suggestions', () => {
     it('returns default label suggestions on label context', () => {
       const instance = new LanguageProvider(datasource);
-      const value = Plain.deserialize('{}');
-      const range = value.selection.merge({
-        anchorOffset: 1,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems(
-        {
-          text: '',
-          prefix: '',
-          wrapperClasses: ['context-labels'],
-          value: valueWithSelection,
-        },
-        { absoluteRange: rangeMock }
-      );
+      const input = createTypeaheadInput('{}', '');
+      const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock });
       expect(result.context).toBe('context-labels');
       expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]);
     });
+
+    it('returns label suggestions from Loki', async () => {
+      const datasource = makeMockLokiDatasource({ label1: [], label2: [] });
+      const provider = await getLanguageProvider(datasource);
+      const input = createTypeaheadInput('{}', '');
+      const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+      expect(result.context).toBe('context-labels');
+      expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]);
+    });
+
+    it('returns label values suggestions from Loki', async () => {
+      const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] });
+      const provider = await getLanguageProvider(datasource);
+      const input = createTypeaheadInput('{label1=}', '=', 'label1');
+      let result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+      // The values for label are loaded adhoc and there is a promise returned that we have to wait for
+      expect(result.refresher).toBeDefined();
+      await result.refresher;
+      result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+      expect(result.context).toBe('context-label-values');
+      expect(result.suggestions).toEqual([
+        { items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' },
+      ]);
+    });
   });
 });
 
@@ -110,17 +112,8 @@ describe('Request URL', () => {
       to: 1560163909000,
     };
 
-    const datasourceWithLabels = {
-      metadataRequest: (url: string) => {
-        if (url.slice(0, 15) === '/api/prom/label') {
-          return { data: { data: ['other'] } };
-        } else {
-          return { data: { data: [] } };
-        }
-      },
-    };
-
-    const datasourceSpy = jest.spyOn(datasourceWithLabels, 'metadataRequest');
+    const datasourceWithLabels = makeMockLokiDatasource({ other: [] });
+    const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest');
 
     const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
     await instance.refreshLogLabels(rangeMock, true);
@@ -130,9 +123,7 @@ describe('Request URL', () => {
 });
 
 describe('Query imports', () => {
-  const datasource = {
-    metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }),
-  };
+  const datasource = makeMockLokiDatasource({});
 
   const rangeMock: AbsoluteTimeRange = {
     from: 1560153109000,
@@ -153,36 +144,21 @@ describe('Query imports', () => {
     });
 
     it('returns empty query from selector query if label is not available', async () => {
-      const datasourceWithLabels = {
-        metadataRequest: (url: string) =>
-          url.slice(0, 15) === '/api/prom/label'
-            ? { data: { data: ['other'] } }
-            : { data: { data: [] as DataQueryResponseData[] } },
-      };
+      const datasourceWithLabels = makeMockLokiDatasource({ other: [] });
       const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
       const result = await instance.importPrometheusQuery('{foo="bar"}');
       expect(result).toEqual('{}');
     });
 
     it('returns selector query from selector query with common labels', async () => {
-      const datasourceWithLabels = {
-        metadataRequest: (url: string) =>
-          url.slice(0, 15) === '/api/prom/label'
-            ? { data: { data: ['foo'] } }
-            : { data: { data: [] as DataQueryResponseData[] } },
-      };
+      const datasourceWithLabels = makeMockLokiDatasource({ foo: [] });
       const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
       const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
       expect(result).toEqual('{foo="bar"}');
     });
 
     it('returns selector query from selector query with all labels if logging label list is empty', async () => {
-      const datasourceWithLabels = {
-        metadataRequest: (url: string) =>
-          url.slice(0, 15) === '/api/prom/label'
-            ? { data: { data: [] as DataQueryResponseData[] } }
-            : { data: { data: [] as DataQueryResponseData[] } },
-      };
+      const datasourceWithLabels = makeMockLokiDatasource({});
       const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
       const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
       expect(result).toEqual('{baz="42",foo="bar"}');
@@ -191,9 +167,7 @@ describe('Query imports', () => {
 });
 
 describe('Labels refresh', () => {
-  const datasource = {
-    metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }),
-  };
+  const datasource = makeMockLokiDatasource({});
   const instance = new LanguageProvider(datasource);
 
   const rangeMock: AbsoluteTimeRange = {
@@ -226,3 +200,39 @@ describe('Labels refresh', () => {
     expect(instance.fetchLogLabels).toBeCalled();
   });
 });
+
+async function getLanguageProvider(datasource: DataSourceApi) {
+  const instance = new LanguageProvider(datasource);
+  instance.initialRange = {
+    from: Date.now() - 10000,
+    to: Date.now(),
+  };
+  await instance.start();
+  return instance;
+}
+
+/**
+ * @param value Value of the full input
+ * @param text Last piece of text (not sure but in case of {label=} this would be just '=')
+ * @param labelKey Label by which to search for values. Cutting corners a bit here as this should be inferred from value
+ */
+function createTypeaheadInput(
+  value: string,
+  text: string,
+  labelKey?: string,
+  anchorOffset?: number,
+  wrapperClasses?: string[]
+): TypeaheadInput {
+  const deserialized = Plain.deserialize(value);
+  const range = deserialized.selection.merge({
+    anchorOffset: anchorOffset || 1,
+  });
+  const valueWithSelection = deserialized.change().select(range).value;
+  return {
+    text,
+    prefix: '',
+    wrapperClasses: wrapperClasses || ['context-labels'],
+    value: valueWithSelection,
+    labelKey,
+  };
+}

+ 26 - 5
public/app/plugins/datasource/loki/language_provider.ts

@@ -17,6 +17,7 @@ import {
 import { LokiQuery } from './types';
 import { dateTime, AbsoluteTimeRange } from '@grafana/data';
 import { PromQuery } from '../prometheus/types';
+import { DataSourceApi } from '@grafana/ui';
 
 const DEFAULT_KEYS = ['job', 'namespace'];
 const EMPTY_SELECTOR = '{}';
@@ -28,7 +29,12 @@ export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
 const wrapLabel = (label: string) => ({ label });
 export const rangeToParams = (range: AbsoluteTimeRange) => ({ start: range.from * NS_IN_MS, end: range.to * NS_IN_MS });
 
-type LokiHistoryItem = HistoryItem<LokiQuery>;
+export type LokiHistoryItem = HistoryItem<LokiQuery>;
+
+type TypeaheadContext = {
+  history?: LokiHistoryItem[];
+  absoluteRange?: AbsoluteTimeRange;
+};
 
 export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryItem[]): CompletionItem {
   const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
@@ -54,7 +60,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
   started: boolean;
   initialRange: AbsoluteTimeRange;
 
-  constructor(datasource: any, initialValues?: any) {
+  constructor(datasource: DataSourceApi, initialValues?: any) {
     super();
 
     this.datasource = datasource;
@@ -74,6 +80,10 @@ export default class LokiLanguageProvider extends LanguageProvider {
     return this.datasource.metadataRequest(url, params);
   };
 
+  /**
+   * Initialise the language provider by fetching set of labels. Without this initialisation the provider would return
+   * just a set of hardcoded default labels on provideCompletionItems or a recent queries from history.
+   */
   start = () => {
     if (!this.startTask) {
       this.startTask = this.fetchLogLabels(this.initialRange);
@@ -81,14 +91,22 @@ export default class LokiLanguageProvider extends LanguageProvider {
     return this.startTask;
   };
 
-  // Keep this DOM-free for testing
-  provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput {
+  /**
+   * Return suggestions based on input that can be then plugged into a typeahead dropdown.
+   * Keep this DOM-free for testing
+   * @param input
+   * @param context Is optional in types but is required in case we are doing getLabelCompletionItems
+   * @param context.absoluteRange Required in case we are doing getLabelCompletionItems
+   * @param context.history Optional used only in getEmptyCompletionItems
+   */
+  provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): TypeaheadOutput {
+    const { wrapperClasses, value } = input;
     // Local text properties
     const empty = value.document.text.length === 0;
     // Determine candidates by CSS context
     if (_.includes(wrapperClasses, 'context-labels')) {
       // Suggestions for {|} and {foo=|}
-      return this.getLabelCompletionItems.apply(this, arguments);
+      return this.getLabelCompletionItems(input, context);
     } else if (empty) {
       return this.getEmptyCompletionItems(context || {});
     }
@@ -245,6 +263,9 @@ export default class LokiLanguageProvider extends LanguageProvider {
         ...this.labelKeys,
         [EMPTY_SELECTOR]: labelKeys,
       };
+      this.labelValues = {
+        [EMPTY_SELECTOR]: {},
+      };
       this.logLabelOptions = labelKeys.map((key: string) => ({ label: key, value: key, isLeaf: false }));
     } catch (e) {
       console.error(e);

+ 27 - 0
public/app/plugins/datasource/loki/mocks.ts

@@ -0,0 +1,27 @@
+import { DataSourceApi } from '@grafana/ui';
+
+export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): DataSourceApi {
+  const labels = Object.keys(labelsAndValues);
+  return {
+    metadataRequest: (url: string) => {
+      let responseData;
+      if (url === '/api/prom/label') {
+        responseData = labels;
+      } else {
+        const match = url.match(/^\/api\/prom\/label\/(\w*)\/values/);
+        if (match) {
+          responseData = labelsAndValues[match[1]];
+        }
+      }
+      if (responseData) {
+        return {
+          data: {
+            data: responseData,
+          },
+        };
+      } else {
+        throw new Error(`Unexpected url error, ${url}`);
+      }
+    },
+  } as any;
+}