From aa544f52f3bafbe3cdda54fbefd5ad3c8af48685 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 17 Jun 2025 17:57:50 +0530 Subject: [PATCH] feat: query_range migration from v3/v4 -> v5 (#8192) * feat: query_range migration from v3/v4 -> v5 * feat: cleanup files * feat: cleanup code * feat: metric payload improvements * feat: metric payload improvements * feat: data retention and qb v2 for dashboard cleanup * feat: corrected datasource change daata updatation in qb v2 * feat: fix value panel plotting with new query v5 * feat: alert migration * feat: fixed aggregation css * feat: explorer pages migration * feat: trace and logs explorer fixes --- frontend/src/api/apiV1.ts | 1 + frontend/src/api/index.ts | 13 + frontend/src/api/v5/queryRange/constants.ts | 168 ++++++++ .../api/v5/queryRange/convertV5Response.ts | 358 ++++++++++++++++ .../src/api/v5/queryRange/getQueryRange.ts | 51 +++ .../queryRange/prepareQueryRangePayloadV5.ts | 384 +++++++++++++++++ frontend/src/api/v5/v5.ts | 8 + .../QueryAddOns/HavingFilter/HavingFilter.tsx | 24 +- .../QueryV2/QueryAddOns/QueryAddOns.tsx | 52 ++- .../QueryAggregation.styles.scss | 1 + .../QueryAggregation/QueryAggregation.tsx | 10 +- .../QueryAggregationSelect.tsx | 26 +- .../QueryV2/QuerySearch/QuerySearch.tsx | 20 +- .../QueryBuilderV2/QueryV2/QueryV2.tsx | 41 +- frontend/src/constants/app.ts | 1 + .../FormAlertRules/ChartPreview/index.tsx | 5 +- .../GridCard/FullView/index.tsx | 5 +- .../GridCardLayout/GridCard/index.tsx | 5 +- .../src/container/LogsExplorerViews/index.tsx | 8 +- .../MetricsExplorer/Explorer/TimeSeries.tsx | 7 +- .../WidgetGraph/WidgetGraphContainer.tsx | 3 +- .../NewWidget/LeftContainer/index.tsx | 8 +- .../PanelWrapper/ValuePanelWrapper.tsx | 10 +- .../filters/GroupByFilter/utils.ts | 6 +- .../src/container/TimeSeriesView/index.tsx | 5 +- .../TracesExplorer/ListView/index.tsx | 5 +- .../TracesExplorer/TableView/index.tsx | 5 +- .../TracesExplorer/TracesView/index.tsx | 5 +- .../queryBuilder/useQueryBuilderOperations.ts | 9 +- frontend/src/lib/dashboard/getQueryResults.ts | 49 ++- .../api/queryBuilder/queryBuilderData.ts | 10 + frontend/src/types/api/v5/queryRange.ts | 397 ++++++++++++++++++ frontend/src/types/api/widgets/getQuery.ts | 12 + frontend/src/types/common/operations.types.ts | 21 +- 34 files changed, 1658 insertions(+), 75 deletions(-) create mode 100644 frontend/src/api/v5/queryRange/constants.ts create mode 100644 frontend/src/api/v5/queryRange/convertV5Response.ts create mode 100644 frontend/src/api/v5/queryRange/getQueryRange.ts create mode 100644 frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts create mode 100644 frontend/src/api/v5/v5.ts create mode 100644 frontend/src/types/api/v5/queryRange.ts diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index abd7d701a4c8..f7f15f0cce72 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -3,6 +3,7 @@ const apiV1 = '/api/v1/'; export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; export const apiV4 = '/api/v4/'; +export const apiV5 = '/api/v5/'; export const gatewayApiV1 = '/api/gateway/v1/'; export const gatewayApiV2 = '/api/gateway/v2/'; export const apiAlertManager = '/api/alertmanager/'; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index a5e62ae78942..9e78b9022129 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -19,6 +19,7 @@ import apiV1, { apiV2, apiV3, apiV4, + apiV5, gatewayApiV1, gatewayApiV2, } from './apiV1'; @@ -171,6 +172,18 @@ ApiV4Instance.interceptors.response.use( ApiV4Instance.interceptors.request.use(interceptorsRequestResponse); // +// axios V5 +export const ApiV5Instance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiV5}`, +}); + +ApiV5Instance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); +ApiV5Instance.interceptors.request.use(interceptorsRequestResponse); +// + // axios Base export const ApiBaseInstance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, diff --git a/frontend/src/api/v5/queryRange/constants.ts b/frontend/src/api/v5/queryRange/constants.ts new file mode 100644 index 000000000000..8a5ec95f7e2b --- /dev/null +++ b/frontend/src/api/v5/queryRange/constants.ts @@ -0,0 +1,168 @@ +// V5 Query Range Constants + +import { ENTITY_VERSION_V5 } from 'constants/app'; +import { + FunctionName, + RequestType, + SignalType, + Step, +} from 'types/api/v5/queryRange'; + +// ===================== Schema and Version Constants ===================== + +export const SCHEMA_VERSION_V5 = ENTITY_VERSION_V5; +export const API_VERSION_V5 = 'v5'; + +// ===================== Default Values ===================== + +export const DEFAULT_STEP_INTERVAL: Step = '60s'; +export const DEFAULT_LIMIT = 100; +export const DEFAULT_OFFSET = 0; + +// ===================== Request Type Constants ===================== + +export const REQUEST_TYPES: Record = { + SCALAR: 'scalar', + TIME_SERIES: 'time_series', + RAW: 'raw', + DISTRIBUTION: 'distribution', +} as const; + +// ===================== Signal Type Constants ===================== + +export const SIGNAL_TYPES: Record = { + TRACES: 'traces', + LOGS: 'logs', + METRICS: 'metrics', +} as const; + +// ===================== Common Aggregation Expressions ===================== + +export const TRACE_AGGREGATIONS = { + COUNT: 'count()', + COUNT_DISTINCT_TRACE_ID: 'count_distinct(traceID)', + AVG_DURATION: 'avg(duration_nano)', + P50_DURATION: 'p50(duration_nano)', + P95_DURATION: 'p95(duration_nano)', + P99_DURATION: 'p99(duration_nano)', + MAX_DURATION: 'max(duration_nano)', + MIN_DURATION: 'min(duration_nano)', + SUM_DURATION: 'sum(duration_nano)', +} as const; + +export const LOG_AGGREGATIONS = { + COUNT: 'count()', + COUNT_DISTINCT_HOST: 'count_distinct(host.name)', + COUNT_DISTINCT_SERVICE: 'count_distinct(service.name)', + COUNT_DISTINCT_CONTAINER: 'count_distinct(container.name)', +} as const; + +// ===================== Common Filter Expressions ===================== + +export const COMMON_FILTERS = { + // Trace filters + SERVER_SPANS: "kind_string = 'Server'", + CLIENT_SPANS: "kind_string = 'Client'", + INTERNAL_SPANS: "kind_string = 'Internal'", + ERROR_SPANS: 'http.status_code >= 400', + SUCCESS_SPANS: 'http.status_code < 400', + + // Common service filters + EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'", + HTTP_REQUESTS: "http.method != ''", + + // Log filters + ERROR_LOGS: "severity_text = 'ERROR'", + WARN_LOGS: "severity_text = 'WARN'", + INFO_LOGS: "severity_text = 'INFO'", + DEBUG_LOGS: "severity_text = 'DEBUG'", +} as const; + +// ===================== Common Group By Fields ===================== + +export const COMMON_GROUP_BY_FIELDS = { + SERVICE_NAME: { + name: 'service.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, + HTTP_METHOD: { + name: 'http.method', + fieldDataType: 'string' as const, + fieldContext: 'attribute' as const, + }, + HTTP_ROUTE: { + name: 'http.route', + fieldDataType: 'string' as const, + fieldContext: 'attribute' as const, + }, + HTTP_STATUS_CODE: { + name: 'http.status_code', + fieldDataType: 'int64' as const, + fieldContext: 'attribute' as const, + }, + HOST_NAME: { + name: 'host.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, + CONTAINER_NAME: { + name: 'container.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, +} as const; + +// ===================== Function Names ===================== + +export const FUNCTION_NAMES: Record = { + CUT_OFF_MIN: 'cutOffMin', + CUT_OFF_MAX: 'cutOffMax', + CLAMP_MIN: 'clampMin', + CLAMP_MAX: 'clampMax', + ABSOLUTE: 'absolute', + RUNNING_DIFF: 'runningDiff', + LOG2: 'log2', + LOG10: 'log10', + CUM_SUM: 'cumSum', + EWMA3: 'ewma3', + EWMA5: 'ewma5', + EWMA7: 'ewma7', + MEDIAN3: 'median3', + MEDIAN5: 'median5', + MEDIAN7: 'median7', + TIME_SHIFT: 'timeShift', + ANOMALY: 'anomaly', +} as const; + +// ===================== Common Step Intervals ===================== + +export const STEP_INTERVALS = { + FIFTEEN_SECONDS: '15s', + THIRTY_SECONDS: '30s', + ONE_MINUTE: '60s', + FIVE_MINUTES: '300s', + TEN_MINUTES: '600s', + FIFTEEN_MINUTES: '900s', + THIRTY_MINUTES: '1800s', + ONE_HOUR: '3600s', + TWO_HOURS: '7200s', + SIX_HOURS: '21600s', + TWELVE_HOURS: '43200s', + ONE_DAY: '86400s', +} as const; + +// ===================== Time Range Presets ===================== + +export const TIME_RANGE_PRESETS = { + LAST_5_MINUTES: 5 * 60 * 1000, + LAST_15_MINUTES: 15 * 60 * 1000, + LAST_30_MINUTES: 30 * 60 * 1000, + LAST_HOUR: 60 * 60 * 1000, + LAST_3_HOURS: 3 * 60 * 60 * 1000, + LAST_6_HOURS: 6 * 60 * 60 * 1000, + LAST_12_HOURS: 12 * 60 * 60 * 1000, + LAST_24_HOURS: 24 * 60 * 60 * 1000, + LAST_3_DAYS: 3 * 24 * 60 * 60 * 1000, + LAST_7_DAYS: 7 * 24 * 60 * 60 * 1000, +} as const; diff --git a/frontend/src/api/v5/queryRange/convertV5Response.ts b/frontend/src/api/v5/queryRange/convertV5Response.ts new file mode 100644 index 000000000000..479b17fd43b9 --- /dev/null +++ b/frontend/src/api/v5/queryRange/convertV5Response.ts @@ -0,0 +1,358 @@ +import { isEmpty } from 'lodash-es'; +import { SuccessResponse } from 'types/api'; +import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange'; +import { + DistributionData, + MetricRangePayloadV5, + RawData, + ScalarData, + TimeSeriesData, +} from 'types/api/v5/queryRange'; +import { QueryDataV3 } from 'types/api/widgets/getQuery'; + +/** + * Converts V5 TimeSeriesData to legacy format + */ +function convertTimeSeriesData( + timeSeriesData: TimeSeriesData, + legendMap: Record, +): QueryDataV3 { + // Convert V5 time series format to legacy QueryDataV3 format + return { + queryName: timeSeriesData.queryName, + legend: legendMap[timeSeriesData.queryName] || timeSeriesData.queryName, + series: timeSeriesData.aggregations.flatMap((aggregation) => + aggregation.series.map((series) => ({ + labels: series.labels + ? Object.fromEntries( + series.labels.map((label) => [label.key.name, label.value]), + ) + : {}, + labelsArray: series.labels + ? series.labels.map((label) => ({ [label.key.name]: label.value })) + : [], + values: series.values.map((value) => ({ + timestamp: value.timestamp, + value: String(value.value), + })), + })), + ), + list: null, + }; +} + +/** + * Helper function to collect columns from scalar data + */ +function collectColumnsFromScalarData( + scalarData: ScalarData[], +): { name: string; queryName: string; isValueColumn: boolean }[] { + const columnMap = new Map< + string, + { name: string; queryName: string; isValueColumn: boolean } + >(); + + scalarData.forEach((scalar) => { + scalar.columns.forEach((col) => { + if (col.columnType === 'group') { + // For group columns, use the column name as-is + const key = `${col.name}_group`; + if (!columnMap.has(key)) { + columnMap.set(key, { + name: col.name, + queryName: '', // Group columns don't have query names + isValueColumn: false, + }); + } + } else if (col.columnType === 'aggregation') { + // For aggregation columns, use the query name as the column name + const key = `${col.queryName}_aggregation`; + if (!columnMap.has(key)) { + columnMap.set(key, { + name: col.queryName, // Use query name as column name (A, B, etc.) + queryName: col.queryName, + isValueColumn: true, + }); + } + } + }); + }); + + return Array.from(columnMap.values()).sort((a, b) => { + if (a.isValueColumn !== b.isValueColumn) { + return a.isValueColumn ? 1 : -1; + } + return a.name.localeCompare(b.name); + }); +} + +/** + * Helper function to process scalar data rows with unified table structure + */ +function processScalarDataRows( + scalarData: ScalarData[], +): { data: Record }[] { + // First, identify all group columns and all value columns + const allGroupColumns = new Set(); + const allValueColumns = new Set(); + + scalarData.forEach((scalar) => { + scalar.columns.forEach((col) => { + if (col.columnType === 'group') { + allGroupColumns.add(col.name); + } else if (col.columnType === 'aggregation') { + // Use query name for value columns to match expected format + allValueColumns.add(col.queryName); + } + }); + }); + + // Create a unified row structure + const unifiedRows = new Map>(); + + // Process each scalar result + scalarData.forEach((scalar) => { + scalar.data.forEach((dataRow) => { + const groupColumns = scalar.columns.filter( + (col) => col.columnType === 'group', + ); + + // Create row key based on group columns + let rowKey: string; + const groupValues: Record = {}; + + if (groupColumns.length > 0) { + const keyParts: string[] = []; + groupColumns.forEach((col, index) => { + const value = dataRow[index]; + keyParts.push(String(value)); + groupValues[col.name] = value; + }); + rowKey = keyParts.join('|'); + } else { + // For scalar values without grouping, create a default row + rowKey = 'default_row'; + // Set all group columns to 'n/a' for this row + Array.from(allGroupColumns).forEach((groupCol) => { + groupValues[groupCol] = 'n/a'; + }); + } + + // Get or create the unified row + if (!unifiedRows.has(rowKey)) { + const newRow: Record = { ...groupValues }; + // Initialize all value columns to 'n/a' + Array.from(allValueColumns).forEach((valueCol) => { + newRow[valueCol] = 'n/a'; + }); + unifiedRows.set(rowKey, newRow); + } + + const row = unifiedRows.get(rowKey)!; + + // Fill in the aggregation values using query name as column name + scalar.columns.forEach((col, colIndex) => { + if (col.columnType === 'aggregation') { + row[col.queryName] = dataRow[colIndex]; + } + }); + }); + }); + + return Array.from(unifiedRows.values()).map((rowData) => ({ + data: rowData, + })); +} + +/** + * Converts V5 ScalarData array to legacy format with table structure + */ +function convertScalarDataArrayToTable( + scalarDataArray: ScalarData[], + legendMap: Record, +): QueryDataV3 { + // If no scalar data, return empty structure + if (!scalarDataArray || scalarDataArray.length === 0) { + return { + queryName: '', + legend: '', + series: null, + list: null, + table: { + columns: [], + rows: [], + }, + }; + } + + // Collect columns and process rows + const columns = collectColumnsFromScalarData(scalarDataArray); + const rows = processScalarDataRows(scalarDataArray); + + // Get the primary query name + const primaryQuery = scalarDataArray.find((s) => + s.columns.some((c) => c.columnType === 'aggregation'), + ); + const queryName = + primaryQuery?.columns.find((c) => c.columnType === 'aggregation') + ?.queryName || + scalarDataArray[0]?.columns[0]?.queryName || + ''; + + return { + queryName, + legend: legendMap[queryName] || queryName, + series: null, + list: null, + table: { + columns, + rows, + }, + }; +} + +/** + * Converts V5 RawData to legacy format + */ +function convertRawData( + rawData: RawData, + legendMap: Record, +): QueryDataV3 { + // Convert V5 raw format to legacy QueryDataV3 format + return { + queryName: rawData.queryName, + legend: legendMap[rawData.queryName] || rawData.queryName, + series: null, + list: rawData.rows?.map((row) => ({ + timestamp: row.timestamp, + data: { + // Map raw data to ILog structure - spread row.data first to include all properties + ...row.data, + date: row.timestamp, + } as any, + })), + }; +} + +/** + * Converts V5 DistributionData to legacy format + */ +function convertDistributionData( + distributionData: DistributionData, + legendMap: Record, +): any { + // eslint-disable-line @typescript-eslint/no-explicit-any + // Convert V5 distribution format to legacy histogram format + return { + ...distributionData, + legendMap, + }; +} + +/** + * Helper function to convert V5 data based on type + */ +function convertV5DataByType( + v5Data: any, + legendMap: Record, +): MetricRangePayloadV3['data'] { + switch (v5Data?.type) { + case 'time_series': { + const timeSeriesData = v5Data.data.results as TimeSeriesData[]; + return { + resultType: 'time_series', + result: timeSeriesData.map((timeSeries) => + convertTimeSeriesData(timeSeries, legendMap), + ), + }; + } + case 'scalar': { + const scalarData = v5Data.data.results as ScalarData[]; + // For scalar data, combine all results into a single table + const combinedTable = convertScalarDataArrayToTable(scalarData, legendMap); + return { + resultType: 'scalar', + result: [combinedTable], + }; + } + case 'raw': { + const rawData = v5Data.data.results as RawData[]; + return { + resultType: 'raw', + result: rawData.map((raw) => convertRawData(raw, legendMap)), + }; + } + case 'distribution': { + const distributionData = v5Data.data.results as DistributionData[]; + return { + resultType: 'distribution', + result: distributionData.map((distribution) => + convertDistributionData(distribution, legendMap), + ), + }; + } + default: + return { + resultType: '', + result: [], + }; + } +} + +/** + * Converts V5 API response to legacy format expected by frontend components + */ +export function convertV5ResponseToLegacy( + v5Response: SuccessResponse, + legendMap: Record, + // formatForWeb?: boolean, +): SuccessResponse { + const { payload } = v5Response; + const v5Data = payload?.data; + + // todo - sagar + // If formatForWeb is true, return as-is (like existing logic) + // Exception: scalar data should always be converted to table format + // if (formatForWeb && v5Data?.type !== 'scalar') { + // return v5Response as any; + // } + + // Convert based on V5 response type + const convertedData = convertV5DataByType(v5Data, legendMap); + + // Create legacy-compatible response structure + const legacyResponse: SuccessResponse = { + ...v5Response, + payload: { + data: convertedData, + }, + }; + + // Apply legend mapping (similar to existing logic) + if (legacyResponse.payload?.data?.result) { + legacyResponse.payload.data.result = legacyResponse.payload.data.result.map( + (queryData: any) => { + // eslint-disable-line @typescript-eslint/no-explicit-any + const newQueryData = queryData; + newQueryData.legend = legendMap[queryData.queryName]; + + // If metric names is an empty object + if (isEmpty(queryData.metric)) { + // If metrics list is empty && the user haven't defined a legend then add the legend equal to the name of the query. + if (!newQueryData.legend) { + newQueryData.legend = queryData.queryName; + } + // If name of the query and the legend if inserted is same then add the same to the metrics object. + if (queryData.queryName === newQueryData.legend) { + newQueryData.metric = newQueryData.metric || {}; + newQueryData.metric[queryData.queryName] = queryData.queryName; + } + } + + return newQueryData; + }, + ); + } + + return legacyResponse; +} diff --git a/frontend/src/api/v5/queryRange/getQueryRange.ts b/frontend/src/api/v5/queryRange/getQueryRange.ts new file mode 100644 index 000000000000..9784cb52cf18 --- /dev/null +++ b/frontend/src/api/v5/queryRange/getQueryRange.ts @@ -0,0 +1,51 @@ +import { ApiV5Instance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ENTITY_VERSION_V5 } from 'constants/app'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + MetricRangePayloadV5, + QueryRangePayloadV5, +} from 'types/api/v5/queryRange'; + +export const getQueryRangeV5 = async ( + props: QueryRangePayloadV5, + version: string, + signal: AbortSignal, + headers?: Record, +): Promise | ErrorResponse> => { + try { + if (version && version === ENTITY_VERSION_V5) { + const response = await ApiV5Instance.post('/query_range', props, { + signal, + headers, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + params: props, + }; + } + + // Default V5 behavior + const response = await ApiV5Instance.post('/query_range', props, { + signal, + headers, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + params: props, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getQueryRangeV5; diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts new file mode 100644 index 000000000000..f97501756698 --- /dev/null +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts @@ -0,0 +1,384 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; +import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; +import { isEmpty } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + QueryFunctionProps, +} from 'types/api/queryBuilder/queryBuilderData'; +import { + BaseBuilderQuery, + FieldContext, + FieldDataType, + FunctionName, + GroupByKey, + LogAggregation, + MetricAggregation, + OrderBy, + QueryEnvelope, + QueryFunction, + QueryRangePayloadV5, + QueryType, + RequestType, + TelemetryFieldKey, + TraceAggregation, +} from 'types/api/v5/queryRange'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; + +type PrepareQueryRangePayloadV5Result = { + queryPayload: QueryRangePayloadV5; + legendMap: Record; +}; + +/** + * Maps panel types to V5 request types + */ +function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType { + switch (panelType) { + case PANEL_TYPES.TIME_SERIES: + case PANEL_TYPES.BAR: + return 'time_series'; + case PANEL_TYPES.TABLE: + case PANEL_TYPES.PIE: + case PANEL_TYPES.VALUE: + case PANEL_TYPES.TRACE: + return 'scalar'; + case PANEL_TYPES.LIST: + return 'raw'; + case PANEL_TYPES.HISTOGRAM: + return 'distribution'; + default: + return ''; + } +} + +/** + * Gets signal type from data source + */ +function getSignalType(dataSource: string): 'traces' | 'logs' | 'metrics' { + if (dataSource === 'traces') return 'traces'; + if (dataSource === 'logs') return 'logs'; + return 'metrics'; +} + +/** + * Creates base spec for builder queries + */ +function createBaseSpec( + queryData: IBuilderQuery, + requestType: RequestType, +): BaseBuilderQuery { + return { + stepInterval: queryData.stepInterval, + disabled: queryData.disabled, + filter: queryData?.filter?.expression ? queryData.filter : undefined, + groupBy: + queryData.groupBy?.length > 0 + ? queryData.groupBy.map( + (item: any): GroupByKey => ({ + name: item.key, + fieldDataType: item?.dataType, + fieldContext: item?.type, + description: item?.description, + unit: item?.unit, + signal: item?.signal, + materialized: item?.materialized, + }), + ) + : undefined, + limit: isEmpty(queryData.limit) + ? queryData?.pageSize ?? undefined + : queryData.limit ?? undefined, + offset: requestType === 'raw' ? queryData.offset : undefined, + order: + queryData.orderBy.length > 0 + ? queryData.orderBy.map( + (order: any): OrderBy => ({ + key: { + name: order.columnName, + }, + direction: order.order, + }), + ) + : undefined, + // legend: isEmpty(queryData.legend) ? undefined : queryData.legend, + having: isEmpty(queryData.havingExpression) + ? undefined + : queryData?.havingExpression, + functions: isEmpty(queryData.functions) + ? undefined + : queryData.functions.map( + (func: QueryFunctionProps): QueryFunction => ({ + name: func.name as FunctionName, + args: func.args.map((arg) => ({ + // name: arg.name, + value: arg, + })), + }), + ), + selectFields: isEmpty(queryData.selectColumns) + ? undefined + : queryData.selectColumns?.map( + (column: BaseAutocompleteData): TelemetryFieldKey => ({ + name: column.key, + fieldDataType: column?.dataType as FieldDataType, + fieldContext: column?.type as FieldContext, + }), + ), + }; +} +// Utility to parse aggregation expressions with optional alias +export function parseAggregations( + expression: string, +): { expression: string; alias?: string }[] { + const result: { expression: string; alias?: string }[] = []; + const regex = /([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+([a-zA-Z0-9_]+))?/g; + let match = regex.exec(expression); + while (match !== null) { + const expr = match[1]; + const alias = match[2]; + if (alias) { + result.push({ expression: expr, alias }); + } else { + result.push({ expression: expr }); + } + match = regex.exec(expression); + } + return result; +} + +function createAggregation( + queryData: any, +): TraceAggregation[] | LogAggregation[] | MetricAggregation[] { + if (queryData.dataSource === DataSource.METRICS) { + return [ + { + metricName: queryData?.aggregateAttribute?.key, + temporality: queryData?.aggregateAttribute?.temporality, + timeAggregation: queryData?.timeAggregation, + spaceAggregation: queryData?.spaceAggregation, + }, + ]; + } + + if (queryData.aggregations?.length > 0) { + return isEmpty(parseAggregations(queryData.aggregations?.[0].expression)) + ? [{ expression: 'count()' }] + : parseAggregations(queryData.aggregations?.[0].expression); + } + + return [{ expression: 'count()' }]; +} + +/** + * Converts query builder data to V5 builder queries + */ +function convertBuilderQueriesToV5( + builderQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any + requestType: RequestType, +): QueryEnvelope[] { + return Object.entries(builderQueries).map( + ([queryName, queryData]): QueryEnvelope => { + const signal = getSignalType(queryData.dataSource); + const baseSpec = createBaseSpec(queryData, requestType); + let spec: QueryEnvelope['spec']; + + const aggregations = createAggregation(queryData); + + switch (signal) { + case 'traces': + spec = { + name: queryName, + signal: 'traces' as const, + ...baseSpec, + aggregations: aggregations as TraceAggregation[], + limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), + }; + break; + case 'logs': + spec = { + name: queryName, + signal: 'logs' as const, + ...baseSpec, + aggregations: aggregations as LogAggregation[], + limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), + }; + break; + case 'metrics': + default: + spec = { + name: queryName, + signal: 'metrics' as const, + ...baseSpec, + aggregations: aggregations as MetricAggregation[], + // reduceTo: queryData.reduceTo, + limit: baseSpec?.limit ?? (requestType === 'raw' ? 10 : undefined), + }; + break; + } + + return { + type: 'builder_query' as QueryType, + spec, + }; + }, + ); +} + +/** + * Converts PromQL queries to V5 format + */ +function convertPromQueriesToV5( + promQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any +): QueryEnvelope[] { + return Object.entries(promQueries).map( + ([queryName, queryData]): QueryEnvelope => ({ + type: 'promql' as QueryType, + spec: { + name: queryName, + query: queryData.query, + disabled: queryData.disabled || false, + step: queryData.stepInterval, + stats: false, // PromQL specific field + }, + }), + ); +} + +/** + * Converts ClickHouse queries to V5 format + */ +function convertClickHouseQueriesToV5( + chQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any +): QueryEnvelope[] { + return Object.entries(chQueries).map( + ([queryName, queryData]): QueryEnvelope => ({ + type: 'clickhouse_sql' as QueryType, + spec: { + name: queryName, + query: queryData.query, + disabled: queryData.disabled || false, + // ClickHouse doesn't have step or stats like PromQL + }, + }), + ); +} + +/** + * Converts query formulas to V5 format + */ +function convertFormulasToV5( + formulas: Record, // eslint-disable-line @typescript-eslint/no-explicit-any +): QueryEnvelope[] { + return Object.entries(formulas).map( + ([queryName, formulaData]): QueryEnvelope => ({ + type: 'builder_formula' as QueryType, + spec: { + name: queryName, + expression: formulaData.expression || '', + functions: formulaData.functions, + }, + }), + ); +} + +/** + * Helper function to reduce query arrays to objects + */ +function reduceQueriesToObject( + queryArray: any[], // eslint-disable-line @typescript-eslint/no-explicit-any +): { queries: Record; legends: Record } { + // eslint-disable-line @typescript-eslint/no-explicit-any + const legends: Record = {}; + const queries = queryArray.reduce((acc, queryItem) => { + if (!queryItem.query) return acc; + acc[queryItem.name] = queryItem; + legends[queryItem.name] = queryItem.legend; + return acc; + }, {} as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + + return { queries, legends }; +} + +/** + * Prepares V5 query range payload from GetQueryResultsProps + */ +export const prepareQueryRangePayloadV5 = ({ + query, + globalSelectedInterval, + graphType, + selectedTime, + tableParams, + variables = {}, + start: startTime, + end: endTime, +}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => { + let legendMap: Record = {}; + const requestType = mapPanelTypeToRequestType(graphType); + let queries: QueryEnvelope[] = []; + + switch (query.queryType) { + case EQueryType.QUERY_BUILDER: { + const { queryData: data, queryFormulas } = query.builder; + const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams); + const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName'); + + // Combine legend maps + legendMap = { + ...currentQueryData.newLegendMap, + ...currentFormulas.newLegendMap, + }; + + // Convert builder queries + const builderQueries = convertBuilderQueriesToV5( + currentQueryData.data, + requestType, + ); + + // Convert formulas as separate query type + const formulaQueries = convertFormulasToV5(currentFormulas.data); + + // Combine both types + queries = [...builderQueries, ...formulaQueries]; + break; + } + case EQueryType.PROM: { + const promQueries = reduceQueriesToObject(query[query.queryType]); + queries = convertPromQueriesToV5(promQueries.queries); + legendMap = promQueries.legends; + break; + } + case EQueryType.CLICKHOUSE: { + const chQueries = reduceQueriesToObject(query[query.queryType]); + queries = convertClickHouseQueriesToV5(chQueries.queries); + legendMap = chQueries.legends; + break; + } + default: + break; + } + + // Calculate time range + const { start, end } = getStartEndRangeTime({ + type: selectedTime, + interval: globalSelectedInterval, + }); + + // Create V5 payload + const queryPayload: QueryRangePayloadV5 = { + schemaVersion: 'v1', + start: startTime ? startTime * 1e3 : parseInt(start, 10) * 1e3, + end: endTime ? endTime * 1e3 : parseInt(end, 10) * 1e3, + requestType, + compositeQuery: { + queries, + }, + variables, + }; + + return { legendMap, queryPayload }; +}; diff --git a/frontend/src/api/v5/v5.ts b/frontend/src/api/v5/v5.ts new file mode 100644 index 000000000000..44d71a74104f --- /dev/null +++ b/frontend/src/api/v5/v5.ts @@ -0,0 +1,8 @@ +// V5 API exports +export * from './queryRange/constants'; +export { convertV5ResponseToLegacy } from './queryRange/convertV5Response'; +export { getQueryRangeV5 } from './queryRange/getQueryRange'; +export { prepareQueryRangePayloadV5 } from './queryRange/prepareQueryRangePayloadV5'; + +// Export types from proper location +export * from 'types/api/v5/queryRange'; diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx index 1e055438b4b4..68f9dbace449 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx @@ -15,6 +15,7 @@ import { Button } from 'antd'; import { useQueryBuilderV2Context } from 'components/QueryBuilderV2/QueryBuilderV2Context'; import { X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; const havingOperators = [ { @@ -67,16 +68,30 @@ const conjunctions = [ { label: 'OR', value: 'OR' }, ]; -function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { +function HavingFilter({ + onClose, + onChange, + queryData, +}: { + onClose: () => void; + onChange: (value: string) => void; + queryData: IBuilderQuery; +}): JSX.Element { const { aggregationOptions } = useQueryBuilderV2Context(); - const [input, setInput] = useState(''); + const [input, setInput] = useState( + queryData?.havingExpression?.expression || '', + ); const [isFocused, setIsFocused] = useState(false); const editorRef = useRef(null); const [options, setOptions] = useState<{ label: string; value: string }[]>([]); - // Effect to handle focus state and trigger suggestions + const handleChange = (value: string): void => { + setInput(value); + onChange(value); + }; + useEffect(() => { if (isFocused && editorRef.current && options.length > 0) { startCompletion(editorRef.current); @@ -237,9 +252,10 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element {
- prevAddOns.filter((addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT), + filteredAddOns = ADD_ONS.filter( + (addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT, ); } else { - setAddOns(Object.values(ADD_ONS)); + filteredAddOns = Object.values(ADD_ONS); + + // Filter out group_by for metrics data source + if (query.dataSource === DataSource.METRICS) { + filteredAddOns = filteredAddOns.filter( + (addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY, + ); + } } // add reduce to if showReduceTo is true if (showReduceTo) { - setAddOns((prevAddOns) => [...prevAddOns, REDUCE_TO]); + filteredAddOns = [...filteredAddOns, REDUCE_TO]; } + setAddOns(filteredAddOns); + + // Filter selectedViews to only include add-ons present in filteredAddOns + setSelectedViews((prevSelectedViews) => + prevSelectedViews.filter((view) => + filteredAddOns.some((addOn) => addOn.key === view.key), + ), + ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [panelType, isListViewPanel]); + }, [panelType, isListViewPanel, query.dataSource]); const handleOptionClick = (e: RadioChangeEvent): void => { if (selectedViews.find((view) => view.key === e.target.value.key)) { @@ -167,6 +184,15 @@ function QueryAddOns({ [handleChangeQueryData], ); + const handleChangeHaving = useCallback( + (value: string) => { + handleChangeQueryData('havingExpression', { + expression: value, + }); + }, + [handleChangeQueryData], + ); + return (
{selectedViews.length > 0 && ( @@ -204,6 +230,8 @@ function QueryAddOns({ selectedViews.filter((view) => view.key !== 'having'), ); }} + onChange={handleChangeHaving} + queryData={query} />
@@ -214,6 +242,7 @@ function QueryAddOns({ { setSelectedViews(selectedViews.filter((view) => view.key !== 'limit')); @@ -233,11 +262,13 @@ function QueryAddOns({ isListViewPanel={isListViewPanel} /> -