mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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 commit3d003ca4bc
. * Revert "Chore: comment line filters pending implementation" This reverts commit84bfe76a6a
. 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:
parent
8fd4fcb987
commit
729ce8bb72
@ -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"]
|
||||
|
@ -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:*",
|
||||
|
@ -34,6 +34,7 @@ export interface FeatureToggles {
|
||||
publicDashboards?: boolean;
|
||||
lokiLive?: boolean;
|
||||
lokiDataframeApi?: boolean;
|
||||
lokiMonacoEditor?: boolean;
|
||||
swaggerUi?: boolean;
|
||||
featureHighlights?: boolean;
|
||||
dashboardComments?: boolean;
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
};
|
@ -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} />;
|
||||
};
|
@ -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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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 ?? {});
|
||||
}
|
||||
}
|
@ -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)}`);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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: '!~' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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);
|
||||
|
@ -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 };
|
||||
}
|
||||
|
||||
|
43
public/app/plugins/datasource/loki/language_utils.test.ts
Normal file
43
public/app/plugins/datasource/loki/language_utils.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
@ -18,7 +18,7 @@ const rawRange = {
|
||||
};
|
||||
|
||||
const defaultTimeSrvMock = {
|
||||
timeRange: () => ({
|
||||
timeRange: jest.fn().mockReturnValue({
|
||||
from: rawRange.from,
|
||||
to: rawRange.to,
|
||||
raw: rawRange,
|
||||
|
@ -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];
|
||||
|
10
yarn.lock
10
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:*"
|
||||
|
Loading…
Reference in New Issue
Block a user