mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a0f1a4b716
commit
467e375fe6
@ -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}
|
||||
|
@ -49,6 +49,8 @@ export interface CodeEditorProps {
|
||||
* Language agnostic suggestion completions -- typically for template variables
|
||||
*/
|
||||
getSuggestions?: CodeEditorSuggestionProvider;
|
||||
|
||||
containerStyles?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export { afterLabelValue } from './afterLabelValue';
|
||||
export { insideLabelValue } from './insideLabelValue';
|
@ -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,
|
||||
},
|
||||
};
|
@ -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 [];
|
||||
},
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { LanguageDefinition } from '../monarch/register';
|
||||
|
||||
const cloudWatchDynamicLabelsLanguageDefinition: LanguageDefinition = {
|
||||
id: 'cloudwatch-dynamicLabels',
|
||||
extensions: [],
|
||||
aliases: [],
|
||||
mimetypes: [],
|
||||
loader: () => import('./language'),
|
||||
};
|
||||
export default cloudWatchDynamicLabelsLanguageDefinition;
|
@ -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
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user