Loki: query editor using Monaco (#55391)

* 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 3d003ca4bc.

* Revert "Chore: comment line filters pending implementation"

This reverts commit 84bfe76a6a.

Co-authored-by: Gábor Farkas <gabor.farkas@gmail.com>
Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
Co-authored-by: Ivana Huckova <ivana.huckova@gmail.com>
This commit is contained in:
Matias Chomicki 2022-10-06 16:35:30 +02:00 committed by GitHub
parent 8fd4fcb987
commit 729ce8bb72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2123 additions and 86 deletions

View File

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

View File

@ -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:*",

View File

@ -34,6 +34,7 @@ export interface FeatureToggles {
publicDashboards?: boolean;
lokiLive?: boolean;
lokiDataframeApi?: boolean;
lokiMonacoEditor?: boolean;
swaggerUi?: boolean;
featureHighlights?: boolean;
dashboardComments?: boolean;

View File

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

View File

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

View File

@ -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<string, string[]>,
series?: Record<string, Array<Record<string, string>>>
): LokiDatasource {
const datasource = createLokiDatasource({} as unknown as TemplateSrv);
const datasource = createLokiDatasource();
const rangeMock = {
start: 1560153109000,

View File

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

View File

@ -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<LokiQueryFieldProps, Lok
app,
datasource,
placeholder = 'Enter a Loki query (run with Shift+Enter)',
history,
onRunQuery,
onBlur,
} = this.props;
const { labelsLoaded, labelBrowserVisible } = this.state;
const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider;
const cleanText = datasource.languageProvider ? lokiLanguageProvider.cleanText : undefined;
const hasLogLabels = lokiLanguageProvider.getLabelKeys().length > 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<LokiQueryFieldProps, Lok
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onChange={this.onChangeQuery}
onBlur={this.props.onBlur}
onRunQuery={this.props.onRunQuery}
placeholder={placeholder}
portalOrigin="loki"
/>
{config.featureToggles.lokiMonacoEditor ? (
<MonacoQueryFieldWrapper
runQueryOnBlur={app !== CoreApp.Explore}
languageProvider={datasource.languageProvider}
history={history ?? []}
onChange={this.onChangeQuery}
onRunQuery={onRunQuery}
initialValue={query.expr ?? ''}
/>
) : (
<QueryField
additionalPlugins={this.plugins}
cleanText={datasource.languageProvider.cleanText}
query={query.expr}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onChange={this.onChangeQuery}
onBlur={onBlur}
onRunQuery={onRunQuery}
placeholder={placeholder}
portalOrigin="loki"
/>
)}
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<LokiLabelBrowser
languageProvider={lokiLanguageProvider}
languageProvider={datasource.languageProvider}
onChange={this.onChangeLabelBrowser}
lastUsedLabels={lastUsedLabels || []}
storeLastUsedLabels={onLastUsedLabelsSave}

View File

@ -0,0 +1,184 @@
import { css } from '@emotion/css';
import React, { useRef, useEffect } from 'react';
import { useLatest } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { languageConfiguration, monarchlanguage } from '@grafana/monaco-logql';
import { useTheme2, ReactMonacoEditor, Monaco, monacoTypes } from '@grafana/ui';
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider';
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
codeLens: false,
contextmenu: false,
// we need `fixedOverflowWidgets` because otherwise in grafana-dashboards
// the popup is clipped by the panel-visualizations.
fixedOverflowWidgets: true,
folding: false,
fontSize: 14,
lineDecorationsWidth: 8, // used as "padding-left"
lineNumbers: 'off',
minimap: { enabled: false },
overviewRulerBorder: false,
overviewRulerLanes: 0,
padding: {
// these numbers were picked so that visually this matches the previous version
// of the query-editor the best
top: 4,
bottom: 5,
},
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
suggest: getSuggestOptions(),
suggestFontSize: 12,
wordWrap: 'on',
};
// this number was chosen by testing various values. it might be necessary
// because of the width of the border, not sure.
//it needs to do 2 things:
// 1. when the editor is single-line, it should make the editor height be visually correct
// 2. when the editor is multi-line, the editor should not be "scrollable" (meaning,
// you do a scroll-movement in the editor, and it will scroll the content by a couple pixels
// up & down. this we want to avoid)
const EDITOR_HEIGHT_OFFSET = 2;
const LANG_ID = 'logql';
// we must only run the lang-setup code once
let LANGUAGE_SETUP_STARTED = false;
function ensureLogQL(monaco: Monaco) {
if (LANGUAGE_SETUP_STARTED === false) {
LANGUAGE_SETUP_STARTED = true;
monaco.languages.register({ id: LANG_ID });
monaco.languages.setMonarchTokensProvider(LANG_ID, monarchlanguage);
monaco.languages.setLanguageConfiguration(LANG_ID, languageConfiguration);
}
}
const getStyles = (theme: GrafanaTheme2) => {
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<HTMLDivElement>(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 (
<div
aria-label={selectors.components.QueryField.container}
className={styles.container}
// NOTE: we will be setting inline-style-width/height on this element
ref={containerRef}
>
<ReactMonacoEditor
overrideServices={overrideServicesRef.current}
options={options}
language={LANG_ID}
value={initialValue}
beforeMount={(monaco) => {
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:
// <CodeEditor resizingMode="single-line"/>
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', {});
}
});
}}
/>
</div>
);
};
// Default export for lazy load.
export default MonacoQueryField;

View File

@ -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 (
<Suspense fallback={null}>
<Field {...props} />
</Suspense>
);
};

View File

@ -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<HistoryItem<LokiQuery>>;
onRunQuery: (value: string) => void;
onBlur: (value: string) => void;
};

View File

@ -0,0 +1,34 @@
import React, { useRef } from 'react';
import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy';
import { Props as MonacoProps } from './MonacoQueryFieldProps';
type Props = Omit<MonacoProps, 'onRunQuery' | 'onBlur'> & {
onChange: (query: string) => void;
onRunQuery: () => void;
runQueryOnBlur: boolean;
};
export const MonacoQueryFieldWrapper = (props: Props) => {
const lastRunValueRef = useRef<string | null>(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 <MonacoQueryFieldLazy onRunQuery={handleRunQuery} onBlur={handleBlur} {...rest} />;
};

View File

@ -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<string, string>();
// 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<void> => {
// 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<void> => {
// 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;
}

View File

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

View File

@ -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<HistoryItem<LokiQuery>> = []) {}
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 ?? {});
}
}

View File

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

View File

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

View File

@ -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<Completion[]> {
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<Completion[]> {
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<Completion[]> {
return getLabelNamesForCompletions('=', true, false, otherLabels, dataProvider);
}
async function getInGroupingCompletions(
otherLabels: Label[],
dataProvider: CompletionDataProvider
): Promise<Completion[]> {
return getLabelNamesForCompletions('', false, true, otherLabels, dataProvider);
}
async function getAfterSelectorCompletions(
labels: Label[],
afterPipe: boolean,
dataProvider: CompletionDataProvider
): Promise<Completion[]> {
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<Completion[]> {
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<Completion[]> {
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);
}
}

View File

@ -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<monacoTypes.languages.CompletionList> => {
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,
};
}

View File

@ -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: '!~' },
],
});
});
});

View File

@ -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<string, LabelOperator>([
['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;
}

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ const rawRange = {
};
const defaultTimeSrvMock = {
timeRange: () => ({
timeRange: jest.fn().mockReturnValue({
from: rawRange.from,
to: rawRange.to,
raw: rawRange,

View File

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

View File

@ -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:*"