Query builder misc - fixes (#8295)

* feat: trace and logs explorer fixes

* fix: ui fixes

* fix: handle multi arg aggregation

* feat: explorer pages fixes

* feat: added fixes for order by for datasource

* feat: metric order by issue

* feat: support for paneltype selectedview tab switch

* feat: qb v2 compatiblity with url's composite query

* feat: conversion fixes

* feat: where clause and aggregation fix

---------

Co-authored-by: Yunus M <myounis.ar@live.com>
This commit is contained in:
SagarRajput-7 2025-06-19 17:03:25 +05:30 committed by Yunus M
parent 62c56d2150
commit 6d1d48e156
25 changed files with 981 additions and 203 deletions

View File

@ -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(

View File

@ -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<string, any>, // 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

View File

@ -101,7 +101,24 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
<QueryBuilderV2Provider>
<div className="query-builder-v2">
<div className="qb-content-container">
{currentQuery.builder.queryData.map((query, index) => (
{isListViewPanel && (
<QueryV2
ref={containerRef}
key={currentQuery.builder.queryData[0].queryName}
index={0}
query={currentQuery.builder.queryData[0]}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
version={version}
isAvailableToDisable={false}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
/>
)}
{!isListViewPanel &&
currentQuery.builder.queryData.map((query, index) => (
<QueryV2
ref={containerRef}
key={query.queryName}

View File

@ -97,6 +97,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
label="Seconds"
placeholder="Enter a number"
labelAfter
initialValue={query?.stepInterval ?? undefined}
/>
</div>
</div>

View File

@ -3,6 +3,7 @@
import {
autocompletion,
closeCompletion,
Completion,
CompletionContext,
completionKeymap,
CompletionResult,
@ -143,9 +144,62 @@ function HavingFilter({
});
};
const havingAutocomplete = useMemo(
() =>
autocompletion({
// 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;
view.dispatch({
changes: { from, to, insert: newText },
selection: { anchor: newPos, head: newPos },
effects: EditorView.scrollIntoView(newPos),
});
};
const havingAutocomplete = useMemo(() => {
// Helper functions for applying completions
const forceCompletion = (view: EditorView): void => {
setTimeout(() => {
if (view) {
startCompletion(view);
}
}, 0);
};
const applyValueCompletion = (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
applyCompletionWithSpace(view, completion, from, to);
forceCompletion(view);
};
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);
};
return autocompletion({
override: [
(context: CompletionContext): CompletionResult | null => {
const text = context.state.sliceDoc(0, context.pos);
@ -167,16 +221,19 @@ function HavingFilter({
};
}
// Show value suggestions after operator - this should take precedence
// Show value suggestions after operator
if (isAfterOperator(tokens)) {
return {
from: context.pos,
options: [
...commonValues,
...commonValues.map((value) => ({
...value,
apply: applyValueCompletion,
})),
{
label: 'Enter a custom number value',
type: 'text',
apply: (): boolean => true,
apply: applyValueCompletion,
},
],
};
@ -185,12 +242,15 @@ function HavingFilter({
// Suggest key/operator pairs and ( for grouping
if (
tokens.length === 0 ||
conjunctions.some((c) => tokens[tokens.length - 1] === c.value) ||
conjunctions.some((c) => tokens[tokens.length - 1] === c.value.trim()) ||
tokens[tokens.length - 1] === '('
) {
return {
from: context.pos,
options,
options: options.map((opt) => ({
...opt,
apply: applyOperatorCompletion,
})),
};
}
@ -203,39 +263,37 @@ function HavingFilter({
if (filteredOptions.length > 0) {
return {
from: context.pos - lastToken.length,
options: filteredOptions,
options: filteredOptions.map((opt) => ({
...opt,
apply: applyOperatorCompletion,
})),
};
}
}
// Suggest ) for grouping after a value and a space, if there are unmatched (
// Suggest conjunctions after a value and a space
if (
tokens.length > 0 &&
isNumber(tokens[tokens.length - 1]) &&
(isNumber(tokens[tokens.length - 1]) ||
tokens[tokens.length - 1] === ')') &&
text.endsWith(' ')
) {
return {
from: context.pos,
options: conjunctions,
};
}
// 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,
options: conjunctions.map((conj) => ({
...conj,
apply: applyValueCompletion,
})),
};
}
// Show all options if no other condition matches
return {
from: context.pos,
options,
options: options.map((opt) => ({
...opt,
apply: applyOperatorCompletion,
})),
};
},
],
@ -243,9 +301,8 @@ function HavingFilter({
closeOnBlur: true,
maxRenderedOptions: 200,
activateOnTyping: true,
}),
[options],
);
});
}, [options]);
return (
<div className="having-filter-container">

View File

@ -260,6 +260,7 @@ function QueryAddOns({
query={query}
onChange={handleChangeOrderByKeys}
isListViewPanel={isListViewPanel}
isNewQueryV2
/>
</div>
{!isListViewPanel && (

View File

@ -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);

View File

@ -165,10 +165,16 @@ function QueryAggregationSelect({
.split(',')
.map((arg) => arg.trim())
.filter((arg) => arg.length > 0);
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);
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -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

View File

@ -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<

View File

@ -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({
<div className="qb-search-filter-container">
<div className="query-search-container">
<QuerySearch
key={`query-search-${query.queryName}-${query.dataSource}`}
onChange={handleSearchChange}
queryData={query}
dataSource={dataSource}
@ -196,6 +240,7 @@ export const QueryV2 = memo(function QueryV2({
dataSource !== DataSource.METRICS && (
<QueryAggregation
dataSource={dataSource}
key={`query-search-${query.queryName}-${query.dataSource}`}
panelType={panelType || undefined}
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
@ -208,6 +253,7 @@ export const QueryV2 = memo(function QueryV2({
panelType={panelType}
query={query}
index={0}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
version="v4"
/>
)}

View File

@ -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,
];
};

View File

@ -47,4 +47,5 @@ export enum QueryParams {
destination = 'destination',
kindString = 'kindString',
tab = 'tab',
selectedExplorerView = 'selectedExplorerView',
}

View File

@ -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(
const defaultValue = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap.logs,
PANEL_TYPES.LIST,
DataSource.LOGS,
),
[updateAllQueriesOperators],
);
return prepareQueryWithDefaultTimestamp(updatedQuery);
}, [updateAllQueriesOperators]);
useShareBuilderUrl(defaultValue);

View File

@ -138,7 +138,7 @@ function LogsExplorerViewsContainer({
const [queryStats, setQueryStats] = useState<WsDataEvent>();
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderDirection, setOrderDirection] = useState<string>('asc');
const [orderDirection, setOrderDirection] = useState<string>('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(() => {

View File

@ -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 */}
{/* <Button.Group className="explore-tabs">

View File

@ -8,6 +8,7 @@ export type OrderByFilterProps = {
onChange: (values: OrderByPayload[]) => void;
isListViewPanel?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
};
export type OrderByFilterValue = {

View File

@ -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 =

View File

@ -39,7 +39,9 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
isListViewPanel={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}

View File

@ -58,8 +58,6 @@ export const useHandleExplorerTabChange = (): {
currentQueryData?: ICurrentQueryData,
redirectToUrl?: typeof ROUTES[keyof typeof ROUTES],
) => {
console.log('hook - type', type);
const newPanelType = type as PANEL_TYPES;
if (newPanelType === panelType && !currentQueryData) return;

View File

@ -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<string, string>;
let response: SuccessResponse<MetricRangePayloadProps>;
// 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;

View File

@ -258,26 +258,43 @@ const transformColumnTitles = (
return item;
});
const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => {
const dynamicColumns: DynamicColumns = [];
queryTableData.forEach((currentQuery) => {
const { series, queryName, list } = currentQuery;
const currentStagedQuery = getQueryByName(
query,
queryName,
isFormula(queryName) ? 'queryFormulas' : 'queryData',
const processTableColumns = (
table: NonNullable<QueryDataV3['table']>,
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,
);
if (list) {
list.forEach((listItem) => {
Object.keys(listItem.data).forEach((label) => {
addLabels(currentStagedQuery, label, dynamicColumns);
});
});
} else {
// For non-value columns, add as field/label column
addLabels(currentStagedQuery, column.name, dynamicColumns);
}
});
};
if (series) {
const processSeriesColumns = (
series: NonNullable<QueryDataV3['series']>,
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);
@ -285,7 +302,7 @@ const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => {
addOperatorFormulaColumns(
currentStagedQuery,
dynamicColumns,
query.queryType,
queryType,
isEveryValuesExist ? undefined : get(currentStagedQuery, 'queryName', ''),
);
}
@ -299,6 +316,45 @@ const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => {
});
});
});
};
const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => {
const dynamicColumns: DynamicColumns = [];
queryTableData.forEach((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) => {
addLabels(currentStagedQuery, label, dynamicColumns);
});
});
}
if (table) {
processTableColumns(
table,
currentStagedQuery,
dynamicColumns,
query.queryType,
);
}
if (series) {
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<keyof RowData>(
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;

View File

@ -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>(
ExplorerViews.LIST,
const [searchParams] = useSearchParams();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
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();

View File

@ -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>(
ExplorerViews.LIST,
const [searchParams, setSearchParams] = useSearchParams();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
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,27 +128,16 @@ function TracesExplorer(): JSX.Element {
return groupByCount > 0;
}, [currentQuery]);
const defaultQuery = useMemo(() => {
const query = updateAllQueriesOperators(
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
return {
...query,
builder: {
...query.builder,
queryData: [
{
...query.builder.queryData[0],
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
},
],
},
};
}, [updateAllQueriesOperators]);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(

View File

@ -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,
);
}

View File

@ -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, ExplorerViews> = {
[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];