diff --git a/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx index 5c193fd1053..0666eb1e4dc 100644 --- a/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx +++ b/public/app/features/alerting/unified/components/AnnotationDetailsField.tsx @@ -7,6 +7,7 @@ import { Tooltip, useStyles } from '@grafana/ui'; import { Annotation, annotationLabels } from '../utils/constants'; import { DetailsField } from './DetailsField'; +import { Tokenize } from './Tokenize'; import { Well } from './Well'; const wellableAnnotationKeys = ['message', 'description']; @@ -38,8 +39,10 @@ const AnnotationValue: FC = ({ annotationKey, value }) => { const needsWell = wellableAnnotationKeys.includes(annotationKey); const needsLink = value && value.startsWith('http'); + const tokenizeValue = ; + if (needsWell) { - return {value}; + return {tokenizeValue}; } if (needsLink) { @@ -50,7 +53,7 @@ const AnnotationValue: FC = ({ annotationKey, value }) => { ); } - return <>{value}; + return <>{tokenizeValue}; }; export const getStyles = (theme: GrafanaTheme) => ({ diff --git a/public/app/features/alerting/unified/components/HoverCard.tsx b/public/app/features/alerting/unified/components/HoverCard.tsx new file mode 100644 index 00000000000..baea3a6e36f --- /dev/null +++ b/public/app/features/alerting/unified/components/HoverCard.tsx @@ -0,0 +1,62 @@ +import { css } from '@emotion/css'; +import { Placement } from '@popperjs/core'; +import classnames from 'classnames'; +import React, { FC, ReactElement, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Popover as GrafanaPopover, PopoverController, useStyles2 } from '@grafana/ui'; + +export interface HoverCardProps { + children: ReactElement; + content: ReactElement; + wrapperClassName?: string; + placement?: Placement; + disabled?: boolean; +} + +export const HoverCard: FC = ({ children, content, wrapperClassName, disabled = false, ...rest }) => { + const popoverRef = useRef(null); + const styles = useStyles2(getStyles); + + if (disabled) { + return children; + } + + return ( + + {(showPopper, hidePopper, popperProps) => { + return ( + <> + {popoverRef.current && ( + + )} + + {React.cloneElement(children, { + ref: popoverRef, + onMouseEnter: showPopper, + onMouseLeave: hidePopper, + })} + + ); + }} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + popover: css` + border-radius: ${theme.shape.borderRadius()}; + box-shadow: ${theme.shadows.z3}; + background: ${theme.colors.background.primary}; + border: 1px solid ${theme.colors.border.medium}; + + padding: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/Tokenize.tsx b/public/app/features/alerting/unified/components/Tokenize.tsx new file mode 100644 index 00000000000..77b217e6b9a --- /dev/null +++ b/public/app/features/alerting/unified/components/Tokenize.tsx @@ -0,0 +1,149 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Badge, useStyles2 } from '@grafana/ui'; + +import { HoverCard } from './HoverCard'; +import { keywords as KEYWORDS, builtinFunctions as FUNCTIONS } from './receivers/editor/language'; + +const VARIABLES = ['$', '.', '"']; + +interface TokenizerProps { + input: string; + delimiter?: [string, string]; +} + +function Tokenize({ input, delimiter = ['{{', '}}'] }: TokenizerProps) { + const styles = useStyles2(getStyles); + + const [open, close] = delimiter; + const normalizedIput = normalizeInput(input); + + /** + * This RegExp uses 2 named capture groups, text that comes before the token and the token itself + * + * open close + * ───────── ── ─────────── ── + * Some text {{ $labels.foo }} + */ + const regex = new RegExp(`(?.*?)(${open}(?.*?)${close}|$)`, 'gm'); + + const matches = Array.from(normalizedIput.matchAll(regex)); + + const output: React.ReactElement[] = []; + + matches.forEach((match, index) => { + const before = match.groups?.before; + const token = match.groups?.token?.trim(); + + if (before) { + output.push({before}); + } + + if (token) { + const type = tokenType(token); + const description = type === TokenType.Variable ? token : ''; + const tokenContent = `${open} ${token} ${close}`; + + output.push(); + } + }); + + return {output}; +} + +enum TokenType { + Variable = 'variable', + Function = 'function', + Keyword = 'keyword', + Unknown = 'unknown', +} + +interface TokenProps { + content: string; + type?: TokenType; + description?: string; +} + +function Token({ content, description, type }: TokenProps) { + const styles = useStyles2(getStyles); + const varName = content.trim(); + + const disableCard = Boolean(type) === false; + + return ( + + {type}} color={'blue'} /> {description && {description}} + + } + > + + + + + ); +} + +function normalizeInput(input: string) { + return input.replace(/\s+/g, ' ').trim(); +} + +function isVariable(input: string) { + return VARIABLES.some((character) => input.startsWith(character)); +} + +function isKeyword(input: string) { + return KEYWORDS.some((keyword) => input.startsWith(keyword)); +} + +function isFunction(input: string) { + return FUNCTIONS.some((functionName) => input.startsWith(functionName)); +} + +function tokenType(input: string) { + let tokenType; + if (isVariable(input)) { + tokenType = TokenType.Variable; + } else if (isKeyword(input)) { + tokenType = TokenType.Keyword; + } else if (isFunction(input)) { + tokenType = TokenType.Function; + } else { + tokenType = TokenType.Unknown; + } + + return tokenType; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + display: inline-flex; + align-items: center; + white-space: pre; + `, + token: css` + cursor: default; + font-family: ${theme.typography.fontFamilyMonospace}; + `, + popover: css` + border-radius: ${theme.shape.borderRadius()}; + box-shadow: ${theme.shadows.z3}; + background: ${theme.colors.background.primary}; + border: 1px solid ${theme.colors.border.medium}; + + padding: ${theme.spacing(1)}; + `, + hoverTokenItem: css` + display: flex; + flex-direction: row; + align-items: center; + gap: ${theme.spacing(1)}; + `, +}); + +export { Tokenize, Token }; diff --git a/public/app/features/alerting/unified/components/receivers/editor/language.ts b/public/app/features/alerting/unified/components/receivers/editor/language.ts index 7775e71c8f2..04418d052d0 100644 --- a/public/app/features/alerting/unified/components/receivers/editor/language.ts +++ b/public/app/features/alerting/unified/components/receivers/editor/language.ts @@ -15,7 +15,7 @@ enum TokenType { // list of available functions in Alertmanager templates // see https://cs.github.com/prometheus/alertmanager/blob/805e505288ce82c3e2b625a3ca63aaf2b0aa9cea/template/template.go?q=join#L132-L151 -const availableAlertManagerFunctions = [ +export const availableAlertManagerFunctions = [ 'toUpper', 'toLower', 'title', @@ -26,8 +26,11 @@ const availableAlertManagerFunctions = [ 'stringSlice', ]; +// boolean functions +const booleanFunctions = ['eq', 'ne', 'lt', 'le', 'gt', 'ge']; + // built-in functions for Go templates -const builtinFunctions = [ +export const builtinFunctions = [ 'and', 'call', 'html', @@ -41,13 +44,11 @@ const builtinFunctions = [ 'printf', 'println', 'urlquery', + ...booleanFunctions, ]; -// boolean functions -const booleanFunctions = ['eq', 'ne', 'lt', 'le', 'gt', 'ge']; - // Go template keywords -const keywords = ['define', 'if', 'else', 'end', 'range', 'break', 'continue', 'template', 'block', 'with']; +export const keywords = ['define', 'if', 'else', 'end', 'range', 'break', 'continue', 'template', 'block', 'with']; // Monarch language definition, see https://microsoft.github.io/monaco-editor/monarch.html // check https://github.com/microsoft/monaco-editor/blob/main/src/basic-languages/go/go.ts for an example @@ -55,7 +56,7 @@ const keywords = ['define', 'if', 'else', 'end', 'range', 'break', 'continue', ' export const language: monacoType.languages.IMonarchLanguage = { defaultToken: '', // change this to "invalid" to find tokens that were never matched keywords: keywords, - functions: [...builtinFunctions, ...booleanFunctions, ...availableAlertManagerFunctions], + functions: [...builtinFunctions, ...availableAlertManagerFunctions], operators: ['|'], tokenizer: { root: [ diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index a6676beccb8..6bc2dd398bb 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -13,6 +13,7 @@ import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '.. import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; import { ProvisioningBadge } from '../Provisioning'; import { RuleLocation } from '../RuleLocation'; +import { Tokenize } from '../Tokenize'; import { RuleDetails } from './RuleDetails'; import { RuleHealth } from './RuleHealth'; @@ -160,7 +161,9 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) { id: 'summary', label: 'Summary', // eslint-disable-next-line react/display-name - renderCell: ({ data: rule }) => rule.annotations[Annotation.summary] ?? '', + renderCell: ({ data: rule }) => { + return ; + }, size: 5, }); }