Compare commits

...

12 Commits

Author SHA1 Message Date
Aditya Singh
42ea4af601 Merge branch 'main' into fix/explorer-list-order-by 2026-10-12 14:05:02 +05:30
aks07
3393012232 chore(explorer): drop comments the repo rule routes to the PR 2026-10-12 12:35:29 +05:30
aks07
dd73845258 test(explorer): type the query builder render helper 2026-10-12 12:35:03 +05:30
aks07
7615dacb84 refactor(logs-explorer): drop the redundant order-by change detection
The ref predates the order living in the query. redirectWithQueryBuilderData
now gives the staged query a new id on every order change, and the effect
already compares that id, so the extra check can never decide the outcome.
2026-10-12 12:34:36 +05:30
aks07
f07e03c5a3 fix(explorer): clear the order when switching to a list or trace panel
A raw panel orders by a row field. The previous filter removed only the
aggregated value sentinel, so an order set on another tab became the raw
panel's sort. On LLM Observability the two raw tabs offer different fields,
so each one could send the other's.

Clearing needs no knowledge of either tab's fields, so it closes both
directions. getRawPanelOrderBy still filters on read, for the saved view and
deep link paths that never reach a tab change.
2026-10-12 12:33:43 +05:30
aks07
89666ef2d9 chore(explorer): remove an inaccurate comment 2026-10-08 21:14:21 +05:30
aks07
41ad49d21d fix(llm-observability): run the orderBy sanitizer on the explorer route 2026-10-08 21:14:21 +05:30
Aditya Singh
9e8b1530fd Merge branch 'main' into fix/explorer-list-order-by 2026-10-08 18:37:10 +05:30
aks07
9e2ac4c55f fix(llm-observability): keep the list order-by in the composite query
Apply the same change as the logs and traces explorers to the list and trace
views, which are a copy of them. The trace view keeps its own default of
last_activity_time:desc.
2026-10-08 18:20:37 +05:30
aks07
a0960fe862 fix(explorer): keep the list order-by in the composite query
The order lived in component state, so it never reached the URL and was lost
on back navigation, ignored on a deep link, and missing from a saved view. It
now lives in the query and is written through redirectWithQueryBuilderData.

A raw panel cannot order by an aggregation, so getRawPanelOrderBy drops those
on read and leaves them in the query for the aggregated tab to use.
2026-10-08 18:20:35 +05:30
aks07
cd1a88fd7d fix(explorer): clear the run-query bypass after the init it applies to
handleRunQuery sets a flag so the init it triggers keeps the query exactly as
built. The flag was cleared only when the pathname changed, so after one run
every later init on that page skipped sanitizing too: tab switches, filters,
time range changes and saved views. A refresh reset it, a tab switch did not.

