From 3fbe111bc0f85ecbb571a0f2d7068978fcfd2797 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Tue, 13 May 2025 16:31:37 +0530 Subject: [PATCH] feat: support aggregation function with values --- .../QueryAggregation.styles.scss | 156 +++++++++++++ .../QueryAggregation.tsx} | 12 +- .../QueryAggregationSelect.tsx | 210 ++++++++++++++++++ .../QueryAggregationOptions.styles.scss | 29 --- .../QueryBuilderV2/QueryBuilderV2.tsx | 4 +- .../QuerySearch/QuerySearch.tsx | 8 +- frontend/src/utils/antlrQueryUtils2.ts | 159 +++++++++++++ 7 files changed, 542 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.styles.scss rename frontend/src/components/QueryBuilderV2/{QueryAggregationOptions/QueryAggregationOptions.tsx => QueryAggregation/QueryAggregation.tsx} (78%) create mode 100644 frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregationSelect.tsx delete mode 100644 frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.styles.scss create mode 100644 frontend/src/utils/antlrQueryUtils2.ts diff --git a/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.styles.scss b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.styles.scss new file mode 100644 index 000000000000..731bcfecf0a9 --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.styles.scss @@ -0,0 +1,156 @@ +.query-aggregation-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .query-aggregation-options-input { + width: 100%; + height: 36px; + line-height: 36px; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + + font-family: 'Space Mono', monospace !important; + + &::placeholder { + color: var(--bg-vanilla-100); + opacity: 0.5; + } + } + + .query-aggregation-interval { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } +} + +.query-aggregation-select-container { + width: 100%; + + .query-aggregation-select-editor { + border-radius: 2px; + + .cm-content { + padding: 0; + } + + .cm-editor { + border-radius: 2px; + overflow: hidden; + background-color: transparent !important; + + &:focus-within { + border-color: var(--bg-robin-500); + } + + .cm-content { + border-radius: 2px; + border: 1px solid var(--Slate-400, #1d212d); + padding: 0px !important; + background-color: #121317 !important; + + &:focus-within { + border-color: var(--bg-ink-200); + } + } + + .cm-tooltip-autocomplete { + background: var(--bg-ink-300) !important; + border-radius: 2px !important; + font-size: 12px !important; + font-weight: 500 !important; + margin-top: -2px !important; + min-width: 400px !important; + position: relative !important; + top: 0px !important; + left: 0px !important; + + border-radius: 4px; + border: 1px solid var(--bg-slate-200, #1d212d); + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ) !important; + backdrop-filter: blur(20px); + box-sizing: border-box; + font-family: 'Space Mono', monospace !important; + + ul { + width: 100% !important; + max-width: 100% !important; + font-family: 'Space Mono', monospace !important; + min-height: 200px !important; + + &::-webkit-scrollbar { + width: 0.3rem; + } + &::-webkit-scrollbar-corner { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgb(136, 136, 136); + border-radius: 0.625rem; + } + &::-webkit-scrollbar-track { + background: transparent; + } + + li { + width: 100% !important; + max-width: 100% !important; + line-height: 36px !important; + height: 36px !important; + padding: 4px 8px !important; + + display: flex !important; + align-items: center !important; + gap: 8px !important; + box-sizing: border-box; + overflow: hidden; + + font-family: 'Space Mono', monospace !important; + + .cm-completionIcon { + display: none !important; + } + + &[aria-selected='true'] { + // background-color: rgba(78, 116, 248, 0.7) !important; + background: rgba(171, 189, 255, 0.04) !important; + } + } + } + } + + .cm-gutters { + display: none !important; + } + + .cm-line { + line-height: 34px !important; + font-family: 'Space Mono', monospace !important; + background-color: #121317 !important; + + ::-moz-selection { + background: var(--bg-ink-100) !important; + opacity: 0.5 !important; + } + + ::selection { + background: var(--bg-ink-100) !important; + opacity: 0.5 !important; + } + } + + .cm-selectionBackground { + background: var(--bg-ink-100) !important; + opacity: 0.5 !important; + } + } + } +} diff --git a/frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.tsx b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.tsx similarity index 78% rename from frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.tsx rename to frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.tsx index a6778f13600f..17475d1f46e7 100644 --- a/frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregation.tsx @@ -1,15 +1,19 @@ -import './QueryAggregationOptions.styles.scss'; +import './QueryAggregation.styles.scss'; -import { Input } from 'antd'; +// import { Input } from 'antd'; import InputWithLabel from 'components/InputWithLabel/InputWithLabel'; +import QueryAggregationSelect from './QueryAggregationSelect'; + function QueryAggregationOptions(): JSX.Element { return (
- + /> */} + +
every
diff --git a/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregationSelect.tsx b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregationSelect.tsx new file mode 100644 index 000000000000..cbf2b70ab9ef --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/QueryAggregation/QueryAggregationSelect.tsx @@ -0,0 +1,210 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import './QueryAggregation.styles.scss'; + +import { + autocompletion, + Completion, + CompletionContext, +} from '@codemirror/autocomplete'; +import { javascript } from '@codemirror/lang-javascript'; +import { copilot } from '@uiw/codemirror-theme-copilot'; +import CodeMirror, { EditorView } from '@uiw/react-codemirror'; +import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators'; +import { TracesAggregatorOperator } from 'types/common/queryBuilder'; + +const operatorArgMeta: Record< + string, + { acceptsArgs: boolean; multiple: boolean } +> = { + [TracesAggregatorOperator.NOOP]: { acceptsArgs: false, multiple: false }, + [TracesAggregatorOperator.COUNT]: { acceptsArgs: false, multiple: false }, + [TracesAggregatorOperator.COUNT_DISTINCT]: { + acceptsArgs: true, + multiple: true, + }, + [TracesAggregatorOperator.SUM]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.AVG]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.MAX]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.MIN]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P05]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P10]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P20]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P25]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P50]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P75]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P90]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P95]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.P99]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.RATE]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.RATE_SUM]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.RATE_AVG]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.RATE_MIN]: { acceptsArgs: true, multiple: false }, + [TracesAggregatorOperator.RATE_MAX]: { acceptsArgs: true, multiple: false }, +}; + +const fieldSuggestions: Completion[] = [ + { label: 'duration', type: 'variable' }, + { label: 'status_code', type: 'variable' }, + { label: 'service_name', type: 'variable' }, + { label: 'trace_id', type: 'variable' }, +]; + +const mapToFunctionCompletions = ( + operators: typeof tracesAggregateOperatorOptions, +): Completion[] => + operators.map((op) => ({ + label: `${op.value}()`, + type: 'function', + apply: `${op.value}()`, + })); + +const applyFieldSuggestion = ( + view: EditorView, + suggestion: Completion, +): void => { + const currentText = view.state.sliceDoc(0, view.state.selection.main.from); + const endPos = view.state.selection.main.from; + + // Find the last opening parenthesis before the cursor + const lastOpenParen = currentText.lastIndexOf('('); + if (lastOpenParen === -1) return; + + // Find the last comma after the opening parenthesis + const textAfterParen = currentText.slice(lastOpenParen); + const lastComma = textAfterParen.lastIndexOf(','); + + // Calculate the start position for insertion + const startPos = + lastComma === -1 ? lastOpenParen + 1 : lastOpenParen + lastComma + 1; + + // Insert the suggestion + view.dispatch({ + changes: { from: startPos, to: endPos, insert: suggestion.label }, + selection: { anchor: startPos + suggestion.label.length }, + }); +}; + +const applyOperatorSuggestion = ( + view: EditorView, + from: number, + label: string, +): void => { + view.dispatch({ + changes: { from, insert: label }, + selection: { anchor: from + label.length }, + }); +}; + +const getOperatorSuggestions = ( + from: number, + operators: typeof tracesAggregateOperatorOptions, +): Completion[] => + mapToFunctionCompletions(operators).map((op) => ({ + ...op, + apply: (view: EditorView): void => + applyOperatorSuggestion(view, from, op.label), + })); + +const aggregatorAutocomplete = autocompletion({ + override: [ + (context: CompletionContext): any => { + const word = context.matchBefore(/[\w\d_\s]*(\()?[^)]*$/); + if (!word || (word.from === word.to && !context.explicit)) return null; + + const textBeforeCursor = context.state.sliceDoc(0, context.pos); + const functionMatch = textBeforeCursor.match(/(\w+)\(([^)]*)$/); + const funcName = functionMatch?.[1]?.toLowerCase(); + + // Handle argument suggestions when cursor is inside parentheses + if (funcName && operatorArgMeta[funcName]) { + const { acceptsArgs, multiple } = operatorArgMeta[funcName]; + + if (!acceptsArgs) return null; + + // Get all arguments for the current function + const argsMatch = functionMatch?.[2]; + const argsSoFar = + argsMatch + ?.split(',') + .map((arg) => arg.trim()) + .filter(Boolean) || []; + + if (!multiple && argsSoFar.length >= 1) return null; + + return { + from: context.pos, + options: fieldSuggestions.map((suggestion) => ({ + ...suggestion, + apply: (view: EditorView): void => { + applyFieldSuggestion(view, suggestion); + // For count_distinct, add a comma after the field + if (funcName === TracesAggregatorOperator.COUNT_DISTINCT.toLowerCase()) { + const currentPos = view.state.selection.main.from; + view.dispatch({ + changes: { from: currentPos, insert: ', ' }, + selection: { anchor: currentPos + 2 }, + }); + } + }, + })), + }; + } + + // Handle operator suggestions + const isAfterCompleteFunction = textBeforeCursor.match(/\w+\([^)]*\)\s*$/); + if (isAfterCompleteFunction) { + return { + from: context.pos, + options: getOperatorSuggestions( + context.pos, + tracesAggregateOperatorOptions, + ), + }; + } + + // Regular word-based suggestions + const wordBeforeCursor = word.text.trim(); + if (wordBeforeCursor) { + const filteredOperators = tracesAggregateOperatorOptions.filter((op) => + op.value.toLowerCase().startsWith(wordBeforeCursor.toLowerCase()), + ); + return { + from: word.from, + options: getOperatorSuggestions(word.from, filteredOperators), + }; + } + + // Show all options if no word before cursor + return { + from: word.from, + options: getOperatorSuggestions(word.from, tracesAggregateOperatorOptions), + }; + }, + ], +}); + +function QueryAggregationSelect(): JSX.Element { + return ( +
+ +
+ ); +} + +export default QueryAggregationSelect; diff --git a/frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.styles.scss b/frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.styles.scss deleted file mode 100644 index 9747aa472300..000000000000 --- a/frontend/src/components/QueryBuilderV2/QueryAggregationOptions/QueryAggregationOptions.styles.scss +++ /dev/null @@ -1,29 +0,0 @@ -.query-aggregation-container { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - - .query-aggregation-options-input { - width: 100%; - height: 36px; - line-height: 36px; - border-radius: 2px; - border: 1px solid var(--bg-slate-400); - box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); - - font-family: 'Space Mono', monospace !important; - - &::placeholder { - color: var(--bg-vanilla-100); - opacity: 0.5; - } - } - - .query-aggregation-interval { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - } -} diff --git a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx index b0dc3ced69a7..746cb87e4585 100644 --- a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx @@ -3,7 +3,7 @@ import './QueryBuilderV2.styles.scss'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import QueryAddOns from './QueryAddOns/QueryAddOns'; -import QueryAggregationOptions from './QueryAggregationOptions/QueryAggregationOptions'; +import QueryAggregation from './QueryAggregation/QueryAggregation'; import QuerySearch from './QuerySearch/QuerySearch'; function QueryBuilderV2(): JSX.Element { @@ -12,7 +12,7 @@ function QueryBuilderV2(): JSX.Element { return (
- + 0) parens--; + state = State.ExpectOperator; + break; + case FilterQueryLexer.LBRACK: + array++; + state = State.ExpectValue; + break; + case FilterQueryLexer.RBRACK: + if (array > 0) array--; + state = State.ExpectOperator; + break; + case FilterQueryLexer.COMMA: + if (array > 0) state = State.ExpectValue; + break; + case FilterQueryLexer.KEY: + if (state === State.ExpectKey) { + lastKey = tok; + state = State.ExpectOperator; + } + break; + case FilterQueryLexer.QUOTED_TEXT: + case FilterQueryLexer.NUMBER: + case FilterQueryLexer.BOOL: + if (state === State.ExpectValue) { + state = State.ExpectOperator; + } + break; + default: + if ( + tok.type >= FilterQueryLexer.EQUALS && + tok.type <= FilterQueryLexer.CONTAINS + ) { + state = State.ExpectValue; + } + break; + } + + pos += text.length; + } + + console.log('cursorTok', cursorTok); + + const out: ContextInfo = { context: CursorContext.NoFilter }; + + if (cursorTok) { + out.token = cursorTok; + } + + console.log('out', cloneDeep(out)); + console.log('state', cloneDeep(state)); + + switch (state) { + case State.ExpectKey: + out.context = CursorContext.Key; + break; + case State.ExpectOperator: + out.context = CursorContext.Operator; + if (lastKey) out.key = lastKey.text; + break; + case State.ExpectValue: + out.context = CursorContext.Value; + if (lastKey) out.key = lastKey.text; + + if (lastOperator) { + out.operator = lastOperator.text; + } + break; + default: + out.context = CursorContext.NoFilter; + break; + } + + console.log('out', cloneDeep(out)); + + if ( + cursorTok && + cursorTok.type === FilterQueryLexer.QUOTED_TEXT && + (out.context === CursorContext.Key || out.context === CursorContext.NoFilter) + ) { + out.context = CursorContext.FullText; + } + + // if (!cursorTok || cursorTok.type === antlr4.Token.EOF) { + // out.context = CursorContext.NoFilter; + // } + + return out; +}