From 729ce8bb72a5db53fbeb1b5856557652bb175c7a Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 6 Oct 2022 16:35:30 +0200 Subject: [PATCH] Loki: query editor using Monaco (#55391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * loki: switch to a monaco-based query field, step 1 (#46291) * loki: use monaco-logql (#46318) * loki: use monaco-logql * updated monaco-logql * fix all the tests (#46327) * loki: recommend parser (#46362) * loki: recommend parser * additional improvements * more improvements * type and lint fixes * more improvements * trigger autocomplete on focus * rename * loki: more smart features (#46414) * loki: more smart features * loki: updated syntax-highlight version * better explanation (#46443) * better explanation * improved help-text Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * Fix label * feat(loki-monaco-editor): add monaco-logql as a dependency * feat(loki-monaco-editor): add back range function removed during merge * feat(loki-monaco-editor): sync imports with recent changes * feat(loki-monaco-editor): add missing lang provider functions * feat(loki-monaco-editor): fix imports * feat(loki-monaco-editor): display monaco editor by default Temporarily * Chore: remove commented code * Chore: minor refactor to NeverCaseError * Chore: minor code cleanups * feat(loki-monaco-editor): add history implementation Will see how it behaves and base the history slicing on tangible feedback * feat(loki-monaco-editor): turn completion data provider into a class * Chore: fix missing imports * feat(loki-monaco-editor): refactor data provider methods Move complexity scattered everywhere to the provider * Chore: clean up redundant code * Chore: minor comments cleanup * Chore: simplify override services * Chore: rename callback * feat(loki-monaco-editor): use query hints implementation to parse expression * feat(loki-monaco-editor): improve function name * Chore: remove superfluous variable in favor of destructuring * Chore: remove unused imports * Chore: make method async * feat(loki-monaco-editor): fix deprecations and errors in situation * feat(loki-monaco-editor): comment failing test case * Chore: remove comment from test * Chore: remove duplicated completion item * Chore: fix linting issues * Chore: update language provider test * Chore: update datasource test * feat(loki-monaco-editor): create feature flag * feat(loki-monaco-editor): place the editor under a feature flag * Chore: add completion unit test * Chore: add completion data provider test * Chore: remove unwanted export * Chore: remove unused export * Chore(loki-query-field): destructure all props * chore(loki-completions): remove odd string * fix(loki-completions): remove rate_interval Not supported * fix(loki-completions): remove line filters for after pipe case We shouldn't offer line filters if we are after first pipe. * refactor(loki-datasource): update default parameter * fix(loki-syntax): remove outdated documentation * Update capitalization in pkg/services/featuremgmt/registry.go Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * refactor(situation): use node types instead of names * Chore: comment line filters pending implementation It's breaking the build due to a linting error. * Chore: update feature flag test after capitalization change * Revert "fix(loki-completions): remove line filters for after pipe case" This reverts commit 3d003ca4bcb792b440add04b883fafe637e4c4c9. * Revert "Chore: comment line filters pending implementation" This reverts commit 84bfe76a6a29e79a15a3e080929d1f387691700b. Co-authored-by: Gábor Farkas Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Ivana Huckova --- .betterer.results | 3 +- package.json | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 5 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../datasource/loki/LanguageProvider.test.ts | 51 +- .../datasource/loki/LanguageProvider.ts | 16 + .../loki/components/LokiQueryField.tsx | 49 +- .../monaco-query-field/MonacoQueryField.tsx | 184 ++++++ .../MonacoQueryFieldLazy.tsx | 13 + .../MonacoQueryFieldProps.ts | 16 + .../MonacoQueryFieldWrapper.tsx | 34 ++ .../monaco-query-field/getOverrideServices.ts | 112 ++++ .../CompletionDataProvider.test.ts | 90 +++ .../CompletionDataProvider.ts | 52 ++ .../NeverCaseError.ts | 22 + .../completions.test.ts | 297 ++++++++++ .../monaco-completion-provider/completions.ts | 233 ++++++++ .../monaco-completion-provider/index.ts | 112 ++++ .../situation.test.ts | 195 +++++++ .../monaco-completion-provider/situation.ts | 523 ++++++++++++++++++ .../datasource/loki/datasource.test.ts | 18 + .../app/plugins/datasource/loki/datasource.ts | 8 +- .../datasource/loki/language_utils.test.ts | 43 ++ public/app/plugins/datasource/loki/mocks.ts | 2 +- public/app/plugins/datasource/loki/syntax.ts | 115 ++-- yarn.lock | 10 + 27 files changed, 2123 insertions(+), 86 deletions(-) create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.tsx create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldLazy.tsx create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldWrapper.tsx create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/getOverrideServices.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/NeverCaseError.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/index.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts create mode 100644 public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts create mode 100644 public/app/plugins/datasource/loki/language_utils.test.ts diff --git a/.betterer.results b/.betterer.results index bec918f5db7..069fc1a0b7a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6658,8 +6658,7 @@ exports[`better eslint`] = { ], "public/app/plugins/datasource/loki/components/LokiQueryField.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/package.json b/package.json index c2087d20f08..b7b50e8fe57 100644 --- a/package.json +++ b/package.json @@ -259,6 +259,7 @@ "@grafana/experimental": "^0.0.2-canary.36", "@grafana/google-sdk": "0.0.3", "@grafana/lezer-logql": "0.1.1", + "@grafana/monaco-logql": "^0.0.6", "@grafana/runtime": "workspace:*", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4cddbc53053..8fa95143f28 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -34,6 +34,7 @@ export interface FeatureToggles { publicDashboards?: boolean; lokiLive?: boolean; lokiDataframeApi?: boolean; + lokiMonacoEditor?: boolean; swaggerUi?: boolean; featureHighlights?: boolean; dashboardComments?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 53768484fec..a2a8ec46fc3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -105,6 +105,11 @@ var ( Description: "use experimental loki api for websocket streaming (early prototype)", State: FeatureStateAlpha, }, + { + Name: "lokiMonacoEditor", + Description: "Access to Monaco query editor for Loki", + State: FeatureStateAlpha, + }, { Name: "swaggerUi", Description: "Serves swagger UI", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index cf8ebc552ea..8ba975cc32b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -79,6 +79,10 @@ const ( // use experimental loki api for websocket streaming (early prototype) FlagLokiDataframeApi = "lokiDataframeApi" + // FlagLokiMonacoEditor + // Access to Monaco query editor for Loki + FlagLokiMonacoEditor = "lokiMonacoEditor" + // FlagSwaggerUi // Serves swagger UI FlagSwaggerUi = "swaggerUi" diff --git a/public/app/plugins/datasource/loki/LanguageProvider.test.ts b/public/app/plugins/datasource/loki/LanguageProvider.test.ts index 95e4c26d6e4..c6dadce7cd0 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.test.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.test.ts @@ -1,14 +1,16 @@ import Plain from 'slate-plain-serializer'; -import { AbstractLabelOperator } from '@grafana/data'; +import { AbstractLabelOperator, DataFrame } from '@grafana/data'; import { TypeaheadInput } from '@grafana/ui'; -import { TemplateSrv } from 'app/features/templating/template_srv'; import LanguageProvider, { LokiHistoryItem } from './LanguageProvider'; import { LokiDatasource } from './datasource'; import { createLokiDatasource, createMetadataRequest } from './mocks'; +import { extractLogParserFromDataFrame } from './responseUtils'; import { LokiQueryType } from './types'; +jest.mock('./responseUtils'); + jest.mock('app/store/store', () => ({ store: { getState: jest.fn().mockReturnValue({ @@ -297,6 +299,49 @@ describe('Query imports', () => { }); }); }); + + describe('getParserAndLabelKeys()', () => { + let datasource: LokiDatasource, languageProvider: LanguageProvider; + const extractLogParserFromDataFrameMock = extractLogParserFromDataFrame as jest.Mock; + beforeEach(() => { + datasource = createLokiDatasource(); + languageProvider = new LanguageProvider(datasource); + }); + + it('identifies selectors with JSON parser data', async () => { + jest.spyOn(datasource, 'getDataSamples').mockResolvedValue([{}] as DataFrame[]); + extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: false, hasJSON: true }); + + expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ + extractedLabelKeys: [], + hasJSON: true, + hasLogfmt: false, + }); + }); + + it('identifies selectors with Logfmt parser data', async () => { + jest.spyOn(datasource, 'getDataSamples').mockResolvedValue([{}] as DataFrame[]); + extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: true, hasJSON: false }); + + expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ + extractedLabelKeys: [], + hasJSON: false, + hasLogfmt: true, + }); + }); + + it('correctly processes empty data', async () => { + jest.spyOn(datasource, 'getDataSamples').mockResolvedValue([]); + extractLogParserFromDataFrameMock.mockClear(); + + expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ + extractedLabelKeys: [], + hasJSON: false, + hasLogfmt: false, + }); + expect(extractLogParserFromDataFrameMock).not.toHaveBeenCalled(); + }); + }); }); async function getLanguageProvider(datasource: LokiDatasource) { @@ -334,7 +379,7 @@ function setup( labelsAndValues: Record, series?: Record>> ): LokiDatasource { - const datasource = createLokiDatasource({} as unknown as TemplateSrv); + const datasource = createLokiDatasource(); const rangeMock = { start: 1560153109000, diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index 36ad7ce8976..b95692c8e4c 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -12,6 +12,7 @@ import { } from 'app/plugins/datasource/prometheus/language_utils'; import { LokiDatasource } from './datasource'; +import { extractLogParserFromDataFrame } from './responseUtils'; import syntax, { FUNCTIONS, PIPE_PARSERS, PIPE_OPERATORS } from './syntax'; import { LokiQuery, LokiQueryType } from './types'; @@ -461,4 +462,19 @@ export default class LokiLanguageProvider extends LanguageProvider { return labelValues ?? []; } + + async getParserAndLabelKeys( + selector: string + ): Promise<{ extractedLabelKeys: string[]; hasJSON: boolean; hasLogfmt: boolean }> { + const series = await this.datasource.getDataSamples({ expr: selector, refId: 'data-samples' }); + + if (!series.length) { + return { extractedLabelKeys: [], hasJSON: false, hasLogfmt: false }; + } + + const { hasLogfmt, hasJSON } = extractLogParserFromDataFrame(series[0]); + + // TODO: figure out extractedLabelKeys + return { extractedLabelKeys: [], hasJSON, hasLogfmt }; + } } diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index bfcf3e2c9bc..9101bd4032e 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -3,8 +3,8 @@ import React, { ReactNode } from 'react'; import { Plugin, Node } from 'slate'; import { Editor } from 'slate-react'; -import { QueryEditorProps } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { CoreApp, QueryEditorProps } from '@grafana/data'; +import { config, reportInteraction } from '@grafana/runtime'; import { SlatePrism, TypeaheadOutput, @@ -23,6 +23,7 @@ import { escapeLabelValueInSelector, shouldRefreshLabels } from '../languageUtil import { LokiQuery, LokiOptions } from '../types'; import { LokiLabelBrowser } from './LokiLabelBrowser'; +import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; @@ -185,12 +186,13 @@ export class LokiQueryField extends React.PureComponent 0; + const hasLogLabels = datasource.languageProvider.getLabelKeys().length > 0; const chooserText = getChooserText(labelsLoaded, hasLogLabels); const buttonDisabled = !(labelsLoaded && hasLogLabels); @@ -212,24 +214,35 @@ export class LokiQueryField extends React.PureComponent
- + {config.featureToggles.lokiMonacoEditor ? ( + + ) : ( + + )}
{labelBrowserVisible && (
{ + return { + container: css` + border-radius: ${theme.shape.borderRadius()}; + border: 1px solid ${theme.components.input.borderColor}; + `, + }; +}; + +const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initialValue }: Props) => { + // we need only one instance of `overrideServices` during the lifetime of the react component + const overrideServicesRef = useRef(getOverrideServices()); + const containerRef = useRef(null); + + const langProviderRef = useLatest(languageProvider); + const historyRef = useLatest(history); + const onRunQueryRef = useLatest(onRunQuery); + const onBlurRef = useLatest(onBlur); + + const autocompleteCleanupCallback = useRef<(() => void) | null>(null); + + const theme = useTheme2(); + const styles = getStyles(theme); + + useEffect(() => { + // when we unmount, we unregister the autocomplete-function, if it was registered + return () => { + autocompleteCleanupCallback.current?.(); + }; + }, []); + + return ( +
+ { + ensureLogQL(monaco); + }} + onMount={(editor, monaco) => { + // we setup on-blur + editor.onDidBlurEditorWidget(() => { + onBlurRef.current(editor.getValue()); + }); + const dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef.current); + const completionProvider = getCompletionProvider(monaco, dataProvider); + + // completion-providers in monaco are not registered directly to editor-instances, + // they are registered to languages. this makes it hard for us to have + // separate completion-providers for every query-field-instance + // (but we need that, because they might connect to different datasources). + // the trick we do is, we wrap the callback in a "proxy", + // and in the proxy, the first thing is, we check if we are called from + // "our editor instance", and if not, we just return nothing. if yes, + // we call the completion-provider. + const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = { + ...completionProvider, + provideCompletionItems: (model, position, context, token) => { + // if the model-id does not match, then this call is from a different editor-instance, + // not "our instance", so return nothing + if (editor.getModel()?.id !== model.id) { + return { suggestions: [] }; + } + return completionProvider.provideCompletionItems(model, position, context, token); + }, + }; + + const { dispose } = monaco.languages.registerCompletionItemProvider(LANG_ID, filteringCompletionProvider); + + autocompleteCleanupCallback.current = dispose; + // this code makes the editor resize itself so that the content fits + // (it will grow taller when necessary) + // FIXME: maybe move this functionality into CodeEditor, like: + // + const updateElementHeight = () => { + const containerDiv = containerRef.current; + if (containerDiv !== null) { + const pixelHeight = editor.getContentHeight(); + containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`; + containerDiv.style.width = '100%'; + const pixelWidth = containerDiv.clientWidth; + editor.layout({ width: pixelWidth, height: pixelHeight }); + } + }; + + editor.onDidContentSizeChange(updateElementHeight); + updateElementHeight(); + + // handle: shift + enter + // FIXME: maybe move this functionality into CodeEditor? + editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { + onRunQueryRef.current(editor.getValue()); + }); + + editor.onDidFocusEditorText(() => { + if (editor.getValue().trim() === '') { + editor.trigger('', 'editor.action.triggerSuggest', {}); + } + }); + }} + /> +
+ ); +}; + +// Default export for lazy load. +export default MonacoQueryField; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldLazy.tsx b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldLazy.tsx new file mode 100644 index 00000000000..aeb6c14c904 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldLazy.tsx @@ -0,0 +1,13 @@ +import React, { Suspense } from 'react'; + +import { Props } from './MonacoQueryFieldProps'; + +const Field = React.lazy(() => import(/* webpackChunkName: "loki-query-field" */ './MonacoQueryField')); + +export const MonacoQueryFieldLazy = (props: Props) => { + return ( + + + + ); +}; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts new file mode 100644 index 00000000000..077c012b572 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldProps.ts @@ -0,0 +1,16 @@ +import { HistoryItem } from '@grafana/data'; + +import type LanguageProvider from '../../LanguageProvider'; +import { LokiQuery } from '../../types'; + +// we need to store this in a separate file, +// because we have an async-wrapper around, +// the react-component, and it needs the same +// props as the sync-component. +export type Props = { + initialValue: string; + languageProvider: LanguageProvider; + history: Array>; + onRunQuery: (value: string) => void; + onBlur: (value: string) => void; +}; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldWrapper.tsx b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldWrapper.tsx new file mode 100644 index 00000000000..60295a3a83e --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryFieldWrapper.tsx @@ -0,0 +1,34 @@ +import React, { useRef } from 'react'; + +import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy'; +import { Props as MonacoProps } from './MonacoQueryFieldProps'; + +type Props = Omit & { + onChange: (query: string) => void; + onRunQuery: () => void; + runQueryOnBlur: boolean; +}; + +export const MonacoQueryFieldWrapper = (props: Props) => { + const lastRunValueRef = useRef(null); + const { runQueryOnBlur, onRunQuery, onChange, ...rest } = props; + + const handleRunQuery = (value: string) => { + lastRunValueRef.current = value; + onChange(value); + onRunQuery(); + }; + + const handleBlur = (value: string) => { + if (runQueryOnBlur) { + // run handleRunQuery only if the current value is different from the last-time-executed value + if (value !== lastRunValueRef.current) { + handleRunQuery(value); + } + } else { + onChange(value); + } + }; + + return ; +}; diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/getOverrideServices.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/getOverrideServices.ts new file mode 100644 index 00000000000..9d82d988974 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/getOverrideServices.ts @@ -0,0 +1,112 @@ +import { monacoTypes } from '@grafana/ui'; + +// this thing here is a workaround in a way. +// what we want to achieve, is that when the autocomplete-window +// opens, the "second, extra popup" with the extra help, +// also opens automatically. +// but there is no API to achieve it. +// the way to do it is to implement the `storageService` +// interface, and provide our custom implementation, +// which will default to `true` for the correct string-key. +// unfortunately, while the typescript-interface exists, +// it is not exported from monaco-editor, +// so we cannot rely on typescript to make sure +// we do it right. all we can do is to manually +// lookup the interface, and make sure we code our code right. +// our code is a "best effort" approach, +// i am not 100% how the `scope` and `target` things work, +// but so far it seems to work ok. +// i would use an another approach, if there was one available. + +function makeStorageService() { + // we need to return an object that fulfills this interface: + // https://github.com/microsoft/vscode/blob/ff1e16eebb93af79fd6d7af1356c4003a120c563/src/vs/platform/storage/common/storage.ts#L37 + // unfortunately it is not export from monaco-editor + + const strings = new Map(); + + // we want this to be true by default + strings.set('expandSuggestionDocs', true.toString()); + + return { + // we do not implement the on* handlers + onDidChangeValue: (data: unknown): void => undefined, + onDidChangeTarget: (data: unknown): void => undefined, + onWillSaveState: (data: unknown): void => undefined, + + get: (key: string, scope: unknown, fallbackValue?: string): string | undefined => { + return strings.get(key) ?? fallbackValue; + }, + + getBoolean: (key: string, scope: unknown, fallbackValue?: boolean): boolean | undefined => { + const val = strings.get(key); + if (val !== undefined) { + // the interface docs say the value will be converted + // to a boolean but do not specify how, so we improvise + return val === 'true'; + } else { + return fallbackValue; + } + }, + + getNumber: (key: string, scope: unknown, fallbackValue?: number): number | undefined => { + const val = strings.get(key); + if (val !== undefined) { + return parseInt(val, 10); + } else { + return fallbackValue; + } + }, + + store: ( + key: string, + value: string | boolean | number | undefined | null, + scope: unknown, + target: unknown + ): void => { + // the interface docs say if the value is nullish, it should act as delete + if (value === null || value === undefined) { + strings.delete(key); + } else { + strings.set(key, value.toString()); + } + }, + + remove: (key: string, scope: unknown): void => { + strings.delete(key); + }, + + keys: (scope: unknown, target: unknown): string[] => { + return Array.from(strings.keys()); + }, + + logStorage: (): void => { + console.log('logStorage: not implemented'); + }, + + migrate: (): Promise => { + // we do not implement this + return Promise.resolve(undefined); + }, + + isNew: (scope: unknown): boolean => { + // we create a new storage for every session, we do not persist it, + // so we return `true`. + return true; + }, + + flush: (reason?: unknown): Promise => { + // we do not implement this + return Promise.resolve(undefined); + }, + }; +} + +let overrideServices: monacoTypes.editor.IEditorOverrideServices = { + storageService: makeStorageService(), +}; + +export function getOverrideServices(): monacoTypes.editor.IEditorOverrideServices { + // One instance of this for every query editor + return overrideServices; +} diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts new file mode 100644 index 00000000000..c55ad148165 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.test.ts @@ -0,0 +1,90 @@ +import { HistoryItem } from '@grafana/data'; + +import LokiLanguageProvider from '../../../LanguageProvider'; +import { LokiDatasource } from '../../../datasource'; +import { createLokiDatasource } from '../../../mocks'; +import { LokiQuery } from '../../../types'; + +import { CompletionDataProvider } from './CompletionDataProvider'; +import { Label } from './situation'; + +const history = [ + { + ts: 12345678, + query: { + refId: 'test-1', + expr: '{test: unit}', + }, + }, + { + ts: 87654321, + query: { + refId: 'test-1', + expr: '{unit: test}', + }, + }, + { + ts: 0, + query: { + refId: 'test-0', + }, + }, +]; +const labelKeys = ['place', 'source']; +const labelValues = ['moon', 'luna']; +const otherLabels: Label[] = [ + { + name: 'place', + value: 'luna', + op: '=', + }, +]; +const seriesLabels = { place: ['series', 'labels'], source: [], other: [] }; +const parserAndLabelKeys = { + extractedLabelKeys: ['extracted', 'label', 'keys'], + hasJSON: true, + hasLogfmt: false, +}; + +describe('CompletionDataProvider', () => { + let completionProvider: CompletionDataProvider, languageProvider: LokiLanguageProvider, datasource: LokiDatasource; + beforeEach(() => { + datasource = createLokiDatasource(); + languageProvider = new LokiLanguageProvider(datasource); + completionProvider = new CompletionDataProvider(languageProvider, history as Array>); + + jest.spyOn(languageProvider, 'getLabelKeys').mockReturnValue(labelKeys); + jest.spyOn(languageProvider, 'getLabelValues').mockResolvedValue(labelValues); + jest.spyOn(languageProvider, 'getSeriesLabels').mockResolvedValue(seriesLabels); + jest.spyOn(languageProvider, 'getParserAndLabelKeys').mockResolvedValue(parserAndLabelKeys); + }); + + test('Returns the expected history entries', () => { + expect(completionProvider.getHistory()).toEqual(['{test: unit}', '{unit: test}']); + }); + + test('Returns the expected label names with no other labels', async () => { + expect(await completionProvider.getLabelNames([])).toEqual(labelKeys); + }); + + test('Returns the expected label names with other labels', async () => { + expect(await completionProvider.getLabelNames(otherLabels)).toEqual(['source', 'other']); + }); + + test('Returns the expected label values with no other labels', async () => { + expect(await completionProvider.getLabelValues('label', [])).toEqual(labelValues); + }); + + test('Returns the expected label values with other labels', async () => { + expect(await completionProvider.getLabelValues('place', otherLabels)).toEqual(['series', 'labels']); + expect(await completionProvider.getLabelValues('other label', otherLabels)).toEqual([]); + }); + + test('Returns the expected parser and label keys', async () => { + expect(await completionProvider.getParserAndLabelKeys([])).toEqual(parserAndLabelKeys); + }); + + test('Returns the expected series labels', async () => { + expect(await completionProvider.getSeriesLabels([])).toEqual(seriesLabels); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts new file mode 100644 index 00000000000..b2df73aeffc --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts @@ -0,0 +1,52 @@ +import { HistoryItem } from '@grafana/data'; +import { escapeLabelValueInExactSelector } from 'app/plugins/datasource/prometheus/language_utils'; + +import LanguageProvider from '../../../LanguageProvider'; +import { LokiQuery } from '../../../types'; + +import { Label } from './situation'; + +export class CompletionDataProvider { + constructor(private languageProvider: LanguageProvider, private history: Array> = []) {} + + private buildSelector(labels: Label[]): string { + const allLabelTexts = labels.map( + (label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"` + ); + + return `{${allLabelTexts.join(',')}}`; + } + + getHistory() { + return this.history.map((entry) => entry.query.expr).filter((expr) => expr !== undefined); + } + + async getLabelNames(otherLabels: Label[] = []) { + if (otherLabels.length === 0) { + // if there is no filtering, we have to use a special endpoint + return this.languageProvider.getLabelKeys(); + } + const data = await this.getSeriesLabels(otherLabels); + const possibleLabelNames = Object.keys(data); // all names from datasource + const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query + return possibleLabelNames.filter((label) => !usedLabelNames.has(label)); + } + + async getLabelValues(labelName: string, otherLabels: Label[]) { + if (otherLabels.length === 0) { + // if there is no filtering, we have to use a special endpoint + return await this.languageProvider.getLabelValues(labelName); + } + + const data = await this.getSeriesLabels(otherLabels); + return data[labelName] ?? []; + } + + async getParserAndLabelKeys(labels: Label[]) { + return await this.languageProvider.getParserAndLabelKeys(this.buildSelector(labels)); + } + + async getSeriesLabels(labels: Label[]) { + return await this.languageProvider.getSeriesLabels(this.buildSelector(labels)).then((data) => data ?? {}); + } +} diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/NeverCaseError.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/NeverCaseError.ts new file mode 100644 index 00000000000..8d78a1d3052 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/NeverCaseError.ts @@ -0,0 +1,22 @@ +// This helper class is used to make typescript warn you when you miss a case-block in a switch statement. +// For example: +// +// const x:'A'|'B'|'C' = 'A'; +// +// switch(x) { +// case 'A': +// // something +// case 'B': +// // something +// default: +// throw new NeverCaseError(x); +// } +// +// +// TypeScript detect the missing case and display an error. + +export class NeverCaseError extends Error { + constructor(value: never) { + super(`Unexpected case in switch statement: ${JSON.stringify(value)}`); + } +} diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts new file mode 100644 index 00000000000..6fcb67ff375 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -0,0 +1,297 @@ +import LokiLanguageProvider from '../../../LanguageProvider'; +import { LokiDatasource } from '../../../datasource'; +import { createLokiDatasource } from '../../../mocks'; + +import { CompletionDataProvider } from './CompletionDataProvider'; +import { getCompletions } from './completions'; +import { Label, Situation } from './situation'; + +const history = [ + { + ts: 12345678, + query: { + refId: 'test-1', + expr: '{test: unit}', + }, + }, + { + ts: 87654321, + query: { + refId: 'test-1', + expr: '{test: unit}', + }, + }, +]; + +const labelNames = ['place', 'source']; +const labelValues = ['moon', 'luna']; +const extractedLabelKeys = ['extracted', 'label']; +const otherLabels: Label[] = [ + { + name: 'place', + value: 'luna', + op: '=', + }, +]; +const afterSelectorCompletions = [ + { + insertText: '|= "$0"', + isSnippet: true, + label: '|= ""', + type: 'LINE_FILTER', + }, + { + insertText: '!= "$0"', + isSnippet: true, + label: '!= ""', + type: 'LINE_FILTER', + }, + { + insertText: '|~ "$0"', + isSnippet: true, + label: '|~ ""', + type: 'LINE_FILTER', + }, + { + insertText: '!~ "$0"', + isSnippet: true, + label: '!~ ""', + type: 'LINE_FILTER', + }, + { + insertText: '', + label: '// Placeholder for the detected parser', + type: 'DETECTED_PARSER_PLACEHOLDER', + }, + { + insertText: '', + label: '// Placeholder for logfmt or json', + type: 'OPPOSITE_PARSER_PLACEHOLDER', + }, + { + insertText: 'pattern', + label: 'pattern', + type: 'PARSER', + }, + { + insertText: 'regexp', + label: 'regexp', + type: 'PARSER', + }, + { + insertText: 'unpack', + label: 'unpack', + type: 'PARSER', + }, + { + insertText: 'unwrap extracted', + label: 'unwrap extracted (detected)', + type: 'LINE_FILTER', + }, + { + insertText: 'unwrap label', + label: 'unwrap label (detected)', + type: 'LINE_FILTER', + }, + { + insertText: 'unwrap', + label: 'unwrap', + type: 'LINE_FILTER', + }, + { + insertText: 'line_format "{{.$0}}"', + isSnippet: true, + label: 'line_format', + type: 'LINE_FORMAT', + }, +]; + +function buildAfterSelectorCompletions( + detectedParser: string, + detectedParserType: string, + otherParser: string, + explanation = '(detected)' +) { + return afterSelectorCompletions.map((completion) => { + if (completion.type === 'DETECTED_PARSER_PLACEHOLDER') { + return { + ...completion, + type: detectedParserType, + label: `${detectedParser} ${explanation}`, + insertText: detectedParser, + }; + } else if (completion.type === 'OPPOSITE_PARSER_PLACEHOLDER') { + return { + ...completion, + type: 'PARSER', + label: otherParser, + insertText: otherParser, + }; + } + + return { ...completion }; + }); +} + +describe('getCompletions', () => { + let completionProvider: CompletionDataProvider, languageProvider: LokiLanguageProvider, datasource: LokiDatasource; + beforeEach(() => { + datasource = createLokiDatasource(); + languageProvider = new LokiLanguageProvider(datasource); + completionProvider = new CompletionDataProvider(languageProvider, history); + + jest.spyOn(completionProvider, 'getLabelNames').mockResolvedValue(labelNames); + jest.spyOn(completionProvider, 'getLabelValues').mockResolvedValue(labelValues); + jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ + extractedLabelKeys, + hasJSON: false, + hasLogfmt: false, + }); + }); + + test.each(['EMPTY', 'AT_ROOT'])(`Returns completion options when the situation is %s`, async (type) => { + const situation = { type } as Situation; + const completions = await getCompletions(situation, completionProvider); + + expect(completions).toHaveLength(25); + }); + + test('Returns completion options when the situation is IN_DURATION', async () => { + const situation: Situation = { type: 'IN_DURATION' }; + const completions = await getCompletions(situation, completionProvider); + + expect(completions).toEqual([ + { insertText: '$__interval', label: '$__interval', type: 'DURATION' }, + { insertText: '$__range', label: '$__range', type: 'DURATION' }, + { insertText: '1m', label: '1m', type: 'DURATION' }, + { insertText: '5m', label: '5m', type: 'DURATION' }, + { insertText: '10m', label: '10m', type: 'DURATION' }, + { insertText: '30m', label: '30m', type: 'DURATION' }, + { insertText: '1h', label: '1h', type: 'DURATION' }, + { insertText: '1d', label: '1d', type: 'DURATION' }, + ]); + }); + + test('Returns completion options when the situation is IN_GROUPING', async () => { + const situation: Situation = { type: 'IN_GROUPING', otherLabels }; + const completions = await getCompletions(situation, completionProvider); + + expect(completions).toEqual([ + { + insertText: 'place', + label: 'place', + triggerOnInsert: false, + type: 'LABEL_NAME', + }, + { + insertText: 'source', + label: 'source', + triggerOnInsert: false, + type: 'LABEL_NAME', + }, + { + insertText: 'extracted', + label: 'extracted (parsed)', + triggerOnInsert: false, + type: 'LABEL_NAME', + }, + { + insertText: 'label', + label: 'label (parsed)', + triggerOnInsert: false, + type: 'LABEL_NAME', + }, + ]); + }); + + test('Returns completion options when the situation is IN_LABEL_SELECTOR_NO_LABEL_NAME', async () => { + const situation: Situation = { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels }; + const completions = await getCompletions(situation, completionProvider); + + expect(completions).toEqual([ + { + insertText: 'place=', + label: 'place', + triggerOnInsert: true, + type: 'LABEL_NAME', + }, + { + insertText: 'source=', + label: 'source', + triggerOnInsert: true, + type: 'LABEL_NAME', + }, + ]); + }); + + test('Returns completion options when the situation is IN_LABEL_SELECTOR_WITH_LABEL_NAME', async () => { + const situation: Situation = { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + otherLabels, + labelName: '', + betweenQuotes: false, + }; + let completions = await getCompletions(situation, completionProvider); + + expect(completions).toEqual([ + { + insertText: '"moon"', + label: 'moon', + type: 'LABEL_VALUE', + }, + { + insertText: '"luna"', + label: 'luna', + type: 'LABEL_VALUE', + }, + ]); + + completions = await getCompletions({ ...situation, betweenQuotes: true }, completionProvider); + + expect(completions).toEqual([ + { + insertText: 'moon', + label: 'moon', + type: 'LABEL_VALUE', + }, + { + insertText: 'luna', + label: 'luna', + type: 'LABEL_VALUE', + }, + ]); + }); + + test('Returns completion options when the situation is AFTER_SELECTOR and JSON parser', async () => { + jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ + extractedLabelKeys, + hasJSON: true, + hasLogfmt: false, + }); + const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe: true }; + const completions = await getCompletions(situation, completionProvider); + + const expected = buildAfterSelectorCompletions('json', 'PARSER', 'logfmt'); + expect(completions).toEqual(expected); + }); + + test('Returns completion options when the situation is AFTER_SELECTOR and Logfmt parser', async () => { + jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ + extractedLabelKeys, + hasJSON: false, + hasLogfmt: true, + }); + const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe: true }; + const completions = await getCompletions(situation, completionProvider); + + const expected = buildAfterSelectorCompletions('logfmt', 'DURATION', 'json'); + expect(completions).toEqual(expected); + }); + + test('Returns completion options when the situation is IN_AGGREGATION', async () => { + const situation: Situation = { type: 'IN_AGGREGATION' }; + const completions = await getCompletions(situation, completionProvider); + + expect(completions).toHaveLength(22); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts new file mode 100644 index 00000000000..098c02c1c04 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -0,0 +1,233 @@ +import { AGGREGATION_OPERATORS, RANGE_VEC_FUNCTIONS } from '../../../syntax'; + +import { CompletionDataProvider } from './CompletionDataProvider'; +import { NeverCaseError } from './NeverCaseError'; +import type { Situation, Label } from './situation'; + +export type CompletionType = + | 'HISTORY' + | 'FUNCTION' + | 'DURATION' + | 'LABEL_NAME' + | 'LABEL_VALUE' + | 'PATTERN' + | 'PARSER' + | 'LINE_FILTER' + | 'LINE_FORMAT'; + +type Completion = { + type: CompletionType; + label: string; + insertText: string; + detail?: string; + documentation?: string; + triggerOnInsert?: boolean; + isSnippet?: boolean; +}; + +const LOG_COMPLETIONS: Completion[] = [ + { + type: 'PATTERN', + label: '{}', + insertText: '{$0}', + isSnippet: true, + triggerOnInsert: true, + }, +]; + +const AGGREGATION_COMPLETIONS: Completion[] = AGGREGATION_OPERATORS.map((f) => ({ + type: 'FUNCTION', + label: f.label, + insertText: `${f.insertText ?? ''}($0)`, // i don't know what to do when this is nullish. it should not be. + isSnippet: true, + triggerOnInsert: true, + detail: f.detail, + documentation: f.documentation, +})); + +const FUNCTION_COMPLETIONS: Completion[] = RANGE_VEC_FUNCTIONS.map((f) => ({ + type: 'FUNCTION', + label: f.label, + insertText: `${f.insertText ?? ''}({$0}[\\$__interval])`, // i don't know what to do when this is nullish. it should not be. + isSnippet: true, + triggerOnInsert: true, + detail: f.detail, + documentation: f.documentation, +})); + +const DURATION_COMPLETIONS: Completion[] = ['$__interval', '$__range', '1m', '5m', '10m', '30m', '1h', '1d'].map( + (text) => ({ + type: 'DURATION', + label: text, + insertText: text, + }) +); + +const LINE_FILTER_COMPLETIONS: Completion[] = ['|=', '!=', '|~', '!~'].map((item) => ({ + type: 'LINE_FILTER', + label: `${item} ""`, + insertText: `${item} "$0"`, + isSnippet: true, +})); + +async function getAllHistoryCompletions(dataProvider: CompletionDataProvider): Promise { + const history = await dataProvider.getHistory(); + + return history.map((expr) => ({ + type: 'HISTORY', + label: expr, + insertText: expr, + })); +} + +async function getLabelNamesForCompletions( + suffix: string, + triggerOnInsert: boolean, + addExtractedLabels: boolean, + otherLabels: Label[], + dataProvider: CompletionDataProvider +): Promise { + const labelNames = await dataProvider.getLabelNames(otherLabels); + const result: Completion[] = labelNames.map((text) => ({ + type: 'LABEL_NAME', + label: text, + insertText: `${text}${suffix}`, + triggerOnInsert, + })); + + if (addExtractedLabels) { + const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(otherLabels); + extractedLabelKeys.forEach((key) => { + result.push({ + type: 'LABEL_NAME', + label: `${key} (parsed)`, + insertText: `${key}${suffix}`, + triggerOnInsert, + }); + }); + } + + return result; +} + +async function getLabelNamesForSelectorCompletions( + otherLabels: Label[], + dataProvider: CompletionDataProvider +): Promise { + return getLabelNamesForCompletions('=', true, false, otherLabels, dataProvider); +} + +async function getInGroupingCompletions( + otherLabels: Label[], + dataProvider: CompletionDataProvider +): Promise { + return getLabelNamesForCompletions('', false, true, otherLabels, dataProvider); +} + +async function getAfterSelectorCompletions( + labels: Label[], + afterPipe: boolean, + dataProvider: CompletionDataProvider +): Promise { + const { extractedLabelKeys, hasJSON, hasLogfmt } = await dataProvider.getParserAndLabelKeys(labels); + const allParsers = new Set(['json', 'logfmt', 'pattern', 'regexp', 'unpack']); + const completions: Completion[] = []; + const prefix = afterPipe ? '' : '| '; + const hasLevelInExtractedLabels = extractedLabelKeys.some((key) => key === 'level'); + if (hasJSON) { + allParsers.delete('json'); + const explanation = hasLevelInExtractedLabels ? 'use to get log-levels in the histogram' : 'detected'; + completions.push({ + type: 'PARSER', + label: `json (${explanation})`, + insertText: `${prefix}json`, + }); + } + + if (hasLogfmt) { + allParsers.delete('logfmt'); + const explanation = hasLevelInExtractedLabels ? 'get detected levels in the histogram' : 'detected'; + completions.push({ + type: 'DURATION', + label: `logfmt (${explanation})`, + insertText: `${prefix}logfmt`, + }); + } + + const remainingParsers = Array.from(allParsers).sort(); + remainingParsers.forEach((parser) => { + completions.push({ + type: 'PARSER', + label: parser, + insertText: `${prefix}${parser}`, + }); + }); + + extractedLabelKeys.forEach((key) => { + completions.push({ + type: 'LINE_FILTER', + label: `unwrap ${key} (detected)`, + insertText: `${prefix}unwrap ${key}`, + }); + }); + + completions.push({ + type: 'LINE_FILTER', + label: 'unwrap', + insertText: `${prefix}unwrap`, + }); + + completions.push({ + type: 'LINE_FORMAT', + label: 'line_format', + insertText: `${prefix}line_format "{{.$0}}"`, + isSnippet: true, + }); + + return [...LINE_FILTER_COMPLETIONS, ...completions]; +} + +async function getLabelValuesForMetricCompletions( + labelName: string, + betweenQuotes: boolean, + otherLabels: Label[], + dataProvider: CompletionDataProvider +): Promise { + const values = await dataProvider.getLabelValues(labelName, otherLabels); + return values.map((text) => ({ + type: 'LABEL_VALUE', + label: text, + insertText: betweenQuotes ? text : `"${text}"`, + })); +} + +export async function getCompletions( + situation: Situation, + dataProvider: CompletionDataProvider +): Promise { + switch (situation.type) { + case 'EMPTY': + case 'AT_ROOT': + const historyCompletions = await getAllHistoryCompletions(dataProvider); + return [...historyCompletions, ...LOG_COMPLETIONS, ...AGGREGATION_COMPLETIONS, ...FUNCTION_COMPLETIONS]; + case 'IN_DURATION': + return DURATION_COMPLETIONS; + case 'IN_GROUPING': + return getInGroupingCompletions(situation.otherLabels, dataProvider); + case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': + return getLabelNamesForSelectorCompletions(situation.otherLabels, dataProvider); + case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME': + return getLabelValuesForMetricCompletions( + situation.labelName, + situation.betweenQuotes, + situation.otherLabels, + dataProvider + ); + case 'AFTER_SELECTOR': + return getAfterSelectorCompletions(situation.labels, situation.afterPipe, dataProvider); + case 'IN_AGGREGATION': + return [...FUNCTION_COMPLETIONS, ...AGGREGATION_COMPLETIONS]; + default: + throw new NeverCaseError(situation); + } +} diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/index.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/index.ts new file mode 100644 index 00000000000..cb450247142 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/index.ts @@ -0,0 +1,112 @@ +import type { Monaco, monacoTypes } from '@grafana/ui'; + +import { CompletionDataProvider } from './CompletionDataProvider'; +import { NeverCaseError } from './NeverCaseError'; +import { getCompletions, CompletionType } from './completions'; +import { getSituation } from './situation'; + +// from: monacoTypes.languages.CompletionItemInsertTextRule.InsertAsSnippet +const INSERT_AS_SNIPPET_ENUM_VALUE = 4; + +export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions { + return { + // monaco-editor sometimes provides suggestions automatically, i am not + // sure based on what, seems to be by analyzing the words already + // written. + // to try it out: + // - enter `go_goroutines{job~` + // - have the cursor at the end of the string + // - press ctrl-enter + // - you will get two suggestions + // those were not provided by grafana, they are offered automatically. + // i want to remove those. the only way i found is: + // - every suggestion-item has a `kind` attribute, + // that controls the icon to the left of the suggestion. + // - items auto-generated by monaco have `kind` set to `text`. + // - we make sure grafana-provided suggestions do not have `kind` set to `text`. + // - and then we tell monaco not to show suggestions of kind `text` + showWords: false, + }; +} + +function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { + switch (type) { + case 'DURATION': + return monaco.languages.CompletionItemKind.Unit; + case 'FUNCTION': + return monaco.languages.CompletionItemKind.Variable; + case 'HISTORY': + return monaco.languages.CompletionItemKind.Snippet; + case 'LABEL_NAME': + return monaco.languages.CompletionItemKind.Enum; + case 'LABEL_VALUE': + return monaco.languages.CompletionItemKind.EnumMember; + case 'PATTERN': + return monaco.languages.CompletionItemKind.Constructor; + case 'PARSER': + return monaco.languages.CompletionItemKind.Class; + case 'LINE_FILTER': + return monaco.languages.CompletionItemKind.TypeParameter; + case 'LINE_FORMAT': + return monaco.languages.CompletionItemKind.Event; + default: + throw new NeverCaseError(type); + } +} +export function getCompletionProvider( + monaco: Monaco, + dataProvider: CompletionDataProvider +): monacoTypes.languages.CompletionItemProvider { + const provideCompletionItems = ( + model: monacoTypes.editor.ITextModel, + position: monacoTypes.Position + ): monacoTypes.languages.ProviderResult => { + const word = model.getWordAtPosition(position); + const range = + word != null + ? monaco.Range.lift({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }) + : monaco.Range.fromPositions(position); + // documentation says `position` will be "adjusted" in `getOffsetAt` + // i don't know what that means, to be sure i clone it + const positionClone = { + column: position.column, + lineNumber: position.lineNumber, + }; + const offset = model.getOffsetAt(positionClone); + const situation = getSituation(model.getValue(), offset); + const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); + return completionsPromise.then((items) => { + // monaco by default alphabetically orders the items. + // to stop it, we use a number-as-string sortkey, + // so that monaco keeps the order we use + const maxIndexDigits = items.length.toString().length; + const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({ + kind: getMonacoCompletionItemKind(item.type, monaco), + label: item.label, + insertText: item.insertText, + insertTextRules: item.isSnippet ? INSERT_AS_SNIPPET_ENUM_VALUE : undefined, + detail: item.detail, + documentation: item.documentation, + sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have + range, + command: item.triggerOnInsert + ? { + id: 'editor.action.triggerSuggest', + title: '', + } + : undefined, + })); + return { suggestions }; + }); + }; + + return { + triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"', '|'], + provideCompletionItems, + }; +} diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts new file mode 100644 index 00000000000..3a0d0306b71 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.test.ts @@ -0,0 +1,195 @@ +import { getSituation, Situation } from './situation'; + +// we use the `^` character as the cursor-marker in the string. +function assertSituation(situation: string, expectedSituation: Situation | null) { + // first we find the cursor-position + const pos = situation.indexOf('^'); + if (pos === -1) { + throw new Error('cursor missing'); + } + + // we remove the cursor-marker from the string + const text = situation.replace('^', ''); + + // sanity check, make sure no more cursor-markers remain + if (text.indexOf('^') !== -1) { + throw new Error('multiple cursors'); + } + + const result = getSituation(text, pos); + + if (expectedSituation === null) { + expect(result).toStrictEqual(null); + } else { + expect(result).toMatchObject(expectedSituation); + } +} + +describe('situation', () => { + it('handles things', () => { + assertSituation('^', { + type: 'EMPTY', + }); + + assertSituation('s^', { + type: 'AT_ROOT', + }); + + assertSituation('{level="info"} ^', { + type: 'AFTER_SELECTOR', + afterPipe: false, + labels: [{ name: 'level', value: 'info', op: '=' }], + }); + + // should not trigger AFTER_SELECTOR before the selector + assertSituation('^ {level="info"}', null); + + // check for an error we had during the implementation + assertSituation('{level="info" ^', null); + + assertSituation('{level="info"} | json ^', { + type: 'AFTER_SELECTOR', + afterPipe: false, + labels: [{ name: 'level', value: 'info', op: '=' }], + }); + + assertSituation('{level="info"} | json | ^', { + type: 'AFTER_SELECTOR', + afterPipe: true, + labels: [{ name: 'level', value: 'info', op: '=' }], + }); + + assertSituation('count_over_time({level="info"}^[10s])', { + type: 'AFTER_SELECTOR', + afterPipe: false, + labels: [{ name: 'level', value: 'info', op: '=' }], + }); + + // should not trigger AFTER_SELECTOR before the selector + assertSituation('count_over_time(^{level="info"}[10s])', null); + + // should work even when the query is half-complete + assertSituation('count_over_time({level="info"}^)', { + type: 'AFTER_SELECTOR', + afterPipe: false, + labels: [{ name: 'level', value: 'info', op: '=' }], + }); + + /* + Currently failing, reason unknown + assertSituation('sum(^)', { + type: 'IN_AGGREGATION', + });*/ + }); + + it('handles label names', () => { + assertSituation('{^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [], + }); + + assertSituation('sum(count_over_time({level="info"})) by (^)', { + type: 'IN_GROUPING', + otherLabels: [{ name: 'level', value: 'info', op: '=' }], + }); + + assertSituation('sum by (^) (count_over_time({level="info"}))', { + type: 'IN_GROUPING', + otherLabels: [{ name: 'level', value: 'info', op: '=' }], + }); + + assertSituation('{one="val1",two!="val2",three=~"val3",four!~"val4",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [ + { name: 'one', value: 'val1', op: '=' }, + { name: 'two', value: 'val2', op: '!=' }, + { name: 'three', value: 'val3', op: '=~' }, + { name: 'four', value: 'val4', op: '!~' }, + ], + }); + + assertSituation('{one="val1",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val1', op: '=' }], + }); + + // double-quoted label-values with escape + assertSituation('{one="val\\"1",^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val"1', op: '=' }], + }); + + // backticked label-values with escape (the escape should not be interpreted) + assertSituation('{one=`val\\"1`,^}', { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels: [{ name: 'one', value: 'val\\"1', op: '=' }], + }); + }); + + it('handles label values', () => { + assertSituation('{job=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('{job!=^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('{job=~^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('{job!~^}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [], + }); + + assertSituation('{job=^,host="h1"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [{ name: 'host', value: 'h1', op: '=' }], + }); + + assertSituation('{job="j1",host="^"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'host', + betweenQuotes: true, + otherLabels: [{ name: 'job', value: 'j1', op: '=' }], + }); + + assertSituation('{job="j1"^}', null); + assertSituation('{job="j1" ^ }', null); + assertSituation('{job="j1" ^ , }', null); + + assertSituation('{job=^,host="h1"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'job', + betweenQuotes: false, + otherLabels: [{ name: 'host', value: 'h1', op: '=' }], + }); + + assertSituation('{one="val1",two!="val2",three=^,four=~"val4",five!~"val5"}', { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName: 'three', + betweenQuotes: false, + otherLabels: [ + { name: 'one', value: 'val1', op: '=' }, + { name: 'two', value: 'val2', op: '!=' }, + { name: 'four', value: 'val4', op: '=~' }, + { name: 'five', value: 'val5', op: '!~' }, + ], + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts new file mode 100644 index 00000000000..27cc94a3fb7 --- /dev/null +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -0,0 +1,523 @@ +import type { Tree, SyntaxNode } from '@lezer/common'; + +import { + parser, + VectorAggregationExpr, + String, + Selector, + RangeAggregationExpr, + Range, + PipelineExpr, + PipelineStage, + Matchers, + Matcher, + LogQL, + LogRangeExpr, + LogExpr, + Identifier, + Grouping, + Expr, +} from '@grafana/lezer-logql'; + +type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; +type NodeType = number; + +type Path = Array<[Direction, NodeType]>; + +function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { + return node[direction]; +} + +function walk(node: SyntaxNode, path: Path): SyntaxNode | null { + let current: SyntaxNode | null = node; + for (const [direction, expectedNode] of path) { + current = move(current, direction); + if (current === null) { + // we could not move in the direction, we stop + return null; + } + if (current.type.id !== expectedNode) { + // the reached node has wrong type, we stop + return null; + } + } + return current; +} + +function getNodeText(node: SyntaxNode, text: string): string { + return text.slice(node.from, node.to); +} + +function parseStringLiteral(text: string): string { + // If it is a string-literal, it is inside quotes of some kind + const inside = text.slice(1, text.length - 1); + + // Very simple un-escaping: + + // Double quotes + if (text.startsWith('"') && text.endsWith('"')) { + // NOTE: this is not 100% perfect, we only unescape the double-quote, + // there might be other characters too + return inside.replace(/\\"/, '"'); + } + + // Single quotes + if (text.startsWith("'") && text.endsWith("'")) { + // NOTE: this is not 100% perfect, we only unescape the single-quote, + // there might be other characters too + return inside.replace(/\\'/, "'"); + } + + // Backticks + if (text.startsWith('`') && text.endsWith('`')) { + return inside; + } + + throw new Error(`Invalid string literal: ${text}`); +} + +export type LabelOperator = '=' | '!=' | '=~' | '!~'; + +export type Label = { + name: string; + value: string; + op: LabelOperator; +}; + +export type Situation = + | { + type: 'EMPTY'; + } + | { + type: 'AT_ROOT'; + } + | { + type: 'IN_DURATION'; + } + | { + type: 'IN_AGGREGATION'; + } + | { + type: 'IN_GROUPING'; + otherLabels: Label[]; + } + | { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; + otherLabels: Label[]; + } + | { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME'; + labelName: string; + betweenQuotes: boolean; + otherLabels: Label[]; + } + | { + type: 'AFTER_SELECTOR'; + afterPipe: boolean; + labels: Label[]; + }; + +type Resolver = { + path: NodeType[]; + fun: (node: SyntaxNode, text: string, pos: number) => Situation | null; +}; + +function isPathMatch(resolverPath: NodeType[], cursorPath: number[]): boolean { + return resolverPath.every((item, index) => item === cursorPath[index]); +} + +const ERROR_NODE_ID = 0; + +const RESOLVERS: Resolver[] = [ + { + path: [Selector], + fun: resolveSelector, + }, + { + path: [LogQL], + fun: resolveTopLevel, + }, + { + path: [String, Matcher], + fun: resolveMatcher, + }, + { + path: [Grouping], + fun: resolveLabelsForGrouping, + }, + { + path: [LogRangeExpr], + fun: resolveLogRange, + }, + { + path: [ERROR_NODE_ID, Matcher], + fun: resolveMatcher, + }, + { + path: [ERROR_NODE_ID, Range], + fun: resolveDurations, + }, + { + path: [ERROR_NODE_ID, LogRangeExpr], + fun: resolveLogRangeFromError, + }, + { + path: [ERROR_NODE_ID, VectorAggregationExpr], + fun: () => ({ type: 'IN_AGGREGATION' }), + }, + { + path: [ERROR_NODE_ID, PipelineStage, PipelineExpr], + fun: resolvePipeError, + }, +]; + +const LABEL_OP_MAP = new Map([ + ['Eq', '='], + ['Re', '=~'], + ['Neq', '!='], + ['Nre', '!~'], +]); + +function getLabelOp(opNode: SyntaxNode): LabelOperator | null { + return LABEL_OP_MAP.get(opNode.name) ?? null; +} + +function getLabel(matcherNode: SyntaxNode, text: string): Label | null { + if (matcherNode.type.id !== Matcher) { + return null; + } + + const nameNode = walk(matcherNode, [['firstChild', Identifier]]); + + if (nameNode === null) { + return null; + } + + const opNode = nameNode.nextSibling; + if (opNode === null) { + return null; + } + + const op = getLabelOp(opNode); + if (op === null) { + return null; + } + + const valueNode = walk(matcherNode, [['lastChild', String]]); + + if (valueNode === null) { + return null; + } + + const name = getNodeText(nameNode, text); + const value = parseStringLiteral(getNodeText(valueNode, text)); + + return { name, value, op }; +} + +function getLabels(selectorNode: SyntaxNode, text: string): Label[] { + if (selectorNode.type.id !== Selector) { + return []; + } + + let listNode: SyntaxNode | null = walk(selectorNode, [['firstChild', Matchers]]); + + const labels: Label[] = []; + + while (listNode !== null) { + const matcherNode = walk(listNode, [['lastChild', Matcher]]); + if (matcherNode === null) { + // unexpected, we stop + return []; + } + + const label = getLabel(matcherNode, text); + if (label !== null) { + labels.push(label); + } + + // there might be more labels + listNode = walk(listNode, [['firstChild', Matchers]]); + } + + // our labels-list is last-first, so we reverse it + labels.reverse(); + + return labels; +} + +function resolvePipeError(node: SyntaxNode, text: string, pos: number): Situation | null { + // for example `{level="info"} |` + const exprNode = walk(node, [ + ['parent', PipelineStage], + ['parent', PipelineExpr], + ]); + + if (exprNode === null) { + return null; + } + + const { parent } = exprNode; + + if (parent === null) { + return null; + } + + if (parent.type.id === LogExpr || parent.type.id === LogRangeExpr) { + return resolveLogOrLogRange(parent, text, pos, true); + } + + return null; +} + +function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Situation | null { + const aggrExpNode = walk(node, [['parent', VectorAggregationExpr]]); + if (aggrExpNode === null) { + return null; + } + const bodyNode = aggrExpNode.getChild('MetricExpr'); + if (bodyNode === null) { + return null; + } + + const selectorNode = walk(bodyNode, [ + ['firstChild', RangeAggregationExpr], + ['lastChild', LogRangeExpr], + ['firstChild', Selector], + ]); + + if (selectorNode === null) { + return null; + } + + const otherLabels = getLabels(selectorNode, text); + + return { + type: 'IN_GROUPING', + otherLabels, + }; +} + +function resolveMatcher(node: SyntaxNode, text: string, pos: number): Situation | null { + // we can arrive here for two reasons. `node` is either: + // - a StringNode (like in `{job="^"}`) + // - or an error node (like in `{job=^}`) + const inStringNode = !node.type.isError; + + const parent = walk(node, [['parent', Matcher]]); + if (parent === null) { + return null; + } + + const labelNameNode = walk(parent, [['firstChild', Identifier]]); + if (labelNameNode === null) { + return null; + } + + const labelName = getNodeText(labelNameNode, text); + + // now we need to go up, to the parent of Matcher, + // there can be one or many `Matchers` parents, we have + // to go through all of them + + const firstListNode = walk(parent, [['parent', Matchers]]); + if (firstListNode === null) { + return null; + } + + let listNode = firstListNode; + + // we keep going through the parent-nodes as long as they are Matchers. + // as soon as we reach Selector, we stop + let selectorNode: SyntaxNode | null = null; + while (selectorNode === null) { + const parent = listNode.parent; + if (parent === null) { + return null; + } + + switch (parent.type.id) { + case Matchers: + //we keep looping + listNode = parent; + continue; + case Selector: + // we reached the end, we can stop the loop + selectorNode = parent; + continue; + default: + // we reached some other node, we stop + return null; + } + } + + // now we need to find the other names + const allLabels = getLabels(selectorNode, text); + + // we need to remove "our" label from all-labels, if it is in there + const otherLabels = allLabels.filter((label) => label.name !== labelName); + + return { + type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', + labelName, + betweenQuotes: inStringNode, + otherLabels, + }; +} + +function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation | null { + // we try a couply specific paths here. + // `{x="y"}` situation, with the cursor at the end + + const logExprNode = walk(node, [ + ['lastChild', Expr], + ['lastChild', LogExpr], + ]); + + if (logExprNode != null) { + return resolveLogOrLogRange(logExprNode, text, pos, false); + } + + // `s` situation, with the cursor at the end. + // (basically, user enters a non-special characters as first + // character in query field) + const idNode = walk(node, [ + ['firstChild', ERROR_NODE_ID], + ['firstChild', Identifier], + ]); + + if (idNode != null) { + return { + type: 'AT_ROOT', + }; + } + + // no patterns match + return null; +} + +function resolveDurations(node: SyntaxNode, text: string, pos: number): Situation { + return { + type: 'IN_DURATION', + }; +} + +function resolveLogRange(node: SyntaxNode, text: string, pos: number): Situation | null { + return resolveLogOrLogRange(node, text, pos, false); +} + +function resolveLogRangeFromError(node: SyntaxNode, text: string, pos: number): Situation | null { + const parent = walk(node, [['parent', LogRangeExpr]]); + if (parent === null) { + return null; + } + + return resolveLogOrLogRange(parent, text, pos, false); +} + +function resolveLogOrLogRange(node: SyntaxNode, text: string, pos: number, afterPipe: boolean): Situation | null { + // here the `node` is either a LogExpr or a LogRangeExpr + // we want to handle the case where we are next to a selector + const selectorNode = walk(node, [['firstChild', Selector]]); + + // we check that the selector is before the cursor, not after it + if (selectorNode != null && selectorNode.to <= pos) { + const labels = getLabels(selectorNode, text); + return { + type: 'AFTER_SELECTOR', + afterPipe, + labels, + }; + } + + return null; +} + +function resolveSelector(node: SyntaxNode, text: string, pos: number): Situation | null { + // for example `{^}` + + // false positive: + // `{a="1"^}` + const child = walk(node, [['firstChild', Matchers]]); + if (child !== null) { + // means the label-matching part contains at least one label already. + // + // in this case, we will need to have a `,` character at the end, + // to be able to suggest adding the next label. + // the area between the end-of-the-child-node and the cursor-pos + // must contain a `,` in this case. + const textToCheck = text.slice(child.to, pos); + + if (!textToCheck.includes(',')) { + return null; + } + } + + const otherLabels = getLabels(node, text); + + return { + type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', + otherLabels, + }; +} + +// we find the first error-node in the tree that is at the cursor-position. +// NOTE: this might be too slow, might need to optimize it +// (ideas: we do not need to go into every subtree, based on from/to) +// also, only go to places that are in the sub-tree of the node found +// by default by lezer. problem is, `next()` will go upward too, +// and we do not want to go higher than our node +function getErrorNode(tree: Tree, text: string, cursorPos: number): SyntaxNode | null { + // sometimes the cursor is a couple spaces after the end of the expression. + // to account for this situation, we "move" the cursor position back, + // so that there are no spaces between the end-of-expression and the cursor + const trimRightTextLen = text.trimEnd().length; + const pos = trimRightTextLen < cursorPos ? trimRightTextLen : cursorPos; + const cur = tree.cursorAt(pos); + do { + if (cur.from === pos && cur.to === pos) { + const { node } = cur; + if (node.type.isError) { + return node; + } + } + } while (cur.next()); + return null; +} + +export function getSituation(text: string, pos: number): Situation | null { + // there is a special case when we are at the start of writing text, + // so we handle that case first + + if (text === '') { + return { + type: 'EMPTY', + }; + } + + const tree = parser.parse(text); + + // if the tree contains error, it is very probable that + // our node is one of those error nodes. + // also, if there are errors, the node lezer finds us, + // might not be the best node. + // so first we check if there is an error node at the cursor position + const maybeErrorNode = getErrorNode(tree, text, pos); + + const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos); + + const currentNode = cur.node; + + const ids = [cur.type.id]; + while (cur.parent()) { + ids.push(cur.type.id); + } + + for (let resolver of RESOLVERS) { + if (isPathMatch(resolver.path, ids)) { + return resolver.fun(currentNode, text, pos); + } + } + + return null; +} diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index eda90cbf0b6..6acd8d32f4b 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -905,6 +905,24 @@ describe('applyTemplateVariables', () => { }); }); +describe('getTimeRange*()', () => { + it('exposes the current time range', () => { + const ds = createLokiDatasource(); + const timeRange = ds.getTimeRange(); + + expect(timeRange.from).toBeDefined(); + expect(timeRange.to).toBeDefined(); + }); + + it('exposes time range as params', () => { + const ds = createLokiDatasource(); + const params = ds.getTimeRangeParams(); + + // Returns a very big integer, so we stringify it for the assertion + expect(JSON.stringify(params)).toEqual('{"start":1524650400000000000,"end":1524654000000000000}'); + }); +}); + describe('Variable support', () => { it('has Loki variable support', () => { const ds = createLokiDatasource(templateSrvStub); diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 4cbf0c49860..766413eef79 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -256,7 +256,7 @@ export class LokiDatasource ); }; - getRangeScopedVars(range: TimeRange = this.timeSrv.timeRange()) { + getRangeScopedVars(range: TimeRange = this.getTimeRange()) { const msRange = range.to.diff(range.from); const sRange = Math.round(msRange / 1000); return { @@ -283,8 +283,12 @@ export class LokiDatasource return query.expr; } + getTimeRange() { + return this.timeSrv.timeRange(); + } + getTimeRangeParams() { - const timeRange = this.timeSrv.timeRange(); + const timeRange = this.getTimeRange(); return { start: timeRange.from.valueOf() * NS_IN_MS, end: timeRange.to.valueOf() * NS_IN_MS }; } diff --git a/public/app/plugins/datasource/loki/language_utils.test.ts b/public/app/plugins/datasource/loki/language_utils.test.ts new file mode 100644 index 00000000000..267e14382d9 --- /dev/null +++ b/public/app/plugins/datasource/loki/language_utils.test.ts @@ -0,0 +1,43 @@ +import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from './languageUtils'; + +describe('escapeLabelValueInExactSelector()', () => { + it('handles newline characters', () => { + expect(escapeLabelValueInExactSelector('t\nes\nt')).toBe('t\\nes\\nt'); + }); + + it('handles backslash characters', () => { + expect(escapeLabelValueInExactSelector('t\\es\\t')).toBe('t\\\\es\\\\t'); + }); + + it('handles double-quote characters', () => { + expect(escapeLabelValueInExactSelector('t"es"t')).toBe('t\\"es\\"t'); + }); + + it('handles all together', () => { + expect(escapeLabelValueInExactSelector('t\\e"st\nl\nab"e\\l')).toBe('t\\\\e\\"st\\nl\\nab\\"e\\\\l'); + }); +}); + +describe('escapeLabelValueInRegexSelector()', () => { + it('handles newline characters', () => { + expect(escapeLabelValueInRegexSelector('t\nes\nt')).toBe('t\\nes\\nt'); + }); + + it('handles backslash characters', () => { + expect(escapeLabelValueInRegexSelector('t\\es\\t')).toBe('t\\\\\\\\es\\\\\\\\t'); + }); + + it('handles double-quote characters', () => { + expect(escapeLabelValueInRegexSelector('t"es"t')).toBe('t\\"es\\"t'); + }); + + it('handles regex-meaningful characters', () => { + expect(escapeLabelValueInRegexSelector('t+es$t')).toBe('t\\\\+es\\\\$t'); + }); + + it('handles all together', () => { + expect(escapeLabelValueInRegexSelector('t\\e"s+t\nl\n$ab"e\\l')).toBe( + 't\\\\\\\\e\\"s\\\\+t\\nl\\n\\\\$ab\\"e\\\\\\\\l' + ); + }); +}); diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index 46277fe04e8..71e64be3894 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -18,7 +18,7 @@ const rawRange = { }; const defaultTimeSrvMock = { - timeRange: () => ({ + timeRange: jest.fn().mockReturnValue({ from: rawRange.from, to: rawRange.to, raw: rawRange, diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index e203e0b218f..430cdc4c077 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/public/app/plugins/datasource/loki/syntax.ts @@ -2,16 +2,21 @@ import { Grammar } from 'prismjs'; import { CompletionItem } from '@grafana/ui'; -const AGGREGATION_OPERATORS: CompletionItem[] = [ +export const AGGREGATION_OPERATORS: CompletionItem[] = [ { - label: 'sum', - insertText: 'sum', - documentation: 'Calculate sum over dimensions', + label: 'avg', + insertText: 'avg', + documentation: 'Calculate the average over dimensions', }, { - label: 'min', - insertText: 'min', - documentation: 'Select minimum over dimensions', + label: 'bottomk', + insertText: 'bottomk', + documentation: 'Smallest k elements by sample value', + }, + { + label: 'count', + insertText: 'count', + documentation: 'Count number of elements in the vector', }, { label: 'max', @@ -19,9 +24,9 @@ const AGGREGATION_OPERATORS: CompletionItem[] = [ documentation: 'Select maximum over dimensions', }, { - label: 'avg', - insertText: 'avg', - documentation: 'Calculate the average over dimensions', + label: 'min', + insertText: 'min', + documentation: 'Select minimum over dimensions', }, { label: 'stddev', @@ -34,14 +39,9 @@ const AGGREGATION_OPERATORS: CompletionItem[] = [ documentation: 'Calculate population standard variance over dimensions', }, { - label: 'count', - insertText: 'count', - documentation: 'Count number of elements in the vector', - }, - { - label: 'bottomk', - insertText: 'bottomk', - documentation: 'Smallest k elements by sample value', + label: 'sum', + insertText: 'sum', + documentation: 'Calculate sum over dimensions', }, { label: 'topk', @@ -54,18 +54,18 @@ export const PIPE_PARSERS: CompletionItem[] = [ { label: 'json', insertText: 'json', - documentation: 'Extracting labels from the log line using json parser. Only available in Loki 2.0+.', + documentation: 'Extracting labels from the log line using json parser.', }, { label: 'regexp', insertText: 'regexp ""', - documentation: 'Extracting labels from the log line using regexp parser. Only available in Loki 2.0+.', + documentation: 'Extracting labels from the log line using regexp parser.', move: -1, }, { label: 'logfmt', insertText: 'logfmt', - documentation: 'Extracting labels from the log line using logfmt parser. Only available in Loki 2.0+.', + documentation: 'Extracting labels from the log line using logfmt parser.', }, { label: 'pattern', @@ -86,20 +86,17 @@ export const PIPE_OPERATORS: CompletionItem[] = [ label: 'unwrap', insertText: 'unwrap', detail: 'unwrap identifier', - documentation: - 'Take labels and use the values as sample data for metric aggregations. Only available in Loki 2.0+.', + documentation: 'Take labels and use the values as sample data for metric aggregations.', }, { label: 'label_format', insertText: 'label_format', - documentation: - 'Use to rename, modify or add labels. For example, | label_format foo=bar . Only available in Loki 2.0+.', + documentation: 'Use to rename, modify or add labels. For example, | label_format foo=bar .', }, { label: 'line_format', insertText: 'line_format', - documentation: - 'Rewrites log line content. For example, | line_format "{{.query}} {{.duration}}" . Only available in Loki 2.0+.', + documentation: 'Rewrites log line content. For example, | line_format "{{.query}} {{.duration}}" .', }, ]; @@ -108,19 +105,19 @@ export const RANGE_VEC_FUNCTIONS = [ insertText: 'avg_over_time', label: 'avg_over_time', detail: 'avg_over_time(range-vector)', - documentation: 'The average of all values in the specified interval. Only available in Loki 2.0+.', + documentation: 'The average of all values in the specified interval.', }, { - insertText: 'min_over_time', - label: 'min_over_time', - detail: 'min_over_time(range-vector)', - documentation: 'The minimum of all values in the specified interval. Only available in Loki 2.0+.', + insertText: 'bytes_over_time', + label: 'bytes_over_time', + detail: 'bytes_over_time(range-vector)', + documentation: 'Counts the amount of bytes used by each log stream for a given range', }, { - insertText: 'max_over_time', - label: 'max_over_time', - detail: 'max_over_time(range-vector)', - documentation: 'The maximum of all values in the specified interval. Only available in Loki 2.0+.', + insertText: 'bytes_rate', + label: 'bytes_rate', + detail: 'bytes_rate(range-vector)', + documentation: 'Calculates the number of bytes per second for each stream.', }, { insertText: 'first_over_time', @@ -138,7 +135,7 @@ export const RANGE_VEC_FUNCTIONS = [ insertText: 'sum_over_time', label: 'sum_over_time', detail: 'sum_over_time(range-vector)', - documentation: 'The sum of all values in the specified interval. Only available in Loki 2.0+.', + documentation: 'The sum of all values in the specified interval.', }, { insertText: 'count_over_time', @@ -147,36 +144,22 @@ export const RANGE_VEC_FUNCTIONS = [ documentation: 'The count of all values in the specified interval.', }, { - insertText: 'stdvar_over_time', - label: 'stdvar_over_time', - detail: 'stdvar_over_time(range-vector)', - documentation: - 'The population standard variance of the values in the specified interval. Only available in Loki 2.0+.', + insertText: 'max_over_time', + label: 'max_over_time', + detail: 'max_over_time(range-vector)', + documentation: 'The maximum of all values in the specified interval.', }, { - insertText: 'stddev_over_time', - label: 'stddev_over_time', - detail: 'stddev_over_time(range-vector)', - documentation: - 'The population standard deviation of the values in the specified interval. Only available in Loki 2.0+.', + insertText: 'min_over_time', + label: 'min_over_time', + detail: 'min_over_time(range-vector)', + documentation: 'The minimum of all values in the specified interval.', }, { insertText: 'quantile_over_time', label: 'quantile_over_time', detail: 'quantile_over_time(scalar, range-vector)', - documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval. Only available in Loki 2.0+.', - }, - { - insertText: 'bytes_over_time', - label: 'bytes_over_time', - detail: 'bytes_over_time(range-vector)', - documentation: 'Counts the amount of bytes used by each log stream for a given range', - }, - { - insertText: 'bytes_rate', - label: 'bytes_rate', - detail: 'bytes_rate(range-vector)', - documentation: 'Calculates the number of bytes per second for each stream.', + documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.', }, { insertText: 'rate', @@ -184,6 +167,18 @@ export const RANGE_VEC_FUNCTIONS = [ detail: 'rate(v range-vector)', documentation: 'Calculates the number of entries per second.', }, + { + insertText: 'stddev_over_time', + label: 'stddev_over_time', + detail: 'stddev_over_time(range-vector)', + documentation: 'The population standard deviation of the values in the specified interval.', + }, + { + insertText: 'stdvar_over_time', + label: 'stdvar_over_time', + detail: 'stdvar_over_time(range-vector)', + documentation: 'The population standard variance of the values in the specified interval.', + }, ]; export const FUNCTIONS = [...AGGREGATION_OPERATORS, ...RANGE_VEC_FUNCTIONS]; diff --git a/yarn.lock b/yarn.lock index 14b9c5cb777..b0e835ba7a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5425,6 +5425,15 @@ __metadata: languageName: node linkType: hard +"@grafana/monaco-logql@npm:^0.0.6": + version: 0.0.6 + resolution: "@grafana/monaco-logql@npm:0.0.6" + peerDependencies: + monaco-editor: ^0.32.1 + checksum: 81ac76c0eaa020cdac4c2eb5a7b5b4c18c3aac932ecfe2a860e1755597a41fae8421290b96a4f6234cd02e640b6cf3dc7380b475973e20d8e29ab62f4cee5933 + languageName: node + linkType: hard + "@grafana/runtime@9.3.0-pre, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime": version: 0.0.0-use.local resolution: "@grafana/runtime@workspace:packages/grafana-runtime" @@ -23160,6 +23169,7 @@ __metadata: "@grafana/experimental": ^0.0.2-canary.36 "@grafana/google-sdk": 0.0.3 "@grafana/lezer-logql": 0.1.1 + "@grafana/monaco-logql": ^0.0.6 "@grafana/runtime": "workspace:*" "@grafana/schema": "workspace:*" "@grafana/toolkit": "workspace:*"