diff --git a/frontend/src/api/v5/queryRange/convertV5Response.ts b/frontend/src/api/v5/queryRange/convertV5Response.ts index 479b17fd43b9..6d020cbcf7c7 100644 --- a/frontend/src/api/v5/queryRange/convertV5Response.ts +++ b/frontend/src/api/v5/queryRange/convertV5Response.ts @@ -21,7 +21,7 @@ function convertTimeSeriesData( return { queryName: timeSeriesData.queryName, legend: legendMap[timeSeriesData.queryName] || timeSeriesData.queryName, - series: timeSeriesData.aggregations.flatMap((aggregation) => + series: timeSeriesData?.aggregations?.flatMap((aggregation) => aggregation.series.map((series) => ({ labels: series.labels ? Object.fromEntries( diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts index f97501756698..02e7da89f2c0 100644 --- a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts @@ -71,6 +71,7 @@ function getSignalType(dataSource: string): 'traces' | 'logs' | 'metrics' { function createBaseSpec( queryData: IBuilderQuery, requestType: RequestType, + panelType?: PANEL_TYPES, ): BaseBuilderQuery { return { stepInterval: queryData.stepInterval, @@ -90,9 +91,10 @@ function createBaseSpec( }), ) : undefined, - limit: isEmpty(queryData.limit) - ? queryData?.pageSize ?? undefined - : queryData.limit ?? undefined, + limit: + panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST + ? queryData.limit || queryData.pageSize || undefined + : queryData.limit || undefined, offset: requestType === 'raw' ? queryData.offset : undefined, order: queryData.orderBy.length > 0 @@ -151,7 +153,7 @@ export function parseAggregations( return result; } -function createAggregation( +export function createAggregation( queryData: any, ): TraceAggregation[] | LogAggregation[] | MetricAggregation[] { if (queryData.dataSource === DataSource.METRICS) { @@ -180,11 +182,12 @@ function createAggregation( function convertBuilderQueriesToV5( builderQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any requestType: RequestType, + panelType?: PANEL_TYPES, ): QueryEnvelope[] { return Object.entries(builderQueries).map( ([queryName, queryData]): QueryEnvelope => { const signal = getSignalType(queryData.dataSource); - const baseSpec = createBaseSpec(queryData, requestType); + const baseSpec = createBaseSpec(queryData, requestType, panelType); let spec: QueryEnvelope['spec']; const aggregations = createAggregation(queryData); @@ -196,7 +199,6 @@ function convertBuilderQueriesToV5( signal: 'traces' as const, ...baseSpec, aggregations: aggregations as TraceAggregation[], - limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), }; break; case 'logs': @@ -205,7 +207,6 @@ function convertBuilderQueriesToV5( signal: 'logs' as const, ...baseSpec, aggregations: aggregations as LogAggregation[], - limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), }; break; case 'metrics': @@ -216,7 +217,6 @@ function convertBuilderQueriesToV5( ...baseSpec, aggregations: aggregations as MetricAggregation[], // reduceTo: queryData.reduceTo, - limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), }; break; } @@ -321,6 +321,8 @@ export const prepareQueryRangePayloadV5 = ({ const requestType = mapPanelTypeToRequestType(graphType); let queries: QueryEnvelope[] = []; + console.log('query', query); + switch (query.queryType) { case EQueryType.QUERY_BUILDER: { const { queryData: data, queryFormulas } = query.builder; @@ -337,6 +339,7 @@ export const prepareQueryRangePayloadV5 = ({ const builderQueries = convertBuilderQueriesToV5( currentQueryData.data, requestType, + graphType, ); // Convert formulas as separate query type diff --git a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx index b38516dcda14..59881ca9c60f 100644 --- a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx @@ -101,12 +101,12 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
- {currentQuery.builder.queryData.map((query, index) => ( + {isListViewPanel && ( - ))} + )} + + {!isListViewPanel && + currentQuery.builder.queryData.map((query, index) => ( + + ))} {!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/MerticsAggregateSection/MetricsAggregateSection.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/MerticsAggregateSection/MetricsAggregateSection.tsx index defcad45e5a6..ad38bf8a05ec 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/MerticsAggregateSection/MetricsAggregateSection.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/MerticsAggregateSection/MetricsAggregateSection.tsx @@ -97,6 +97,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({ label="Seconds" placeholder="Enter a number" labelAfter + initialValue={query?.stepInterval ?? undefined} />
diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx index 68f9dbace449..357d97d1358b 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx @@ -3,6 +3,7 @@ import { autocompletion, closeCompletion, + Completion, CompletionContext, completionKeymap, CompletionResult, @@ -54,18 +55,18 @@ const havingOperators = [ // Add common value suggestions const commonValues = [ - { label: '0', value: '0' }, - { label: '1', value: '1' }, - { label: '5', value: '5' }, - { label: '10', value: '10' }, - { label: '50', value: '50' }, - { label: '100', value: '100' }, - { label: '1000', value: '1000' }, + { label: '0', value: '0 ' }, + { label: '1', value: '1 ' }, + { label: '5', value: '5 ' }, + { label: '10', value: '10 ' }, + { label: '50', value: '50 ' }, + { label: '100', value: '100 ' }, + { label: '1000', value: '1000 ' }, ]; const conjunctions = [ - { label: 'AND', value: 'AND' }, - { label: 'OR', value: 'OR' }, + { label: 'AND', value: 'AND ' }, + { label: 'OR', value: 'OR ' }, ]; function HavingFilter({ @@ -143,109 +144,165 @@ function HavingFilter({ }); }; - const havingAutocomplete = useMemo( - () => - autocompletion({ - override: [ - (context: CompletionContext): CompletionResult | null => { - const text = context.state.sliceDoc(0, context.pos); - const trimmedText = text.trim(); - const tokens = trimmedText.split(/\s+/).filter(Boolean); + // Helper function for applying completion with space + const applyCompletionWithSpace = ( + view: EditorView, + completion: Completion, + from: number, + to: number, + ): void => { + const insertValue = + typeof completion.apply === 'string' ? completion.apply : completion.label; + const newText = `${insertValue} `; + const newPos = from + newText.length; - // Handle empty state when no aggregation options are available - if (options.length === 0) { - return { - from: context.pos, - options: [ - { - label: - 'No aggregation functions available. Please add aggregation functions first.', - type: 'text', - apply: (): boolean => true, - }, - ], - }; - } + view.dispatch({ + changes: { from, to, insert: newText }, + selection: { anchor: newPos, head: newPos }, + effects: EditorView.scrollIntoView(newPos), + }); + }; - // Show value suggestions after operator - this should take precedence - if (isAfterOperator(tokens)) { - return { - from: context.pos, - options: [ - ...commonValues, - { - label: 'Enter a custom number value', - type: 'text', - apply: (): boolean => true, - }, - ], - }; - } + const havingAutocomplete = useMemo(() => { + // Helper functions for applying completions + const forceCompletion = (view: EditorView): void => { + setTimeout(() => { + if (view) { + startCompletion(view); + } + }, 0); + }; - // Suggest key/operator pairs and ( for grouping - if ( - tokens.length === 0 || - conjunctions.some((c) => tokens[tokens.length - 1] === c.value) || - tokens[tokens.length - 1] === '(' - ) { - return { - from: context.pos, - options, - }; - } + const applyValueCompletion = ( + view: EditorView, + completion: Completion, + from: number, + to: number, + ): void => { + applyCompletionWithSpace(view, completion, from, to); + forceCompletion(view); + }; - // Show suggestions when typing - if (tokens.length > 0) { - const lastToken = tokens[tokens.length - 1]; - const filteredOptions = options.filter((opt) => - opt.label.toLowerCase().includes(lastToken.toLowerCase()), - ); - if (filteredOptions.length > 0) { - return { - from: context.pos - lastToken.length, - options: filteredOptions, - }; - } - } + const applyOperatorCompletion = ( + view: EditorView, + completion: Completion, + from: number, + to: number, + ): void => { + const insertValue = + typeof completion.apply === 'string' ? completion.apply : completion.label; + const insertWithSpace = `${insertValue} `; + view.dispatch({ + changes: { from, to, insert: insertWithSpace }, + selection: { anchor: from + insertWithSpace.length }, + }); + forceCompletion(view); + }; - // Suggest ) for grouping after a value and a space, if there are unmatched ( - if ( - tokens.length > 0 && - isNumber(tokens[tokens.length - 1]) && - text.endsWith(' ') - ) { - return { - from: context.pos, - options: conjunctions, - }; - } + return autocompletion({ + override: [ + (context: CompletionContext): CompletionResult | null => { + const text = context.state.sliceDoc(0, context.pos); + const trimmedText = text.trim(); + const tokens = trimmedText.split(/\s+/).filter(Boolean); - // Suggest conjunctions after a closing parenthesis and a space - if ( - tokens.length > 0 && - tokens[tokens.length - 1] === ')' && - text.endsWith(' ') - ) { - return { - from: context.pos, - options: conjunctions, - }; - } - - // Show all options if no other condition matches + // Handle empty state when no aggregation options are available + if (options.length === 0) { return { from: context.pos, - options, + options: [ + { + label: + 'No aggregation functions available. Please add aggregation functions first.', + type: 'text', + apply: (): boolean => true, + }, + ], }; - }, - ], - defaultKeymap: true, - closeOnBlur: true, - maxRenderedOptions: 200, - activateOnTyping: true, - }), - [options], - ); + } + + // Show value suggestions after operator + if (isAfterOperator(tokens)) { + return { + from: context.pos, + options: [ + ...commonValues.map((value) => ({ + ...value, + apply: applyValueCompletion, + })), + { + label: 'Enter a custom number value', + type: 'text', + apply: applyValueCompletion, + }, + ], + }; + } + + // Suggest key/operator pairs and ( for grouping + if ( + tokens.length === 0 || + conjunctions.some((c) => tokens[tokens.length - 1] === c.value.trim()) || + tokens[tokens.length - 1] === '(' + ) { + return { + from: context.pos, + options: options.map((opt) => ({ + ...opt, + apply: applyOperatorCompletion, + })), + }; + } + + // Show suggestions when typing + if (tokens.length > 0) { + const lastToken = tokens[tokens.length - 1]; + const filteredOptions = options.filter((opt) => + opt.label.toLowerCase().includes(lastToken.toLowerCase()), + ); + if (filteredOptions.length > 0) { + return { + from: context.pos - lastToken.length, + options: filteredOptions.map((opt) => ({ + ...opt, + apply: applyOperatorCompletion, + })), + }; + } + } + + // Suggest conjunctions after a value and a space + if ( + tokens.length > 0 && + (isNumber(tokens[tokens.length - 1]) || + tokens[tokens.length - 1] === ')') && + text.endsWith(' ') + ) { + return { + from: context.pos, + options: conjunctions.map((conj) => ({ + ...conj, + apply: applyValueCompletion, + })), + }; + } + + // Show all options if no other condition matches + return { + from: context.pos, + options: options.map((opt) => ({ + ...opt, + apply: applyOperatorCompletion, + })), + }; + }, + ], + defaultKeymap: true, + closeOnBlur: true, + maxRenderedOptions: 200, + activateOnTyping: true, + }); + }, [options]); return (
diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/QueryAddOns.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/QueryAddOns.tsx index 6543a66bc63c..229e27567454 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/QueryAddOns.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/QueryAddOns.tsx @@ -260,6 +260,7 @@ function QueryAddOns({ query={query} onChange={handleChangeOrderByKeys} isListViewPanel={isListViewPanel} + isNewQueryV2 />
{!isListViewPanel && ( diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss index be11668a26e8..b67e199872fa 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregation.styles.scss @@ -94,11 +94,11 @@ border-radius: 2px !important; font-size: 12px !important; font-weight: 500 !important; - margin-top: -2px !important; + margin-top: 8px !important; min-width: 400px !important; position: absolute !important; - top: 38px !important; left: 0px !important; + width: 100% !important; border-radius: 4px; border: 1px solid var(--bg-slate-200, #1d212d); diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx index e2eb62bc2b76..b84032df42e3 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx @@ -165,9 +165,15 @@ function QueryAggregationSelect({ .split(',') .map((arg) => arg.trim()) .filter((arg) => arg.length > 0); - args.forEach((arg) => { - pairs.push({ func, arg }); - }); + + if (args.length === 0) { + // For functions with no arguments, add a pair with empty string as arg + pairs.push({ func, arg: '' }); + } else { + args.forEach((arg) => { + pairs.push({ func, arg }); + }); + } } setFunctionArgPairs(pairs); setAggregationOptions(pairs); @@ -261,11 +267,19 @@ function QueryAggregationSelect({ from: number, to: number, ): void => { - const isCount = op.value === TracesAggregatorOperator.COUNT; - const insertText = isCount ? `${op.value}() ` : `${op.value}(`; - const cursorPos = isCount - ? from + op.value.length + 3 // after 'count() ' - : from + op.value.length + 1; // after 'operator(' + const acceptsArgs = operatorArgMeta[op.value]?.acceptsArgs; + + let insertText: string; + let cursorPos: number; + + if (!acceptsArgs) { + insertText = `${op.value}() `; + cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values + } else { + insertText = `${op.value}(`; + cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values + } + view.dispatch({ changes: { from, to, insert: insertText }, selection: { anchor: cursorPos }, @@ -293,10 +307,19 @@ function QueryAggregationSelect({ from: number, to: number, ): void => { - // Insert the selected key followed by ') ' + const text = view.state.sliceDoc(0, from); + const funcName = getFunctionContextAtCursor(text, from); + const multiple = funcName ? operatorArgMeta[funcName]?.multiple : false; + + // Insert the selected key followed by either a comma or closing parenthesis + const insertText = multiple + ? `${completion.label},` + : `${completion.label}) `; + const cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values + view.dispatch({ - changes: { from, to, insert: `${completion.label}) ` }, - selection: { anchor: from + completion.label.length + 2 }, // Position cursor after ') ' + changes: { from, to, insert: insertText }, + selection: { anchor: cursorPos }, }); // Trigger next suggestions after a small delay diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx index 026cf045a989..97eb3dd9b2d0 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx @@ -140,9 +140,10 @@ function QuerySearch({ }; useEffect(() => { + setKeySuggestions([]); fetchKeySuggestions(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dataSource]); // Add a state for tracking editing mode const [editingMode, setEditingMode] = useState< diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx index d3584d761d29..eeb7f580900d 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx @@ -8,11 +8,16 @@ import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearch import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { Copy, Ellipsis, Trash } from 'lucide-react'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { HandleChangeQueryDataV5 } from 'types/common/operations.types'; import { DataSource } from 'types/common/queryBuilder'; +import { + convertAggregationToExpression, + convertFiltersToExpression, + convertHavingToExpression, +} from '../utils'; import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection'; import { MetricsSelect } from './MetricsSelect/MetricsSelect'; import QueryAddOns from './QueryAddOns/QueryAddOns'; @@ -49,6 +54,44 @@ export const QueryV2 = memo(function QueryV2({ entityVersion: version, }); + // Convert old format to new format and update query when component mounts or query changes + const performQueryConversions = useCallback(() => { + // Convert filters if needed + if (query.filters?.items?.length > 0 && !query.filter?.expression) { + const convertedFilter = convertFiltersToExpression(query.filters); + handleChangeQueryData('filter', convertedFilter); + } + + // Convert having if needed + if (query.having?.length > 0 && !query.havingExpression?.expression) { + const convertedHaving = convertHavingToExpression(query.having); + handleChangeQueryData('havingExpression', convertedHaving); + } + + // Convert aggregation if needed + if (!query.aggregations && query.aggregateOperator) { + const convertedAggregation = convertAggregationToExpression( + query.aggregateOperator, + query.aggregateAttribute, + query.dataSource, + query.timeAggregation, + query.spaceAggregation, + ) as any; // Type assertion to handle union type + handleChangeQueryData('aggregations', convertedAggregation); + } + }, [query, handleChangeQueryData]); + + useEffect(() => { + const needsConversion = + (query.filters?.items?.length > 0 && !query.filter?.expression) || + (query.having?.length > 0 && !query.havingExpression?.expression) || + (!query.aggregations && query.aggregateOperator); + + if (needsConversion) { + performQueryConversions(); + } + }, [performQueryConversions, query]); + const handleToggleDisableQuery = useCallback(() => { handleChangeQueryData('disabled', !query.disabled); }, [handleChangeQueryData, query]); @@ -176,6 +219,7 @@ export const QueryV2 = memo(function QueryV2({
)} diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts new file mode 100644 index 000000000000..5b332323c23a --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -0,0 +1,185 @@ +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Having, TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { + LogAggregation, + MetricAggregation, + TraceAggregation, +} from 'types/api/v5/queryRange'; +import { DataSource } from 'types/common/queryBuilder'; + +/** + * Check if an operator requires array values (like IN, NOT IN) + * @param operator - The operator to check + * @returns True if the operator requires array values + */ +const isArrayOperator = (operator: string): boolean => { + const arrayOperators = ['in', 'nin', 'IN', 'NOT IN']; + return arrayOperators.includes(operator); +}; + +/** + * Format a value for the expression string + * @param value - The value to format + * @param operator - The operator being used (to determine if array is needed) + * @returns Formatted value string + */ +const formatValueForExpression = ( + value: string[] | string | number | boolean, + operator?: string, +): string => { + // For IN operators, ensure value is always an array + if (isArrayOperator(operator || '')) { + const arrayValue = Array.isArray(value) ? value : [value]; + return `[${arrayValue + .map((v) => + typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v), + ) + .join(', ')}]`; + } + + if (Array.isArray(value)) { + // Handle array values (e.g., for IN operations) + return `[${value + .map((v) => + typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v), + ) + .join(', ')}]`; + } + + if (typeof value === 'string') { + // Add single quotes around all string values and escape internal single quotes + return `'${value.replace(/'/g, "\\'")}'`; + } + + return String(value); +}; + +export const convertFiltersToExpression = ( + filters: TagFilter, +): { expression: string } => { + if (!filters?.items || filters.items.length === 0) { + return { expression: '' }; + } + + const expressions = filters.items + .map((filter) => { + const { key, op, value } = filter; + + // Skip if key is not defined + if (!key?.key) { + return ''; + } + + const formattedValue = formatValueForExpression(value, op); + return `${key.key} ${op} ${formattedValue}`; + }) + .filter((expression) => expression !== ''); // Remove empty expressions + + return { + expression: expressions.join(' AND '), + }; +}; + +/** + * Convert old having format to new having format + * @param having - Array of old having objects with columnName, op, and value + * @returns New having format with expression string + */ +export const convertHavingToExpression = ( + having: Having[], +): { expression: string } => { + if (!having || having.length === 0) { + return { expression: '' }; + } + + const expressions = having + .map((havingItem) => { + const { columnName, op, value } = havingItem; + + // Skip if columnName is not defined + if (!columnName) { + return ''; + } + + // Format value based on its type + let formattedValue: string; + if (Array.isArray(value)) { + // For array values, format as [val1, val2, ...] + formattedValue = `[${value.join(', ')}]`; + } else { + // For single values, just convert to string + formattedValue = String(value); + } + + return `${columnName} ${op} ${formattedValue}`; + }) + .filter((expression) => expression !== ''); // Remove empty expressions + + return { + expression: expressions.join(' AND '), + }; +}; + +/** + * Convert old aggregation format to new aggregation format + * @param aggregateOperator - The aggregate operator (e.g., 'sum', 'count', 'avg') + * @param aggregateAttribute - The attribute to aggregate + * @param dataSource - The data source type + * @param timeAggregation - Time aggregation for metrics (optional) + * @param spaceAggregation - Space aggregation for metrics (optional) + * @param alias - Optional alias for the aggregation + * @returns New aggregation format based on data source + * + */ +export const convertAggregationToExpression = ( + aggregateOperator: string, + aggregateAttribute: BaseAutocompleteData, + dataSource: DataSource, + timeAggregation?: string, + spaceAggregation?: string, + alias?: string, +): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => { + // Skip if no operator or attribute key + if (!aggregateOperator) { + return undefined; + } + + // Replace noop with count as default + const normalizedOperator = + aggregateOperator === 'noop' ? 'count' : aggregateOperator; + const normalizedTimeAggregation = + timeAggregation === 'noop' ? 'count' : timeAggregation; + const normalizedSpaceAggregation = + spaceAggregation === 'noop' ? 'count' : spaceAggregation; + + // For metrics, use the MetricAggregation format + if (dataSource === DataSource.METRICS) { + return [ + { + metricName: aggregateAttribute.key, + timeAggregation: (normalizedTimeAggregation || normalizedOperator) as any, + spaceAggregation: (normalizedSpaceAggregation || normalizedOperator) as any, + } as MetricAggregation, + ]; + } + + // For traces and logs, use expression format + const expression = `${normalizedOperator}(${aggregateAttribute.key})`; + + if (dataSource === DataSource.TRACES) { + return [ + { + expression, + ...(alias && { alias }), + } as TraceAggregation, + ]; + } + + // For logs + return [ + { + expression, + ...(alias && { alias }), + } as LogAggregation, + ]; +}; diff --git a/frontend/src/constants/query.ts b/frontend/src/constants/query.ts index 9460602aec3c..5fca029ac085 100644 --- a/frontend/src/constants/query.ts +++ b/frontend/src/constants/query.ts @@ -47,4 +47,5 @@ export enum QueryParams { destination = 'destination', kindString = 'kindString', tab = 'tab', + selectedExplorerView = 'selectedExplorerView', } diff --git a/frontend/src/container/LogExplorerQuerySection/index.tsx b/frontend/src/container/LogExplorerQuerySection/index.tsx index b491af6d6a83..a0247ac1e68f 100644 --- a/frontend/src/container/LogExplorerQuerySection/index.tsx +++ b/frontend/src/container/LogExplorerQuerySection/index.tsx @@ -12,10 +12,7 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; -import { - ExplorerViews, - prepareQueryWithDefaultTimestamp, -} from 'pages/LogsExplorer/utils'; +import { ExplorerViews } from 'pages/LogsExplorer/utils'; import { memo, useCallback, useMemo } from 'react'; import { DataSource } from 'types/common/queryBuilder'; @@ -27,14 +24,15 @@ function LogExplorerQuerySection({ const { updateAllQueriesOperators } = useQueryBuilder(); const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST); - const defaultValue = useMemo(() => { - const updatedQuery = updateAllQueriesOperators( - initialQueriesMap.logs, - PANEL_TYPES.LIST, - DataSource.LOGS, - ); - return prepareQueryWithDefaultTimestamp(updatedQuery); - }, [updateAllQueriesOperators]); + const defaultValue = useMemo( + () => + updateAllQueriesOperators( + initialQueriesMap.logs, + PANEL_TYPES.LIST, + DataSource.LOGS, + ), + [updateAllQueriesOperators], + ); useShareBuilderUrl(defaultValue); diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index 757e211fd7cf..461121688ca4 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -138,7 +138,7 @@ function LogsExplorerViewsContainer({ const [queryStats, setQueryStats] = useState(); const [listChartQuery, setListChartQuery] = useState(null); - const [orderDirection, setOrderDirection] = useState('asc'); + const [orderDirection, setOrderDirection] = useState('desc'); const listQuery = useMemo(() => { if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null; @@ -331,14 +331,26 @@ function LogsExplorerViewsContainer({ }; } + // Create orderBy array based on orderDirection + const orderBy = [ + { columnName: 'timestamp', order: orderDirection }, + { columnName: 'id', order: orderDirection }, + ]; + const queryData: IBuilderQuery[] = query.builder.queryData.length > 1 - ? query.builder.queryData + ? query.builder.queryData.map((item) => ({ + ...item, + ...(selectedPanelType !== PANEL_TYPES.LIST ? { order: [] } : {}), + })) : [ { ...(listQuery || initialQueryBuilderFormValues), ...paginateData, ...(updatedFilters ? { filters: updatedFilters } : {}), + ...(selectedPanelType === PANEL_TYPES.LIST + ? { order: orderBy } + : { order: [] }), }, ]; @@ -352,7 +364,7 @@ function LogsExplorerViewsContainer({ return data; }, - [listQuery, activeLogId], + [activeLogId, orderDirection, listQuery, selectedPanelType], ); const handleEndReached = useCallback(() => { @@ -495,10 +507,19 @@ function LogsExplorerViewsContainer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); + // Store previous orderDirection to detect changes + const prevOrderDirectionRef = useRef(orderDirection); + useEffect(() => { + const orderDirectionChanged = + prevOrderDirectionRef.current !== orderDirection && + selectedPanelType === PANEL_TYPES.LIST; + prevOrderDirectionRef.current = orderDirection; + if ( requestData?.id !== stagedQuery?.id || - currentMinTimeRef.current !== minTime + currentMinTimeRef.current !== minTime || + orderDirectionChanged ) { // Recalculate global time when query changes i.e. stage and run query clicked if ( @@ -534,6 +555,8 @@ function LogsExplorerViewsContainer({ dispatch, selectedTime, maxTime, + orderDirection, + selectedPanelType, ]); const chartData = useMemo(() => { diff --git a/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx b/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx index 42eb125c08fc..fa6042ded173 100644 --- a/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx +++ b/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx @@ -55,6 +55,16 @@ function Explorer(): JSX.Element { }); }; + const defaultQuery = useMemo( + () => + updateAllQueriesOperators( + initialQueriesMap[DataSource.METRICS], + PANEL_TYPES.TIME_SERIES, + DataSource.METRICS, + ), + [updateAllQueriesOperators], + ); + const exportDefaultQuery = useMemo( () => updateAllQueriesOperators( @@ -65,7 +75,7 @@ function Explorer(): JSX.Element { [currentQuery, updateAllQueriesOperators], ); - useShareBuilderUrl(exportDefaultQuery); + useShareBuilderUrl(defaultQuery); const handleExport = useCallback( ( @@ -132,7 +142,6 @@ function Explorer(): JSX.Element { queryComponents={queryComponents} showFunctions={false} version="v3" - isListViewPanel /> {/* TODO: Enable once we have resolved all related metrics issues */} {/* diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces.ts b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces.ts index 323531f6f1a7..ce8f4a4bd900 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces.ts +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces.ts @@ -8,6 +8,7 @@ export type OrderByFilterProps = { onChange: (values: OrderByPayload[]) => void; isListViewPanel?: boolean; entityVersion?: string; + isNewQueryV2?: boolean; }; export type OrderByFilterValue = { diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx index 32aefe490a8d..e824bda2424a 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx @@ -2,6 +2,7 @@ import { Select, Spin } from 'antd'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { useMemo } from 'react'; import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder'; +import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter'; import { popupContainer } from 'utils/selectPopupContainer'; import { selectStyle } from '../QueryBuilderSearch/config'; @@ -13,6 +14,7 @@ export function OrderByFilter({ onChange, isListViewPanel = false, entityVersion, + isNewQueryV2 = false, }: OrderByFilterProps): JSX.Element { const { debouncedSearchText, @@ -37,22 +39,35 @@ export function OrderByFilter({ }, ); + // Get parsed aggregation options using createAggregation only for QueryV2 + const parsedAggregationOptions = useMemo( + () => (isNewQueryV2 ? getParsedAggregationOptionsForOrderBy(query) : []), + [query, isNewQueryV2], + ); + const optionsData = useMemo(() => { const keyOptions = createOptions(data?.payload?.attributeKeys || []); const groupByOptions = createOptions(query.groupBy); + const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions); + const options = query.aggregateOperator === MetricAggregateOperator.NOOP ? keyOptions - : [...groupByOptions, ...aggregationOptions]; + : [ + ...groupByOptions, + ...(isNewQueryV2 ? aggregationOptionsFromParsed : aggregationOptions), + ]; return generateOptions(options); }, [ - aggregationOptions, createOptions, data?.payload?.attributeKeys, - generateOptions, - query.aggregateOperator, query.groupBy, + query.aggregateOperator, + parsedAggregationOptions, + aggregationOptions, + generateOptions, + isNewQueryV2, ]); const isDisabledSelect = diff --git a/frontend/src/container/TracesExplorer/QuerySection/index.tsx b/frontend/src/container/TracesExplorer/QuerySection/index.tsx index b9b90d2d1023..96f11fd77849 100644 --- a/frontend/src/container/TracesExplorer/QuerySection/index.tsx +++ b/frontend/src/container/TracesExplorer/QuerySection/index.tsx @@ -39,7 +39,9 @@ function QuerySection(): JSX.Element { return ( { - console.log('hook - type', type); - const newPanelType = type as PANEL_TYPES; if (newPanelType === panelType && !currentQueryData) return; diff --git a/frontend/src/lib/dashboard/getQueryResults.ts b/frontend/src/lib/dashboard/getQueryResults.ts index 08366c888229..382d062459fd 100644 --- a/frontend/src/lib/dashboard/getQueryResults.ts +++ b/frontend/src/lib/dashboard/getQueryResults.ts @@ -22,9 +22,40 @@ import { isEmpty } from 'lodash-es'; import { SuccessResponse } from 'types/api'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; import { prepareQueryRangePayload } from './prepareQueryRangePayload'; +/** + * Validates if metric name is available for METRICS data source + */ +function validateMetricNameForMetricsDataSource(query: Query): boolean { + if (query.queryType !== 'builder') { + return true; // Non-builder queries don't need this validation + } + + const { queryData } = query.builder; + + // Check if any METRICS data source queries exist + const metricsQueries = queryData.filter( + (queryItem) => queryItem.dataSource === DataSource.METRICS, + ); + + // If no METRICS queries, validation passes + if (metricsQueries.length === 0) { + return true; + } + + // Check if ALL METRICS queries are missing metric names + const allMetricsQueriesMissingNames = metricsQueries.every((queryItem) => { + const metricName = queryItem.aggregateAttribute?.key; + return !metricName || metricName.trim() === ''; + }); + + // Return false only if ALL METRICS queries are missing metric names + return !allMetricsQueriesMissingNames; +} + export async function GetMetricQueryRange( props: GetQueryResultsProps, version: string, @@ -35,6 +66,32 @@ export async function GetMetricQueryRange( let legendMap: Record; let response: SuccessResponse; + // Validate metric name for METRICS data source before making the API call + if ( + version === ENTITY_VERSION_V5 && + !validateMetricNameForMetricsDataSource(props.query) + ) { + // Return empty response to avoid 400 error when metric name is missing + return { + statusCode: 200, + error: null, + message: 'Metric name is required for metrics data source', + payload: { + data: { + result: [], + resultType: '', + newResult: { + data: { + result: [], + resultType: '', + }, + }, + }, + }, + params: props, + }; + } + if (version === ENTITY_VERSION_V5) { const v5Result = prepareQueryRangePayloadV5(props); legendMap = v5Result.legendMap; diff --git a/frontend/src/lib/query/createTableColumnsFromQuery.ts b/frontend/src/lib/query/createTableColumnsFromQuery.ts index ff5ee7a9ad82..b2b367a35590 100644 --- a/frontend/src/lib/query/createTableColumnsFromQuery.ts +++ b/frontend/src/lib/query/createTableColumnsFromQuery.ts @@ -258,17 +258,78 @@ const transformColumnTitles = ( return item; }); +const processTableColumns = ( + table: NonNullable, + currentStagedQuery: + | IBuilderQuery + | IBuilderFormula + | IClickHouseQuery + | IPromQLQuery, + dynamicColumns: DynamicColumns, + queryType: EQueryType, +): void => { + table.columns.forEach((column) => { + if (column.isValueColumn) { + // For value columns, add as operator/formula column + addOperatorFormulaColumns( + currentStagedQuery, + dynamicColumns, + queryType, + column.name, + ); + } else { + // For non-value columns, add as field/label column + addLabels(currentStagedQuery, column.name, dynamicColumns); + } + }); +}; + +const processSeriesColumns = ( + series: NonNullable, + currentStagedQuery: + | IBuilderQuery + | IBuilderFormula + | IClickHouseQuery + | IPromQLQuery, + dynamicColumns: DynamicColumns, + queryType: EQueryType, + currentQuery: QueryDataV3, +): void => { + const isValuesColumnExist = series.some((item) => item.values.length > 0); + const isEveryValuesExist = series.every((item) => item.values.length > 0); + + if (isValuesColumnExist) { + addOperatorFormulaColumns( + currentStagedQuery, + dynamicColumns, + queryType, + isEveryValuesExist ? undefined : get(currentStagedQuery, 'queryName', ''), + ); + } + + series.forEach((seria) => { + seria.labelsArray?.forEach((lab) => { + Object.keys(lab).forEach((label) => { + if (label === currentQuery?.queryName) return; + + addLabels(currentStagedQuery, label, dynamicColumns); + }); + }); + }); +}; + const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => { const dynamicColumns: DynamicColumns = []; queryTableData.forEach((currentQuery) => { - const { series, queryName, list } = currentQuery; + const { series, queryName, list, table } = currentQuery; const currentStagedQuery = getQueryByName( query, queryName, isFormula(queryName) ? 'queryFormulas' : 'queryData', ); + if (list) { list.forEach((listItem) => { Object.keys(listItem.data).forEach((label) => { @@ -277,28 +338,23 @@ const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => { }); } + if (table) { + processTableColumns( + table, + currentStagedQuery, + dynamicColumns, + query.queryType, + ); + } + if (series) { - const isValuesColumnExist = series.some((item) => item.values.length > 0); - const isEveryValuesExist = series.every((item) => item.values.length > 0); - - if (isValuesColumnExist) { - addOperatorFormulaColumns( - currentStagedQuery, - dynamicColumns, - query.queryType, - isEveryValuesExist ? undefined : get(currentStagedQuery, 'queryName', ''), - ); - } - - series.forEach((seria) => { - seria.labelsArray?.forEach((lab) => { - Object.keys(lab).forEach((label) => { - if (label === currentQuery?.queryName) return; - - addLabels(currentStagedQuery, label, dynamicColumns); - }); - }); - }); + processSeriesColumns( + series, + currentStagedQuery, + dynamicColumns, + query.queryType, + currentQuery, + ); } }); @@ -474,6 +530,56 @@ const fillDataFromList = ( }); }; +const processTableRowValue = (value: any, column: DynamicColumn): void => { + if (value !== null && value !== undefined && value !== '') { + if (isObject(value)) { + column.data.push(JSON.stringify(value)); + } else if (typeof value === 'number' || !isNaN(Number(value))) { + column.data.push(Number(value)); + } else { + column.data.push(value.toString()); + } + } else { + column.data.push('N/A'); + } +}; + +const fillDataFromTable = ( + currentQuery: QueryDataV3, + columns: DynamicColumns, +): void => { + const { table } = currentQuery; + + if (!table || !table.rows) return; + + table.rows.forEach((row) => { + const unusedColumnsKeys = new Set( + columns.map((item) => item.field), + ); + + columns.forEach((column) => { + const rowData = row.data; + + if (Object.prototype.hasOwnProperty.call(rowData, column.field)) { + const value = rowData[column.field]; + processTableRowValue(value, column); + unusedColumnsKeys.delete(column.field); + } else { + column.data.push('N/A'); + unusedColumnsKeys.delete(column.field); + } + }); + + // Fill any remaining unused columns with N/A + unusedColumnsKeys.forEach((key) => { + const unusedCol = columns.find((item) => item.field === key); + if (unusedCol) { + unusedCol.data.push('N/A'); + } + }); + }); +}; + const fillColumnsData: FillColumnData = (queryTableData, cols) => { const fields = cols.filter((item) => item.type === 'field'); const operators = cols.filter((item) => item.type === 'operator'); @@ -497,6 +603,8 @@ const fillColumnsData: FillColumnData = (queryTableData, cols) => { fillDataFromList(listItem, resultColumns); }); } + + fillDataFromTable(currentQuery, resultColumns); }); const rowsLength = resultColumns.length > 0 ? resultColumns[0].data.length : 0; diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index ad7939933fb7..44fbce891248 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -8,6 +8,7 @@ import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; import QuickFilters from 'components/QuickFilters/QuickFilters'; import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import { LOCALSTORAGE } from 'constants/localStorage'; +import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViewsContainer from 'container/LogsExplorerViews'; @@ -20,6 +21,7 @@ import { OptionsQuery } from 'container/OptionsMenu/types'; import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions'; import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; import Toolbar from 'container/Toolbar/Toolbar'; +import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; import useUrlQueryData from 'hooks/useUrlQueryData'; @@ -27,14 +29,24 @@ import { isEqual, isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataSource } from 'types/common/queryBuilder'; +import { + getExplorerViewForPanelType, + getExplorerViewFromUrl, +} from 'utils/explorerUtils'; import { ExplorerViews } from './utils'; function LogsExplorer(): JSX.Element { - const [selectedView, setSelectedView] = useState( - ExplorerViews.LIST, + const [searchParams] = useSearchParams(); + + // Get panel type from URL + const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST); + + const [selectedView, setSelectedView] = useState(() => + getExplorerViewFromUrl(searchParams, panelTypesFromUrl), ); const { preferences, loading: preferencesLoading } = usePreferenceContext(); @@ -48,6 +60,23 @@ function LogsExplorer(): JSX.Element { return true; }); + // Update selected view when panel type from URL changes + useEffect(() => { + if (panelTypesFromUrl) { + const newView = getExplorerViewForPanelType(panelTypesFromUrl); + if (newView && newView !== selectedView) { + setSelectedView(newView); + } + } + }, [panelTypesFromUrl, selectedView]); + + // Update URL when selectedView changes (without triggering re-renders) + useEffect(() => { + const url = new URL(window.location.href); + url.searchParams.set(QueryParams.selectedExplorerView, selectedView); + window.history.replaceState({}, '', url.toString()); + }, [selectedView]); + const { handleRunQuery, handleSetConfig } = useQueryBuilder(); const { handleExplorerTabChange } = useHandleExplorerTabChange(); diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index 3297bdc8ead5..26aa28396fb3 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -9,6 +9,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters'; import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import { LOCALSTORAGE } from 'constants/localStorage'; import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes'; +import { QueryParams } from 'constants/query'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper'; import ExportPanel from 'container/ExportPanel'; @@ -22,6 +23,7 @@ import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/config import QuerySection from 'container/TracesExplorer/QuerySection'; import TableView from 'container/TracesExplorer/TableView'; import TracesView from 'container/TracesExplorer/TracesView'; +import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; @@ -30,10 +32,15 @@ import { cloneDeep, isEmpty, set } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { ExplorerViews } from 'pages/LogsExplorer/utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { Dashboard } from 'types/api/dashboard/getAll'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; +import { + getExplorerViewForPanelType, + getExplorerViewFromUrl, +} from 'utils/explorerUtils'; import { v4 } from 'uuid'; function TracesExplorer(): JSX.Element { @@ -55,13 +62,36 @@ function TracesExplorer(): JSX.Element { }, }); - const [selectedView, setSelectedView] = useState( - ExplorerViews.LIST, + const [searchParams, setSearchParams] = useSearchParams(); + + // Get panel type from URL + const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST); + + const [selectedView, setSelectedView] = useState(() => + getExplorerViewFromUrl(searchParams, panelTypesFromUrl), ); const { handleExplorerTabChange } = useHandleExplorerTabChange(); const { safeNavigate } = useSafeNavigate(); + // Update selected view when panel type from URL changes + useEffect(() => { + if (panelTypesFromUrl) { + const newView = getExplorerViewForPanelType(panelTypesFromUrl); + if (newView && newView !== selectedView) { + setSelectedView(newView); + } + } + }, [panelTypesFromUrl, selectedView]); + + // Update URL when selectedView changes + useEffect(() => { + setSearchParams((prev: URLSearchParams) => { + prev.set(QueryParams.selectedExplorerView, selectedView); + return prev; + }); + }, [selectedView, setSearchParams]); + const handleChangeSelectedView = useCallback( (view: ExplorerViews): void => { if (selectedView === ExplorerViews.LIST) { @@ -98,26 +128,15 @@ function TracesExplorer(): JSX.Element { return groupByCount > 0; }, [currentQuery]); - const defaultQuery = useMemo(() => { - const query = updateAllQueriesOperators( - initialQueriesMap.traces, - PANEL_TYPES.LIST, - DataSource.TRACES, - ); - - return { - ...query, - builder: { - ...query.builder, - queryData: [ - { - ...query.builder.queryData[0], - orderBy: [{ columnName: 'timestamp', order: 'desc' }], - }, - ], - }, - }; - }, [updateAllQueriesOperators]); + const defaultQuery = useMemo( + () => + updateAllQueriesOperators( + initialQueriesMap.traces, + PANEL_TYPES.LIST, + DataSource.TRACES, + ), + [updateAllQueriesOperators], + ); const exportDefaultQuery = useMemo( () => diff --git a/frontend/src/utils/aggregationConverter.ts b/frontend/src/utils/aggregationConverter.ts new file mode 100644 index 000000000000..a07c09828cf9 --- /dev/null +++ b/frontend/src/utils/aggregationConverter.ts @@ -0,0 +1,139 @@ +import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + LogAggregation, + MetricAggregation, + TraceAggregation, +} from 'types/api/v5/queryRange'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +/** + * Converts QueryV2 aggregations to BaseAutocompleteData format + * for compatibility with existing OrderByFilter component + */ +export function convertAggregationsToBaseAutocompleteData( + aggregations: + | TraceAggregation[] + | LogAggregation[] + | MetricAggregation[] + | undefined, + dataSource: DataSource, + metricName?: string, + spaceAggregation?: string, +): BaseAutocompleteData[] { + // If no aggregations provided, return default based on data source + if (!aggregations || aggregations.length === 0) { + switch (dataSource) { + case DataSource.METRICS: + return [ + { + id: uuid(), + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + key: `${spaceAggregation || 'avg'}(${metricName || 'metric'})`, + }, + ]; + case DataSource.TRACES: + case DataSource.LOGS: + default: + return [ + { + id: uuid(), + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + key: 'count()', + }, + ]; + } + } + + return aggregations.map((agg) => { + if ('expression' in agg) { + // TraceAggregation or LogAggregation + const { expression } = agg; + const alias = 'alias' in agg ? agg.alias : ''; + const displayKey = alias || expression; + + return { + id: uuid(), + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + key: displayKey, + }; + } + // MetricAggregation + const { + metricName: aggMetricName, + spaceAggregation: aggSpaceAggregation, + } = agg; + const displayKey = `${aggSpaceAggregation}(${aggMetricName})`; + + return { + id: uuid(), + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + key: displayKey, + }; + }); +} + +/** + * Helper function to get aggregation options for OrderByFilter + * This creates BaseAutocompleteData that can be used with the existing OrderByFilter + */ +export function getAggregationOptionsForOrderBy(query: { + aggregations?: TraceAggregation[] | LogAggregation[] | MetricAggregation[]; + dataSource: DataSource; + aggregateAttribute?: { key: string }; + spaceAggregation?: string; +}): BaseAutocompleteData[] { + const { + aggregations, + dataSource, + aggregateAttribute, + spaceAggregation, + } = query; + + return convertAggregationsToBaseAutocompleteData( + aggregations, + dataSource, + aggregateAttribute?.key, + spaceAggregation, + ); +} + +/** + * Enhanced function that uses createAggregation to parse aggregations first + * then converts them to BaseAutocompleteData format for OrderByFilter + */ +export function getParsedAggregationOptionsForOrderBy(query: { + aggregations?: TraceAggregation[] | LogAggregation[] | MetricAggregation[]; + dataSource: DataSource; + aggregateAttribute?: { key: string }; + spaceAggregation?: string; + timeAggregation?: string; + temporality?: string; +}): BaseAutocompleteData[] { + // First, use createAggregation to parse the aggregations + const parsedAggregations = createAggregation(query); + + // Then convert the parsed aggregations to BaseAutocompleteData format + return convertAggregationsToBaseAutocompleteData( + parsedAggregations, + query.dataSource, + query.aggregateAttribute?.key, + query.spaceAggregation, + ); +} diff --git a/frontend/src/utils/explorerUtils.ts b/frontend/src/utils/explorerUtils.ts new file mode 100644 index 000000000000..a897e2819248 --- /dev/null +++ b/frontend/src/utils/explorerUtils.ts @@ -0,0 +1,45 @@ +import { QueryParams } from 'constants/query'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { ExplorerViews } from 'pages/LogsExplorer/utils'; + +// Mapping between panel types and explorer views +export const panelTypeToExplorerView: Record = { + [PANEL_TYPES.LIST]: ExplorerViews.LIST, + [PANEL_TYPES.TIME_SERIES]: ExplorerViews.TIMESERIES, + [PANEL_TYPES.TRACE]: ExplorerViews.TRACE, + [PANEL_TYPES.TABLE]: ExplorerViews.TABLE, + [PANEL_TYPES.VALUE]: ExplorerViews.TIMESERIES, + [PANEL_TYPES.BAR]: ExplorerViews.TIMESERIES, + [PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES, + [PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES, + [PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST, +}; + +/** + * Get the explorer view based on panel type from URL or saved view + * @param searchParams - URL search parameters + * @param panelTypesFromUrl - Panel type extracted from URL + * @returns The appropriate ExplorerViews value + */ +export const getExplorerViewFromUrl = ( + searchParams: URLSearchParams, + panelTypesFromUrl: PANEL_TYPES | null, +): ExplorerViews => { + const savedView = searchParams.get(QueryParams.selectedExplorerView); + if (savedView) { + return savedView as ExplorerViews; + } + + // If no saved view, use panel type from URL to determine the view + const urlPanelType = panelTypesFromUrl || PANEL_TYPES.LIST; + return panelTypeToExplorerView[urlPanelType]; +}; + +/** + * Get the explorer view for a given panel type + * @param panelType - The panel type + * @returns The corresponding ExplorerViews value + */ +export const getExplorerViewForPanelType = ( + panelType: PANEL_TYPES, +): ExplorerViews => panelTypeToExplorerView[panelType];