Cloudwatch: Dynamic labels autocomplete (#49794)

* add completeable interface

* add basic labels language

* render monaco editor for label field

* align styling in math expression field

* add unit tests

* fix broken test

* remove unused import

* use theme

* remove comment

* pr feedback

* fix broken imports

* improve test

* make it possible to override code editor styles

* use input styles and align border styles
This commit is contained in:
Erik Sundell 2022-06-02 10:54:51 +02:00 committed by GitHub
parent a0f1a4b716
commit 467e375fe6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 355 additions and 18 deletions

View File

@ -111,7 +111,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
const value = this.props.value ?? '';
const longText = value.length > 100;
const styles = getStyles(theme);
const containerStyles = this.props.containerStyles ?? getStyles(theme).container;
const options: MonacoOptions = {
wordWrap: 'off',
@ -143,7 +143,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
}
return (
<div className={styles.container} onBlur={this.onBlur} aria-label={selectors.components.CodeEditor.container}>
<div className={containerStyles} onBlur={this.onBlur} aria-label={selectors.components.CodeEditor.container}>
<ReactMonacoEditorLazy
width={width}
height={height}

View File

@ -49,6 +49,8 @@ export interface CodeEditorProps {
* Language agnostic suggestion completions -- typically for template variables
*/
getSuggestions?: CodeEditorSuggestionProvider;
containerStyles?: string;
}
/**

View File

@ -0,0 +1,23 @@
import { monacoTypes } from '@grafana/ui';
export const afterLabelValue = {
query: '${DATAPOINT_COUNT} ',
tokens: [
[
{
offset: 0,
type: 'predefined.cloudwatch-dynamicLabels',
language: 'cloudwatch-dynamicLabels',
},
{
offset: 18,
type: 'white.cloudwatch-dynamicLabels',
language: 'cloudwatch-dynamicLabels',
},
],
] as monacoTypes.Token[][],
position: {
lineNumber: 1,
column: 20,
},
};

View File

@ -0,0 +1,2 @@
export { afterLabelValue } from './afterLabelValue';
export { insideLabelValue } from './insideLabelValue';

View File

@ -0,0 +1,23 @@
import { monacoTypes } from '@grafana/ui';
export const insideLabelValue = {
query: '${DATAPOINT_COUNT} ',
tokens: [
[
{
offset: 0,
type: 'predefined.cloudwatch-dynamicLabels',
language: 'cloudwatch-dynamicLabels',
},
{
offset: 18,
type: 'white.cloudwatch-dynamicLabels',
language: 'cloudwatch-dynamicLabels',
},
],
] as monacoTypes.Token[][],
position: {
lineNumber: 1,
column: 10,
},
};

View File

@ -2,6 +2,7 @@ import { monacoTypes } from '@grafana/ui';
import { Monaco } from '../../monarch/types';
import * as SQLTestData from '../cloudwatch-sql-test-data';
import * as DynamicLabelTestData from '../dynamic-label-test-data';
import * as MetricMathTestData from '../metric-math-test-data';
// Stub for the Monaco instance.
@ -30,6 +31,14 @@ const MonacoMock: Monaco = {
};
return TestData[value];
}
if (languageId === 'cloudwatch-dynamicLabels') {
const TestData = {
[DynamicLabelTestData.afterLabelValue.query]: DynamicLabelTestData.afterLabelValue.tokens,
[DynamicLabelTestData.insideLabelValue.query]: DynamicLabelTestData.insideLabelValue.tokens,
};
return TestData[value];
}
return [];
},
},

View File

@ -0,0 +1,82 @@
import { css, cx } from '@emotion/css';
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
import React, { useCallback, useRef } from 'react';
import { CodeEditor, getInputStyles, Monaco, useTheme2 } from '@grafana/ui';
import { DynamicLabelsCompletionItemProvider } from '../dynamic-labels/CompletionItemProvider';
import language from '../dynamic-labels/definition';
import { TRIGGER_SUGGEST } from '../monarch/commands';
import { registerLanguage } from '../monarch/register';
const dynamicLabelsCompletionItemProvider = new DynamicLabelsCompletionItemProvider();
export interface Props {
onChange: (query: string) => void;
onRunQuery: () => void;
label: string;
width: number;
}
export function DynamicLabelsField({ label, width, onChange, onRunQuery }: Props) {
const theme = useTheme2();
const styles = getInputStyles({ theme, width });
const containerRef = useRef<HTMLDivElement>(null);
const onEditorMount = useCallback(
(editor: monacoType.editor.IStandaloneCodeEditor, monaco: Monaco) => {
editor.onDidFocusEditorText(() => editor.trigger(TRIGGER_SUGGEST.id, TRIGGER_SUGGEST.id, {}));
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
onChange(text);
onRunQuery();
});
const containerDiv = containerRef.current;
containerDiv !== null && editor.layout({ width: containerDiv.clientWidth, height: containerDiv.clientHeight });
},
[onChange, onRunQuery]
);
return (
<div ref={containerRef} className={cx(styles.wrapper)}>
<CodeEditor
containerStyles={css`
border: 1px solid ${theme.colors.action.disabledBackground};
&:hover {
border-color: ${theme.components.input.borderColor};
}
`}
monacoOptions={{
// without this setting, the auto-resize functionality causes an infinite loop, don't remove it!
scrollBeyondLastLine: false,
// These additional options are style focused and are a subset of those in the query editor in Prometheus
fontSize: 14,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerLanes: 0,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
suggestFontSize: 12,
padding: {
top: 6,
},
}}
language={language.id}
value={label}
onBlur={(value) => {
if (value !== label) {
onChange(value);
onRunQuery();
}
}}
onBeforeEditorMount={(monaco: Monaco) =>
registerLanguage(monaco, language, dynamicLabelsCompletionItemProvider)
}
onEditorDidMount={onEditorMount}
/>
</div>
);
}

View File

@ -37,7 +37,7 @@ export function MathExpressionQueryField({
const updateElementHeight = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null && editor.getContentHeight() < 200) {
const pixelHeight = editor.getContentHeight();
const pixelHeight = Math.max(32, editor.getContentHeight());
containerDiv.style.height = `${pixelHeight}px`;
containerDiv.style.width = '100%';
const pixelWidth = containerDiv.clientWidth;
@ -68,6 +68,9 @@ export function MathExpressionQueryField({
},
suggestFontSize: 12,
wordWrap: 'on',
padding: {
top: 6,
},
}}
language={language.id}
value={query}

View File

@ -4,6 +4,7 @@ import selectEvent from 'react-select-event';
import { DataSourceInstanceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import * as ui from '@grafana/ui';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CustomVariableModel, initialVariableModelState } from '../../../../../features/variables/types';
@ -12,6 +13,13 @@ import { CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../../typ
import { MetricsQueryEditor, Props } from './MetricsQueryEditor';
jest.mock('@grafana/ui', () => ({
...jest.requireActual<typeof ui>('@grafana/ui'),
CodeEditor: function CodeEditor({ value }: { value: string }) {
return <pre>{value}</pre>;
},
}));
const setup = () => {
const instanceSettings = {
jsonData: { defaultRegion: 'us-east-1' },
@ -173,9 +181,7 @@ describe('QueryEditor', () => {
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.queryByText('Alias')).toBeNull();
expect(screen.getByLabelText('Label - optional')).toHaveValue(
"Period: ${PROP('Period')} InstanceId: ${PROP('Dim.InstanceId')}"
);
expect(screen.getByText("Period: ${PROP('Period')} InstanceId: ${PROP('Dim.InstanceId')}"));
config.featureToggles.cloudWatchDynamicLabels = originalValue;
});

View File

@ -16,6 +16,7 @@ import {
MetricQueryType,
MetricStat,
} from '../../types';
import { DynamicLabelsField } from '../DynamicLabelsField';
import QueryHeader from '../QueryHeader';
import { Alias } from './Alias';
@ -138,14 +139,12 @@ export const MetricsQueryEditor = (props: Props) => {
optional
tooltip="Change time series legend name using Dynamic labels. See documentation for details."
>
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-label`}
onBlur={onRunQuery}
value={preparedQuery.label ?? ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...preparedQuery, label: event.target.value })
}
/>
<DynamicLabelsField
width={52}
onRunQuery={onRunQuery}
label={preparedQuery.label ?? ''}
onChange={(label) => props.onChange({ ...query, label })}
></DynamicLabelsField>
</EditorField>
) : (
<EditorField

View File

@ -0,0 +1,46 @@
import { CompletionItemPriority } from '@grafana/experimental';
import { Monaco, monacoTypes } from '@grafana/ui';
import { afterLabelValue, insideLabelValue } from '../__mocks__/dynamic-label-test-data';
import MonacoMock from '../__mocks__/monarch/Monaco';
import TextModel from '../__mocks__/monarch/TextModel';
import { DynamicLabelsCompletionItemProvider } from './CompletionItemProvider';
import cloudWatchDynamicLabelsLanguageDefinition from './definition';
import { DYNAMIC_LABEL_PATTERNS } from './language';
const getSuggestions = async (value: string, position: monacoTypes.IPosition) => {
const setup = new DynamicLabelsCompletionItemProvider();
const monaco = MonacoMock as Monaco;
const provider = setup.getCompletionProvider(monaco, cloudWatchDynamicLabelsLanguageDefinition);
const { suggestions } = await provider.provideCompletionItems(
TextModel(value) as monacoTypes.editor.ITextModel,
position
);
return suggestions;
};
describe('Dynamic labels: CompletionItemProvider', () => {
describe('getSuggestions', () => {
it('returns all dynamic labels in case current token is a whitespace', async () => {
const { query, position } = afterLabelValue;
const suggestions = await getSuggestions(query, position);
expect(suggestions.length).toEqual(DYNAMIC_LABEL_PATTERNS.length + 1); // + 1 for the dimension suggestions
});
it('should return suggestion for dimension label that has high prio', async () => {
const { query, position } = afterLabelValue;
const suggestions = await getSuggestions(query, position);
expect(suggestions.length).toBeTruthy();
const highPrioSuggestsions = suggestions.filter((s) => s.sortText === CompletionItemPriority.High);
expect(highPrioSuggestsions.length).toBe(1);
expect(highPrioSuggestsions[0].label).toBe("${PROP('Dim.')}");
});
it('doesnt return suggestions if cursor is inside a dynamic label', async () => {
const { query, position } = insideLabelValue;
const suggestions = await getSuggestions(query, position);
expect(suggestions.length).toBe(0);
});
});
});

View File

@ -0,0 +1,71 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { linkedTokenBuilder } from '../monarch/linkedTokenBuilder';
import { LanguageDefinition } from '../monarch/register';
import { Completeable, CompletionItemPriority, TokenTypes } from '../monarch/types';
import { DYNAMIC_LABEL_PATTERNS } from './language';
type CompletionItem = monacoTypes.languages.CompletionItem;
export class DynamicLabelsCompletionItemProvider implements Completeable {
tokenTypes: TokenTypes;
constructor() {
this.tokenTypes = {
Parenthesis: 'delimiter.parenthesis.cloudwatch-dynamicLabels',
Whitespace: 'white.cloudwatch-dynamicLabels',
Keyword: 'keyword.cloudwatch-dynamicLabels',
Delimiter: 'delimiter.cloudwatch-dynamicLabels',
Operator: 'operator.cloudwatch-dynamicLabels',
Identifier: 'identifier.cloudwatch-dynamicLabels',
Type: 'type.cloudwatch-dynamicLabels',
Function: 'predefined.cloudwatch-dynamicLabels',
Number: 'number.cloudwatch-dynamicLabels',
String: 'string.cloudwatch-dynamicLabels',
Variable: 'variable.cloudwatch-dynamicLabels',
};
}
// called by registerLanguage and passed to monaco with registerCompletionItemProvider
// returns an object that implements https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.CompletionItemProvider.html
getCompletionProvider(monaco: Monaco, languageDefinition: LanguageDefinition) {
return {
triggerCharacters: [' ', '$', ',', '(', "'"], // one of these characters indicates that it is time to look for a suggestion
provideCompletionItems: async (model: monacoTypes.editor.ITextModel, position: monacoTypes.IPosition) => {
const currentToken = linkedTokenBuilder(monaco, languageDefinition, model, position, this.tokenTypes);
const invalidRangeToken = currentToken?.isWhiteSpace() || currentToken?.isParenthesis();
const range =
invalidRangeToken || !currentToken?.range ? monaco.Range.fromPositions(position) : currentToken?.range;
const toCompletionItem = (value: string, rest: Partial<CompletionItem> = {}) => {
const item: CompletionItem = {
label: value,
insertText: value,
kind: monaco.languages.CompletionItemKind.Field,
range,
sortText: CompletionItemPriority.Medium,
...rest,
};
return item;
};
let suggestions: CompletionItem[] = [];
const next = currentToken?.next;
if (!currentToken?.isFunction() && (!next || next.isWhiteSpace())) {
suggestions = DYNAMIC_LABEL_PATTERNS.map((val) => toCompletionItem(val));
// always insert suggestion for dimension value and allow user to complete pattern by providing the dimension name
suggestions.push(
toCompletionItem("${PROP('Dim.')}", {
sortText: CompletionItemPriority.High,
insertText: `\${PROP('Dim.$0')} `,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
})
);
}
return {
suggestions,
};
},
};
}
}

View File

@ -0,0 +1,10 @@
import { LanguageDefinition } from '../monarch/register';
const cloudWatchDynamicLabelsLanguageDefinition: LanguageDefinition = {
id: 'cloudwatch-dynamicLabels',
extensions: [],
aliases: [],
mimetypes: [],
loader: () => import('./language'),
};
export default cloudWatchDynamicLabelsLanguageDefinition;

View File

@ -0,0 +1,52 @@
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
// Dynamic labels: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/graph-dynamic-labels.html
export const DYNAMIC_LABEL_PATTERNS = [
'${DATAPOINT_COUNT}',
'${FIRST}',
'${FIRST_LAST_RANGE}',
'${FIRST_LAST_TIME_RANGE}',
'${FIRST_TIME}',
'${FIRST_TIME_RELATIVE}',
'${LABEL}',
'${LAST}',
'${LAST_TIME}',
'${LAST_TIME_RELATIVE}',
'${MAX}',
'${MAX_TIME}',
'${MAX_TIME_RELATIVE}',
'${MIN}',
'${MIN_MAX_RANGE}',
'${MIN_MAX_TIME_RANGE}',
'${MIN_TIME}',
'${MIN_TIME_RELATIVE}',
"${PROP('AccountId')}",
"${PROP('MetricName')}",
"${PROP('Namespace')}",
"${PROP('Period')}",
"${PROP('Region')}",
"${PROP('Stat')}",
'${SUM}',
];
export const language: monacoType.languages.IMonarchLanguage = {
id: 'dynamicLabels',
ignoreCase: false,
tokenizer: {
root: [
{ include: '@whitespace' },
{ include: '@builtInFunctions' },
{ include: '@string' },
[/\$\{PROP\('Dim.[a-zA-Z0-9-_]?.*'\)\}+/, 'predefined'], //custom handling for dimension patterns
],
builtInFunctions: [[DYNAMIC_LABEL_PATTERNS.map(escapeRegExp).join('|'), 'predefined']],
whitespace: [[/\s+/, 'white']],
string: [],
},
};
export const conf: monacoType.languages.LanguageConfiguration = {};
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

View File

@ -6,7 +6,7 @@ import { CloudWatchDatasource } from '../datasource';
import { LinkedToken } from './LinkedToken';
import { linkedTokenBuilder } from './linkedTokenBuilder';
import { LanguageDefinition } from './register';
import { StatementPosition, SuggestionKind, TokenTypes } from './types';
import { Completeable, StatementPosition, SuggestionKind, TokenTypes } from './types';
type CompletionItem = monacoTypes.languages.CompletionItem;
@ -17,7 +17,7 @@ CompletionItemProvider is an extendable class which needs to implement :
- getSuggestionKinds
- getSuggestions
*/
export class CompletionItemProvider {
export class CompletionItemProvider implements Completeable {
templateVariables: string[];
datasource: CloudWatchDatasource;
templateSrv: TemplateSrv;

View File

@ -2,7 +2,7 @@ import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
import { Monaco } from '@grafana/ui';
import { CompletionItemProvider } from './CompletionItemProvider';
import { Completeable } from './types';
export type LanguageDefinition = {
id: string;
@ -18,7 +18,7 @@ export type LanguageDefinition = {
export const registerLanguage = (
monaco: Monaco,
language: LanguageDefinition,
completionItemProvider: CompletionItemProvider
completionItemProvider: Completeable
) => {
const { id, loader } = language;

View File

@ -1,5 +1,7 @@
import { monacoTypes } from '@grafana/ui';
import { LanguageDefinition } from './register';
export interface TokenTypes {
Parenthesis: string;
Whitespace: string;
@ -98,3 +100,10 @@ export interface Monaco {
Range: Range;
languages: Languages;
}
export interface Completeable {
getCompletionProvider(
monaco: Monaco,
languageDefinition: LanguageDefinition
): monacoTypes.languages.CompletionItemProvider;
}