Cmd+Enter in the filter bar also sets it, so most sessions entered that state
without using Stage and Run.
2026-10-08 18:20:30 +05:30
aks07
0b6a6134d2 fix(explorer): stop the orderBy sanitizer running on list panels
The sanitizer keeps only order-by columns that are a group-by key or an
aggregation. A list panel has neither, so it dropped every valid row-field
sort. Gate it on panel type, and keep it for the aggregated panels it was
written for.
2026-10-08 18:20:17 +05:30
15 changed files with 626 additions and 60 deletions

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -34,6 +33,8 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
@@ -58,12 +59,16 @@ function ListView({
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -92,7 +97,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
orderBy,
],
[
stagedQuery,
@@ -101,7 +105,6 @@ function ListView({
paginationConfig,
maxTime,
minTime,
orderBy,
],
);
@@ -167,9 +170,12 @@ function ListView({
[queryTableData],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -23,6 +22,7 @@ import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
@@ -57,9 +57,13 @@ function TracesView({
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const orderBy = useMemo(
() => getListOrderBy(stagedQuery, TRACE_VIEW_DEFAULT_ORDER_BY),
[stagedQuery],
);
const {
columns,
@@ -94,7 +98,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -103,7 +106,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -165,9 +167,12 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(

View File

@@ -1,6 +1,7 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -11,12 +12,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -16,14 +16,14 @@ function LogsActionsContainer({
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
setOrderBy,
onOrderChange,
explorerActions,
}: {
listQuery: any;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
onOrderChange: (value: string) => void;
explorerActions: ReactNode;
}): JSX.Element {
const { options, config } = useOptionsMenu({
@@ -78,7 +78,7 @@ function LogsActionsContainer({
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
onChange={onOrderChange}
dataSource={DataSource.LOGS}
/>
</div>

View File

@@ -59,6 +59,7 @@ import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import LogsActionsContainer from './LogsActionsContainer';
@@ -101,7 +102,8 @@ function LogsExplorerViewsContainer({
const currentMinTimeRef = useRef<number>(minTime);
// Context
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const selectedPanelType = panelType || PANEL_TYPES.LIST;
@@ -111,7 +113,7 @@ function LogsExplorerViewsContainer({
const [requestData, setRequestData] = useState<Query | null>(null);
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
@@ -192,6 +194,13 @@ function LogsExplorerViewsContainer({
'custom',
);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const getRequestData = useCallback(
(
query: Query | null,
@@ -323,18 +332,10 @@ function LogsExplorerViewsContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Store previous orderDirection to detect changes
const prevOrderByRef = useRef(orderBy);
useEffect(() => {
const orderByChanged =
prevOrderByRef.current !== orderBy && selectedPanelType === PANEL_TYPES.LIST;
prevOrderByRef.current = orderBy;
if (
requestData?.id !== stagedQuery?.id ||
currentMinTimeRef.current !== minTime ||
orderByChanged
currentMinTimeRef.current !== minTime
) {
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
@@ -358,7 +359,6 @@ function LogsExplorerViewsContainer({
activeLogId,
selectedPanelType,
maxTime,
orderBy,
]);
const chartData = useMemo(() => {
@@ -430,7 +430,7 @@ function LogsExplorerViewsContainer({
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
onOrderChange={handleOrderChange}
explorerActions={explorerActions}
/>
)}

View File

@@ -7,7 +7,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
@@ -34,6 +33,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -66,12 +66,16 @@ function ListView({
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -123,7 +127,6 @@ function ListView({
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
@@ -133,7 +136,6 @@ function ListView({
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
@@ -215,9 +217,12 @@ function ListView({
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -2,6 +2,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -12,12 +13,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -0,0 +1,108 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/saveViews/useGetSavedViewParams', () => ({
useGetSavedViewParams: (): { viewName: string; viewKey: string } => ({
viewName: '',
viewKey: '',
}),
}));
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const countOrderBy = [{ columnName: 'count()', order: 'desc' }];
const queryWithOrder = (orderBy: typeof countOrderBy): Query => ({
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((item) => ({
...item,
orderBy,
})),
},
});
const setUpQueryBuilder = (
from: PANEL_TYPES,
currentQuery: Query,
): jest.Mock => {
const redirectWithQueryBuilderData = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType: from,
redirectWithQueryBuilderData,
updateAllQueriesOperators: (query: Query): Query => query,
updateQueriesData: (
query: Query,
type: 'queryData',
updateCallback: (item: IBuilderQuery, index: number) => IBuilderQuery,
): Query => ({
...query,
builder: {
...query.builder,
[type]: query.builder[type].map(updateCallback),
},
}),
} as unknown as ReturnType<typeof useQueryBuilder>);
return redirectWithQueryBuilderData;
};
const switchTab = (from: PANEL_TYPES, to: PANEL_TYPES, query: Query): Query => {
const redirect = setUpQueryBuilder(from, query);
const { result } = renderHook(() => useHandleExplorerTabChange());
result.current.handleExplorerTabChange(to);
return redirect.mock.calls[0][0] as Query;
};
describe('explorer tab change clears the order for raw panels', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, PANEL_TYPES.LIST],
[PANEL_TYPES.TABLE, PANEL_TYPES.LIST],
[PANEL_TYPES.TRACE, PANEL_TYPES.LIST],
[PANEL_TYPES.LIST, PANEL_TYPES.TRACE],
])('clears it on %s -> %s', (from, to) => {
const query = switchTab(from, to, queryWithOrder(countOrderBy));
query.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([]);
});
});
it.each([
[PANEL_TYPES.LIST, PANEL_TYPES.TIME_SERIES],
[PANEL_TYPES.LIST, PANEL_TYPES.TABLE],
])('keeps it on %s -> %s, where the sanitizer validates it', (from, to) => {
const query = switchTab(from, to, queryWithOrder(countOrderBy));
query.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual(countOrderBy);
});
});
it('leaves the order alone when the caller supplies its own query', () => {
const saved = queryWithOrder([{ columnName: 'timestamp', order: 'asc' }]);
const redirect = setUpQueryBuilder(
PANEL_TYPES.TIME_SERIES,
queryWithOrder(countOrderBy),
);
const { result } = renderHook(() => useHandleExplorerTabChange());
result.current.handleExplorerTabChange(PANEL_TYPES.LIST, { query: saved });
const sent = redirect.mock.calls[0][0] as Query;
sent.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'asc' },
]);
});
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import { initialAutocompleteData, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -47,7 +46,7 @@ export const useHandleExplorerTabChange = (): {
) {
query = updateQueriesData(query, 'queryData', (item) => ({
...item,
orderBy: item.orderBy.filter((item) => item.columnName !== SIGNOZ_VALUE),
orderBy: [],
aggregateAttribute: initialAutocompleteData,
}));
}

View File

@@ -66,6 +66,7 @@ import {
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
currentQuery: initialQueriesMap.metrics,
@@ -182,6 +183,11 @@ export function QueryBuilderProvider({
})) ?? [],
};
const urlPanelType = readPanelTypeFromUrl(PANEL_TYPES.LIST);
const isRawPanel =
urlPanelType === PANEL_TYPES.LIST || urlPanelType === PANEL_TYPES.TRACE;
const setupedQueryData = builder.queryData.map((item) => {
const currentElement: IBuilderQuery = {
...item,
@@ -200,8 +206,10 @@ export function QueryBuilderProvider({
// Explorer pages: sanitize stale orderBy before first query
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
(location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER ||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER) &&
!isRawPanel;
if (isExplorer) {
const sanitizedOrderBy = sanitizeOrderByForExplorer(currentElement);
return calledFromHandleRunQuery
@@ -262,6 +270,8 @@ export function QueryBuilderProvider({
setStagedQuery(nextQuery);
setCurrentQuery(newQueryState);
setQueryType(type);
setCalledFromHandleRunQuery(false);
},
[prepareQueryBuilderData],
);
@@ -1038,7 +1048,8 @@ export function QueryBuilderProvider({
const handleRunQuery = useCallback(() => {
const isExplorer =
location.pathname === ROUTES.LOGS_EXPLORER ||
location.pathname === ROUTES.TRACES_EXPLORER;
location.pathname === ROUTES.TRACES_EXPLORER ||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER;
if (isExplorer) {
setCalledFromHandleRunQuery(true);
}

View File

@@ -0,0 +1,176 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
act,
AllTheProviders,
renderHook,
RenderHookResult,
} from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
const timestampOrderBy = [{ columnName: 'timestamp', order: 'desc' }];
const queryWithTimestampOrder = (base: Query): Query => ({
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((item) => ({
...item,
orderBy: timestampOrderBy,
})),
},
});
const renderOn = (
route: string,
panelType: PANEL_TYPES | null,
): RenderHookResult<QueryBuilderContextType, unknown> => {
const search = new URLSearchParams();
if (panelType) {
search.set('panelTypes', JSON.stringify(panelType));
}
__setSearchParamsGetterForTest(() => search);
return renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={route}>{children}</AllTheProviders>
),
});
};
const initOn = (
route: string,
panelType: PANEL_TYPES | null,
query: Query,
): Query | null => {
const { result } = renderOn(route, panelType);
act(() => {
result.current.initQueryBuilderData(query);
});
return result.current.stagedQuery;
};
describe('explorer orderBy sanitizing by panel type', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('keeps the order on the logs list panel', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
PANEL_TYPES.LIST,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('keeps the order when panelTypes is absent, since the explorers default to list', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
null,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('keeps the order on the traces trace panel', () => {
const staged = initOn(
ROUTES.TRACES_EXPLORER,
PANEL_TYPES.TRACE,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
// engineering-pod#2813: a timestamp order carried from the list view into the
// time series tab is not a group-by key or an aggregation, and errors the query.
it('drops the order on the time series panel', () => {
const staged = initOn(
ROUTES.LOGS_EXPLORER,
PANEL_TYPES.TIME_SERIES,
queryWithTimestampOrder(initialQueriesMap.logs),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
it('keeps the order on the LLM observability list panel', () => {
const staged = initOn(
ROUTES.AI_OBSERVABILITY_EXPLORER,
PANEL_TYPES.LIST,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
});
it('drops the order on the LLM observability time series panel', () => {
const staged = initOn(
ROUTES.AI_OBSERVABILITY_EXPLORER,
PANEL_TYPES.TIME_SERIES,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
it('drops the order on the table panel', () => {
const staged = initOn(
ROUTES.TRACES_EXPLORER,
PANEL_TYPES.TABLE,
queryWithTimestampOrder(initialQueriesMap.traces),
);
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
});
});
// handleRunQuery means "respect the query I just built" for the init it causes.
// It used to stay set for the rest of the visit, so every later init skipped
// sanitizing as well.
describe('the run bypass is one-shot', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('skips sanitizing for the init after a run, and sanitizes the next one', () => {
const { result } = renderOn(ROUTES.LOGS_EXPLORER, PANEL_TYPES.TIME_SERIES);
act(() => {
result.current.handleRunQuery();
});
const afterRun = {
...queryWithTimestampOrder(initialQueriesMap.logs),
id: 'run-1',
};
act(() => {
result.current.initQueryBuilderData(afterRun);
});
expect(
result.current.stagedQuery?.builder.queryData[0].orderBy,
).toStrictEqual(timestampOrderBy);
const next = {
...queryWithTimestampOrder(initialQueriesMap.logs),
id: 'run-2',
};
act(() => {
result.current.initQueryBuilderData(next);
});
expect(
result.current.stagedQuery?.builder.queryData[0].orderBy,
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,137 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
DEFAULT_LIST_ORDER_BY,
getListOrderBy,
getRawPanelOrderBy,
parseListOrderBy,
setListOrderBy,
} from 'utils/explorerUtils';
const withOrderBy = (
orderBy: { columnName: string; order: string }[],
): Query => ({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: initialQueriesMap.logs.builder.queryData.map((item) => ({
...item,
orderBy,
})),
},
});
describe('getRawPanelOrderBy', () => {
it('keeps row fields', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: 'duration_nano', order: 'desc' }]),
),
).toStrictEqual([{ columnName: 'duration_nano', order: 'desc' }]);
});
it('keeps a group-by key, which is a row field too', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: 'service.name', order: 'asc' }]),
),
).toStrictEqual([{ columnName: 'service.name', order: 'asc' }]);
});
it('drops an aggregation carried in from an aggregated panel', () => {
expect(
getRawPanelOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
).toStrictEqual([]);
});
it('drops the aggregated value sentinel', () => {
expect(
getRawPanelOrderBy(
withOrderBy([{ columnName: '#SIGNOZ_VALUE', order: 'desc' }]),
),
).toStrictEqual([]);
});
it('keeps the usable entry and drops the rest', () => {
expect(
getRawPanelOrderBy(
withOrderBy([
{ columnName: 'count()', order: 'desc' },
{ columnName: 'timestamp', order: 'desc' },
]),
),
).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
});
});
describe('getListOrderBy', () => {
it('falls back to the default when the query holds only an aggregation', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
).toBe(DEFAULT_LIST_ORDER_BY);
});
it('reads the first order entry as a select value', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'asc' }])),
).toBe('timestamp:asc');
});
it('lower-cases a hand-written direction from a deep link', () => {
expect(
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'ASC' }])),
).toBe('timestamp:asc');
});
it('shows the primary entry when the query also carries the id tiebreaker', () => {
const query = withOrderBy([
{ columnName: 'timestamp', order: 'desc' },
{ columnName: 'id', order: 'desc' },
]);
expect(getListOrderBy(query)).toBe('timestamp:desc');
});
it('falls back to the default when the query carries no order', () => {
expect(getListOrderBy(withOrderBy([]))).toBe(DEFAULT_LIST_ORDER_BY);
expect(getListOrderBy(null)).toBe(DEFAULT_LIST_ORDER_BY);
});
it('falls back to the given default', () => {
expect(getListOrderBy(withOrderBy([]), 'last_activity_time:desc')).toBe(
'last_activity_time:desc',
);
});
});
describe('parseListOrderBy', () => {
it('splits a select value into an order payload', () => {
expect(parseListOrderBy('durationNano:asc')).toStrictEqual({
columnName: 'durationNano',
order: 'asc',
});
});
});
describe('setListOrderBy', () => {
it('replaces the order on every queryData entry', () => {
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
const next = setListOrderBy(base, 'severity_text:asc');
next.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([
{ columnName: 'severity_text', order: 'asc' },
]);
});
});
it('does not mutate the query it is given', () => {
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
setListOrderBy(base, 'timestamp:asc');
expect(base.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
});
});

View File

@@ -0,0 +1,45 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
const withParam = (value?: string): void => {
const search = new URLSearchParams();
if (value !== undefined) {
search.set('panelTypes', value);
}
__setSearchParamsGetterForTest(() => search);
};
describe('readPanelTypeFromUrl', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it('decodes the JSON-encoded value the app writes', () => {
withParam(JSON.stringify(PANEL_TYPES.TIME_SERIES));
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.TIME_SERIES);
});
it('accepts an unencoded value from a hand-written link', () => {
withParam('list');
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.LIST);
});
it('returns the raw value instead of throwing on a malformed one', () => {
withParam('{list');
expect(readPanelTypeFromUrl()).toBe('{list');
});
it('returns the fallback when the param is absent', () => {
withParam();
expect(readPanelTypeFromUrl(PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
expect(readPanelTypeFromUrl()).toBeNull();
});
});

View File

@@ -1,8 +1,69 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getParsedAggregationOptionsForOrderBy } from './aggregationConverter';
export const DEFAULT_LIST_ORDER_BY = 'timestamp:desc';
export const getRawPanelOrderBy = (query: Query | null): OrderByPayload[] => {
const queryData = query?.builder?.queryData?.[0];
if (!queryData) {
return [];
}
const disallowed = new Set<string>([SIGNOZ_VALUE]);
getParsedAggregationOptionsForOrderBy(queryData).forEach((aggregation) => {
if (aggregation?.key) {
disallowed.add(aggregation.key);
}
});
return (queryData.orderBy ?? []).filter(
(item) => !disallowed.has(item.columnName),
);
};
export const getListOrderBy = (
query: Query | null,
fallback: string = DEFAULT_LIST_ORDER_BY,
): string => {
const [orderBy] = getRawPanelOrderBy(query);
if (!orderBy?.columnName || !orderBy?.order) {
return fallback;
}
return `${orderBy.columnName}:${orderBy.order.toLowerCase()}`;
};
export const parseListOrderBy = (orderBy: string): OrderByPayload => {
const [columnName, order] = orderBy.split(':');
return {
columnName: columnName || 'timestamp',
order: order || 'desc',
};
};
export const setListOrderBy = (query: Query, orderBy: string): Query => {
const nextOrderBy = [parseListOrderBy(orderBy)];
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...item,
orderBy: nextOrderBy,
})),
},
};
};
// Mapping between panel types and explorer views
export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {

View File

@@ -0,0 +1,21 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getUnstableCurrentSearchParams } from './getUnstableCurrentSearchParams';
// The param is JSON-encoded, but a hand-written link can carry it unencoded.
export const readPanelTypeFromUrl = (
fallback: PANEL_TYPES | null = null,
): PANEL_TYPES | null => {
const raw = getUnstableCurrentSearchParams().get(QueryParams.panelTypes);
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw) as PANEL_TYPES;
} catch {
return raw as PANEL_TYPES;
}
};