Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi kumar
c39d978601 Merge branch 'main' into feat/qb-dual-mode 2026-06-12 17:25:07 +05:30
Abhi Kumar
8e9857ccfa feat(hooks): add useBeforeUnloadWarning
Generic hook that raises the browser's native unsaved-changes confirmation on
refresh / tab-close while `enabled` is true. Reusable guard for any surface
holding unsaved in-memory state; in-app navigation is expected to be guarded
separately (e.g. a discard-changes modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:21:10 +05:30
Abhi Kumar
3c9c3c98b1 feat(query-builder): dual-mode staged-query persistence (url + memory)
Introduce a CompositeQueryStore strategy so the query builder can persist its
staged query either in the compositeQuery URL param (default, unchanged) or in
React state (memory mode), selected via a nested QueryBuilderProvider.

- extract pure wire-format helpers into lib/compositeQuery
  (migrate / serialize / normalize) as the single source of truth
- add url + memory store implementations behind a shared interface
- provider consumes a store instead of touching the URL directly
- expose `mode` and `committedQuery` on the query builder context
- make useShareBuilderUrl store-agnostic (seeds via committedQuery, so it
  no longer navigates in memory mode)

Behavior-neutral for all existing url-mode surfaces. Memory mode has no
consumer yet; the dashboard panel editor adoption lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:21:10 +05:30
30 changed files with 1036 additions and 358 deletions

View File

@@ -106,6 +106,8 @@ describe('MetricsSelect - signal source switching (standalone)', () => {
};
(mockedUseQueryBuilder as any).mockReturnValue({
mode: 'url',
committedQuery: null,
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
@@ -263,6 +265,8 @@ describe('DataSource change - Logs to Traces', () => {
mockedUseQueryBuilder.mockReset();
mockedUseQueryBuilder.mockReturnValue({
mode: 'url',
committedQuery: null,
currentQuery: logsCurrentQuery,
stagedQuery: null,
lastUsedQuery: null,

View File

@@ -796,7 +796,7 @@ export const getClusterMetricsQueryPayload = (
key: k8sDeploymentDesiredKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
@@ -839,7 +839,7 @@ export const getClusterMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'latest',
timeAggregation: 'avg',
},
],
queryFormulas: [],

View File

@@ -40,7 +40,6 @@ import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
@@ -168,25 +167,17 @@ function EntityEventsContent({
[events],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const columns: TableColumnsType<EventDataType> = [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
];
const handleExpandRowIcon = ({
expanded,

View File

@@ -41,7 +41,6 @@ import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
@@ -137,11 +136,7 @@ function EntityTracesContent({
[timeRange.startTime, timeRange.endTime, userExpression],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =

View File

@@ -1,14 +1,15 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,
} from 'container/TracesExplorer/ListView/utils';
import dayjs from 'dayjs';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -58,7 +59,6 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
export const getTraceListColumns = (
selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
@@ -73,8 +73,8 @@ export const getTraceListColumns = (
if (primaryKey === 'timestamp') {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(value)
: formatTimezoneAdjustedTimestamp(value / 1e6);
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>

View File

@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
spaceAggregation: 'avg',
stepInterval: 60,
timeAggregation: 'avg',
},

View File

@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'capacity',
header: 'Capacity',
header: 'Volume Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'usage',
header: 'Used',
header: 'Volume Utilization',
accessorFn: (row): number => row.volumeUsage,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'available',
header: 'Available',
header: 'Volume Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
@@ -141,61 +141,4 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
);
},
},
{
id: 'inodes',
header: 'Inodes',
accessorFn: (row): number => row.volumeInodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodes = value as number;
return (
<ValidateColumnValueWrapper
value={inodes}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes metric"
>
<TanStackTable.Text>{inodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesUsed',
header: 'Inodes Used',
accessorFn: (row): number => row.volumeInodesUsed,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesUsed = value as number;
return (
<ValidateColumnValueWrapper
value={inodesUsed}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes used metric"
>
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesFree',
header: 'Inodes Free',
accessorFn: (row): number => row.volumeInodesFree,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesFree = value as number;
return (
<ValidateColumnValueWrapper
value={inodesFree}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes free metric"
>
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -388,6 +388,8 @@ function LogsExplorerWithMockContext({
const contextValue = React.useMemo(
() => ({
mode: 'url' as const,
committedQuery: stagedQuery,
isDefaultQuery: (): boolean => false,
currentQuery,
stagedQuery,

View File

@@ -61,6 +61,8 @@ export const logsQueryRangeSuccessNewFormatResponse = {
};
export const mockQueryBuilderContextValue = {
mode: 'url' as const,
committedQuery: null,
isDefaultQuery: (): boolean => false,
currentQuery: {
...initialQueriesMap.logs,

View File

@@ -0,0 +1,38 @@
import { renderHook } from '@testing-library/react';
import useBeforeUnloadWarning from '../useBeforeUnloadWarning';
const dispatchBeforeUnload = (): Event => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event;
};
describe('useBeforeUnloadWarning', () => {
it('prevents unload while enabled', () => {
renderHook(() => useBeforeUnloadWarning(true));
expect(dispatchBeforeUnload().defaultPrevented).toBe(true);
});
it('does not prevent unload while disabled', () => {
renderHook(() => useBeforeUnloadWarning(false));
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
});
it('tracks enabled changes and cleans up on unmount', () => {
const { rerender, unmount } = renderHook(
({ enabled }) => useBeforeUnloadWarning(enabled),
{ initialProps: { enabled: false } },
);
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
rerender({ enabled: true });
expect(dispatchBeforeUnload().defaultPrevented).toBe(true);
unmount();
expect(dispatchBeforeUnload().defaultPrevented).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import { act, renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useMemoryCompositeQueryStore } from '../useMemoryCompositeQueryStore';
const legacyLogsQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
having: [{ columnName: 'count()', op: '>', value: 100 }],
},
],
},
};
describe('useMemoryCompositeQueryStore', () => {
it('starts empty when no initialQuery is provided', () => {
const { result } = renderHook(() => useMemoryCompositeQueryStore({}));
expect(result.current.mode).toBe('memory');
expect(result.current.query).toBeNull();
expect(result.current.panelType).toBeNull();
});
it('seeds query (with legacy-format migration) and panelType', () => {
const { result } = renderHook(() =>
useMemoryCompositeQueryStore({
initialQuery: legacyLogsQuery,
initialPanelType: PANEL_TYPES.TABLE,
}),
);
expect(result.current.query?.id).toBe(legacyLogsQuery.id);
expect(result.current.query?.builder.queryData[0].having).toStrictEqual({
expression: 'count() > 100',
});
expect(result.current.panelType).toBe(PANEL_TYPES.TABLE);
});
it('updates query and notifies onCommit when a query is committed', () => {
const onCommit = jest.fn();
const { result } = renderHook(() =>
useMemoryCompositeQueryStore({ onCommit }),
);
act(() => {
result.current.commit(initialQueriesMap.logs);
});
expect(result.current.query).toBe(initialQueriesMap.logs);
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledWith(initialQueriesMap.logs);
});
it('ignores URL-mode commit options but still stores the query', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => useMemoryCompositeQueryStore({}));
act(() => {
result.current.commit(initialQueriesMap.logs, {
redirectingUrl: '/traces-explorer' as never,
newTab: true,
});
});
expect(result.current.query).toBe(initialQueriesMap.logs);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serializeCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { useUrlCompositeQueryStore } from '../useUrlCompositeQueryStore';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
function createWrapper(
initialUrl: string,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return React.createElement(
MemoryRouter,
{ initialEntries: [initialUrl] },
children,
) as JSX.Element;
};
}
function getNavigatedParams(): URLSearchParams {
const [navigatedUrl] = mockSafeNavigate.mock.calls[0];
return new URLSearchParams(navigatedUrl.split('?')[1]);
}
describe('useUrlCompositeQueryStore', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
});
it('reads the committed query and panelType from the URL', () => {
const initialParams = new URLSearchParams();
initialParams.set(
QueryParams.compositeQuery,
serializeCompositeQueryParam(initialQueriesMap.logs),
);
initialParams.set(QueryParams.panelTypes, PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper(`/logs-explorer?${initialParams.toString()}`),
});
expect(result.current.mode).toBe('url');
expect(result.current.query?.id).toBe(initialQueriesMap.logs.id);
expect(result.current.panelType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('returns null query and panelType when the params are absent', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
expect(result.current.query).toBeNull();
expect(result.current.panelType).toBeNull();
});
it('commits the query to the URL, resets pagination and drops activeLogId', () => {
const initialParams = new URLSearchParams();
initialParams.set(
QueryParams.pagination,
JSON.stringify({ limit: 25, offset: 50 }),
);
initialParams.set(QueryParams.activeLogId, 'some-log-id');
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper(`/logs-explorer?${initialParams.toString()}`),
});
act(() => {
result.current.commit(initialQueriesMap.logs);
});
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [navigatedUrl, navigateOptions] = mockSafeNavigate.mock.calls[0];
expect(navigatedUrl.startsWith('/logs-explorer?')).toBe(true);
expect(navigateOptions).toStrictEqual({ newTab: undefined });
const params = getNavigatedParams();
expect(params.get(QueryParams.compositeQuery)).toBe(
serializeCompositeQueryParam(initialQueriesMap.logs),
);
expect(
JSON.parse(params.get(QueryParams.pagination) as string),
).toStrictEqual({ limit: 25, offset: 0 });
expect(params.get(QueryParams.activeLogId)).toBeNull();
});
it('honors redirectingUrl, stringified searchParams and newTab', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
act(() => {
result.current.commit(initialQueriesMap.logs, {
searchParams: { [QueryParams.panelTypes]: PANEL_TYPES.TIME_SERIES },
redirectingUrl: ROUTES.TRACES_EXPLORER,
newTab: true,
});
});
const [navigatedUrl, navigateOptions] = mockSafeNavigate.mock.calls[0];
expect(navigatedUrl.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
expect(navigateOptions).toStrictEqual({ newTab: true });
const params = getNavigatedParams();
expect(params.get(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
});
it('passes searchParams through raw when shouldNotStringify is set', () => {
const { result } = renderHook(() => useUrlCompositeQueryStore(), {
wrapper: createWrapper('/logs-explorer'),
});
act(() => {
result.current.commit(initialQueriesMap.logs, {
searchParams: { [QueryParams.panelTypes]: PANEL_TYPES.TIME_SERIES },
shouldNotStringify: true,
});
});
const params = getNavigatedParams();
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
});
});

View File

@@ -0,0 +1,31 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CompositeQueryStoreMode } from 'types/common/queryBuilder';
export interface CommitCompositeQueryOptions {
searchParams?: Record<string, unknown>;
redirectingUrl?: (typeof ROUTES)[keyof typeof ROUTES];
shouldNotStringify?: boolean;
newTab?: boolean;
}
/**
* Storage strategy for the staged composite query. The provider edits
* `currentQuery` in React state; on stage/run the normalized query is
* committed to a store. The `url` store persists it in the
* `compositeQuery` URL param (shareable, survives reloads); the `memory`
* store keeps it in React state (clean URLs, host-controlled lifetime).
*/
export interface CompositeQueryStore {
mode: CompositeQueryStoreMode;
/** Parsed + migrated committed query, or null when none is committed. */
query: Query | null;
/** Panel type associated with the committed query, when known. */
panelType: PANEL_TYPES | null;
/**
* Persists an already-normalized query (see normalizeCompositeQuery).
* Options are url-mode navigation concerns; the memory store ignores them.
*/
commit: (query: Query, options?: CommitCompositeQueryOptions) => void;
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { migrateCompositeQuery } from 'lib/compositeQuery/migrateCompositeQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CommitCompositeQueryOptions, CompositeQueryStore } from './types';
export interface UseMemoryCompositeQueryStoreProps {
initialQuery?: Query;
initialPanelType?: PANEL_TYPES;
onCommit?: (query: Query) => void;
}
/**
* CompositeQueryStore backed by React state — the staged query never
* touches the URL. The seed query goes through the same legacy-format
* migration as URL parsing, so hosts can pass queries loaded from saved
* sources (alert rules, dashboard specs) as is. The store lives and dies
* with the provider that mounts it.
*/
export const useMemoryCompositeQueryStore = ({
initialQuery,
initialPanelType,
onCommit,
}: UseMemoryCompositeQueryStoreProps): CompositeQueryStore => {
const [query, setQuery] = useState<Query | null>(() =>
initialQuery ? migrateCompositeQuery(initialQuery) : null,
);
const [panelType] = useState<PANEL_TYPES | null>(initialPanelType ?? null);
const onCommitRef = useRef(onCommit);
useEffect(() => {
onCommitRef.current = onCommit;
}, [onCommit]);
const commit = useCallback(
(committedQuery: Query, options?: CommitCompositeQueryOptions): void => {
if (
process.env.NODE_ENV !== 'production' &&
options &&
(options.redirectingUrl || options.searchParams || options.newTab)
) {
// eslint-disable-next-line no-console
console.warn(
'[QueryBuilder] memory mode ignores URL commit options:',
options,
);
}
setQuery(committedQuery);
onCommitRef.current?.(committedQuery);
},
[],
);
return useMemo(
() => ({ mode: 'memory', query, panelType, commit }),
[query, panelType, commit],
);
};

View File

@@ -0,0 +1,78 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { CommitCompositeQueryOptions, CompositeQueryStore } from './types';
/**
* CompositeQueryStore backed by the `compositeQuery` URL param — the
* default, shareable storage for the staged query.
*/
export const useUrlCompositeQueryStore = (): CompositeQueryStore => {
const urlQuery = useUrlQuery();
const location = useLocation();
const query = useGetCompositeQueryParam();
const { safeNavigate } = useSafeNavigate({
preventSameUrlNavigation: false,
});
const panelType = urlQuery.get(QueryParams.panelTypes) as PANEL_TYPES | null;
const commit = useCallback(
(committedQuery: Query, options?: CommitCompositeQueryOptions): void => {
const { searchParams, redirectingUrl, shouldNotStringify, newTab } =
options || {};
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
const parsedPagination = JSON.parse(pagination);
urlQuery.set(
QueryParams.pagination,
JSON.stringify({
limit: parsedPagination.limit,
offset: 0,
}),
);
}
urlQuery.set(
QueryParams.compositeQuery,
serializeCompositeQueryParam(committedQuery),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);
const generatedUrl = redirectingUrl
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
},
[location.pathname, safeNavigate, urlQuery],
);
return useMemo(
() => ({ mode: 'url', query, panelType, commit }),
[query, panelType, commit],
);
};

View File

@@ -1,72 +1,14 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { parseCompositeQueryParam } from 'lib/compositeQuery/compositeQuerySerialization';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(
() => parseCompositeQueryParam(urlQuery.get(QueryParams.compositeQuery)),
[urlQuery],
);
};

View File

@@ -1,35 +1,36 @@
import { useEffect } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetCompositeQueryParam } from './useGetCompositeQueryParam';
import { useQueryBuilder } from './useQueryBuilder';
export type UseShareBuilderUrlParams = {
defaultValue: Query;
/** Force reset the query regardless of URL state */
/** Force reset the query regardless of the committed query state */
forceReset?: boolean;
};
/**
* Seeds the query builder with a default query when nothing is committed
* yet. In url mode this writes the `compositeQuery` URL param (preserving
* the original "share builder url" behavior); in memory mode it commits to
* the in-memory store without touching the URL.
*/
export const useShareBuilderUrl = ({
defaultValue,
forceReset = false,
}: UseShareBuilderUrlParams): void => {
const { resetQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const urlQuery = useUrlQuery();
const compositeQuery = useGetCompositeQueryParam();
const { committedQuery, resetQuery, redirectWithQueryBuilderData } =
useQueryBuilder();
useEffect(() => {
if (!compositeQuery || forceReset) {
if (!committedQuery || forceReset) {
resetQuery(defaultValue);
redirectWithQueryBuilderData(defaultValue);
}
}, [
defaultValue,
urlQuery,
redirectWithQueryBuilderData,
compositeQuery,
committedQuery,
resetQuery,
forceReset,
]);

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Shows the browser's native "unsaved changes" confirmation when the user
* refreshes or closes the tab while `enabled` is true. In-app navigation is
* unaffected — guard that separately (e.g. with a discard-changes modal).
*/
const useBeforeUnloadWarning = (enabled: boolean): void => {
useEffect(() => {
if (!enabled) {
return undefined;
}
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault();
// Chrome requires returnValue to be set for the dialog to appear
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return (): void =>
window.removeEventListener('beforeunload', handleBeforeUnload);
}, [enabled]);
};
export default useBeforeUnloadWarning;

View File

@@ -22,13 +22,11 @@ interface CacheEntry {
const CACHE_SIZE_LIMIT = 1000;
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
export type FormatTimezoneAdjustedTimestamp = (
input: TimestampInput,
format?: string,
) => string;
function useTimezoneFormatter({ userTimezone }: { userTimezone: Timezone }): {
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
} {
// Initialize cache using useMemo to persist between renders
const cache = useMemo(() => new Map<string, CacheEntry>(), []);

View File

@@ -0,0 +1,185 @@
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
parseCompositeQueryParam,
serializeCompositeQueryParam,
} from '../compositeQuerySerialization';
import { migrateCompositeQuery } from '../migrateCompositeQuery';
import { normalizeCompositeQuery } from '../normalizeCompositeQuery';
const legacyLogsQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
aggregations: undefined,
aggregateOperator: 'count',
filter: undefined,
filters: {
items: [
{
id: 'service-name-filter',
key: {
id: 'service.name',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'frontend',
},
],
op: 'AND',
},
having: [{ columnName: 'count()', op: '>', value: 100 }],
},
],
},
};
describe('migrateCompositeQuery', () => {
it('converts legacy filters array to filter expression', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
const { filter } = migrated.builder.queryData[0];
expect(filter?.expression).toContain('service.name');
expect(filter?.expression).toContain('frontend');
});
it('converts legacy having array to having expression', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
expect(migrated.builder.queryData[0].having).toStrictEqual({
expression: 'count() > 100',
});
});
it('converts legacy aggregateOperator to aggregations', () => {
const migrated = migrateCompositeQuery(legacyLogsQuery);
expect(migrated.builder.queryData[0].aggregations).toStrictEqual([
{ expression: 'count()' },
]);
});
it('keeps existing aggregations and filter expression untouched', () => {
const newFormatQuery: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: "severity_text = 'ERROR'" },
},
],
},
};
const migrated = migrateCompositeQuery(newFormatQuery);
expect(migrated.builder.queryData[0].aggregations).toStrictEqual(
newFormatQuery.builder.queryData[0].aggregations,
);
expect(migrated.builder.queryData[0].filter?.expression).toBe(
"severity_text = 'ERROR'",
);
});
it('returns the query as is when builder queryData is missing', () => {
const queryWithoutBuilder = {
queryType: EQueryType.PROM,
} as unknown as Query;
expect(migrateCompositeQuery(queryWithoutBuilder)).toBe(queryWithoutBuilder);
});
});
describe('parseCompositeQueryParam / serializeCompositeQueryParam', () => {
it('round-trips a composite query through serialize and parse', () => {
const query: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: "key1 = 'a+b' AND key2 = 'c d'" },
},
],
},
};
const parsed = parseCompositeQueryParam(serializeCompositeQueryParam(query));
expect(parsed?.id).toBe(query.id);
expect(parsed?.queryType).toBe(query.queryType);
expect(parsed?.builder.queryData[0].filter?.expression).toBe(
"key1 = 'a+b' AND key2 = 'c d'",
);
});
it('treats literal + characters as spaces (legacy URL encoding)', () => {
const serialized = serializeCompositeQueryParam(initialQueriesMap.logs);
const legacyEncoded = serialized.replace(/%20/g, '+');
const parsed = parseCompositeQueryParam(legacyEncoded);
expect(parsed?.id).toBe(initialQueriesMap.logs.id);
});
it('returns null for missing or unparseable values', () => {
expect(parseCompositeQueryParam(null)).toBeNull();
expect(parseCompositeQueryParam('')).toBeNull();
expect(parseCompositeQueryParam('not-a-json')).toBeNull();
});
});
describe('normalizeCompositeQuery', () => {
it('fills defaults for an empty partial query', () => {
const normalized = normalizeCompositeQuery({});
expect(normalized.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(normalized.builder).toBe(initialQueryState.builder);
expect(normalized.promql).toBe(initialQueryState.promql);
expect(normalized.clickhouse_sql).toBe(initialQueryState.clickhouse_sql);
expect(normalized.unit).toBe(initialQueryState.unit);
});
it('falls back to QUERY_BUILDER for an invalid queryType', () => {
const normalized = normalizeCompositeQuery({
queryType: 'bogus' as unknown as EQueryType,
});
expect(normalized.queryType).toBe(EQueryType.QUERY_BUILDER);
});
it('falls back to defaults for empty builder, promql and clickhouse queries', () => {
const normalized = normalizeCompositeQuery({
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
promql: [],
clickhouse_sql: [],
});
expect(normalized.builder).toBe(initialQueryState.builder);
expect(normalized.promql).toBe(initialQueryState.promql);
expect(normalized.clickhouse_sql).toBe(initialQueryState.clickhouse_sql);
});
it('preserves provided values and stamps a fresh id on every call', () => {
const query = initialQueriesMap.logs;
const first = normalizeCompositeQuery(query);
const second = normalizeCompositeQuery(query);
expect(first.queryType).toBe(query.queryType);
expect(first.builder).toBe(query.builder);
expect(first.id).not.toBe(query.id);
expect(first.id).not.toBe(second.id);
});
});

View File

@@ -0,0 +1,35 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { migrateCompositeQuery } from './migrateCompositeQuery';
/**
* Parses a raw `compositeQuery` URL param value into a Query, migrating
* old-format queries to the new format. Returns null when the value is
* missing or unparseable.
*/
export const parseCompositeQueryParam = (
compositeQuery: string | null,
): Query | null => {
if (!compositeQuery) {
return null;
}
try {
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
const parsedCompositeQuery: Query = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
return migrateCompositeQuery(parsedCompositeQuery);
} catch (e) {
return null;
}
};
/**
* Serializes a Query into the URL-safe string stored in the
* `compositeQuery` URL param. Inverse of parseCompositeQueryParam.
*/
export const serializeCompositeQueryParam = (query: Query): string =>
encodeURIComponent(JSON.stringify(query));

View File

@@ -0,0 +1,60 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Converts a composite query from the old format to the new format for each
* query in builder.queryData:
* - `filters` array -> `filter.expression`
* - `having` array -> `having` expression object
* - `aggregateOperator` -> `aggregations` object
*
* Queries already in the new format pass through unchanged.
*/
export const migrateCompositeQuery = (query: Query): Query => {
if (!query?.builder?.queryData) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
const existingExpression = queryData.filter?.expression || '';
const convertedQuery = { ...queryData };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
queryData.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(queryData.having)) {
convertedQuery.having = convertHavingToExpression(queryData.having);
}
// Convert aggregation if needed
if (!queryData.aggregations && queryData.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: queryData.aggregateOperator,
aggregateAttribute: queryData.aggregateAttribute as BaseAutocompleteData,
dataSource: queryData.dataSource,
timeAggregation: queryData.timeAggregation,
spaceAggregation: queryData.spaceAggregation,
reduceTo: queryData.reduceTo,
temporality: queryData.temporality,
}) as IBuilderQuery['aggregations']; // Narrow the mixed aggregation array to the union of homogeneous arrays
}
return convertedQuery;
}),
},
};
};

View File

@@ -0,0 +1,40 @@
import { initialQueryState } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
/**
* Fills a partial composite query with defaults from initialQueryState and
* stamps a fresh id. This is the normalization applied before a query is
* committed (staged) — extracted from redirectWithQueryBuilderData.
*/
export const normalizeCompositeQuery = (query: Partial<Query>): Query => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
? EQueryType.QUERY_BUILDER
: query.queryType;
const builder =
!query.builder || query.builder.queryData?.length === 0
? initialQueryState.builder
: query.builder;
const promql =
!query.promql || query.promql.length === 0
? initialQueryState.promql
: query.promql;
const clickhouseSql =
!query.clickhouse_sql || query.clickhouse_sql.length === 0
? initialQueryState.clickhouse_sql
: query.clickhouse_sql;
return {
queryType,
builder,
promql,
clickhouse_sql: clickhouseSql,
id: uuid(),
unit: query.unit || initialQueryState.unit,
};
};

View File

@@ -177,6 +177,8 @@ describe('Logs Explorer Tests', () => {
>
<QueryBuilderContext.Provider
value={{
mode: 'url',
committedQuery: null,
isDefaultQuery: (): boolean => false,
currentQuery: {
...initialQueriesMap.metrics,

View File

@@ -10,7 +10,6 @@ import {
} from 'react';
import { useLocation } from 'react-router-dom';
import { isQueryUpdatedInView } from 'components/ExplorerCard/utils';
import { QueryParams } from 'constants/query';
import {
alphabet,
baseAutoCompleteIdKeysOrder,
@@ -21,7 +20,6 @@ import {
initialQueryBuilderFormTraceOperatorValues,
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
initialQueryState,
initialSingleQueryMap,
MAX_FORMULAS,
MAX_QUERIES,
@@ -34,10 +32,10 @@ import {
PartialPanelTypes,
} from 'container/NewWidget/utils';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useMemoryCompositeQueryStore } from 'hooks/queryBuilder/compositeQueryStore/useMemoryCompositeQueryStore';
import { useUrlCompositeQueryStore } from 'hooks/queryBuilder/compositeQueryStore/useUrlCompositeQueryStore';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { normalizeCompositeQuery } from 'lib/compositeQuery/normalizeCompositeQuery';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -65,9 +63,10 @@ import {
ReduceOperators,
} from 'types/common/queryBuilder';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
import { v4 as uuid } from 'uuid';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
mode: 'url',
committedQuery: null,
currentQuery: initialQueriesMap.metrics,
supersetQuery: initialQueriesMap.metrics,
lastUsedQuery: null,
@@ -102,10 +101,28 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
isDefaultQuery: () => false,
});
export function QueryBuilderProvider({
children,
}: PropsWithChildren): JSX.Element {
const urlQuery = useUrlQuery();
export type QueryBuilderProviderProps = PropsWithChildren<
| {
/** Default: the staged query is stored in the `compositeQuery` URL param. */
mode?: 'url';
}
| {
/** The staged query is kept in memory — the URL is never touched. */
mode: 'memory';
/** Seed for the staged query; runs the same legacy-format migration as URL parsing. */
initialQuery?: Query;
initialPanelType?: PANEL_TYPES;
/** Fired with the normalized query every time it is staged (Stage & Run). */
onStagedQueryChange?: (query: Query) => void;
}
>;
export function QueryBuilderProvider(
props: QueryBuilderProviderProps,
): JSX.Element {
const { children } = props;
const memoryModeProps = props.mode === 'memory' ? props : null;
const location = useLocation();
const currentPathnameRef = useRef<string | null>(location.pathname);
@@ -114,7 +131,18 @@ export function QueryBuilderProvider({
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
useState<boolean>(false);
const compositeQueryParam = useGetCompositeQueryParam();
// Rules of hooks: both store hooks always run; only the selected one is used.
const urlCompositeQueryStore = useUrlCompositeQueryStore();
const memoryCompositeQueryStore = useMemoryCompositeQueryStore({
initialQuery: memoryModeProps?.initialQuery,
initialPanelType: memoryModeProps?.initialPanelType,
onCommit: memoryModeProps?.onStagedQueryChange,
});
const compositeQueryStore = memoryModeProps
? memoryCompositeQueryStore
: urlCompositeQueryStore;
const compositeQueryParam = compositeQueryStore.query;
const { queryType: queryTypeParam, ...queryState } =
compositeQueryParam || initialQueriesMap.metrics;
@@ -122,12 +150,8 @@ export function QueryBuilderProvider({
null,
);
const panelTypeQueryParams = urlQuery.get(
QueryParams.panelTypes,
) as PANEL_TYPES | null;
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(
panelTypeQueryParams,
compositeQueryStore.panelType,
);
const [currentQuery, setCurrentQuery] = useState<QueryState>(queryState);
@@ -935,9 +959,7 @@ export function QueryBuilderProvider({
[panelType, stagedQuery],
);
const { safeNavigate } = useSafeNavigate({
preventSameUrlNavigation: false,
});
const { commit: commitCompositeQuery } = compositeQueryStore;
const redirectWithQueryBuilderData = useCallback(
(
@@ -947,74 +969,14 @@ export function QueryBuilderProvider({
shouldNotStringify?: boolean,
newTab?: boolean,
) => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
? EQueryType.QUERY_BUILDER
: query.queryType;
const builder =
!query.builder || query.builder.queryData?.length === 0
? initialQueryState.builder
: query.builder;
const promql =
!query.promql || query.promql.length === 0
? initialQueryState.promql
: query.promql;
const clickhouseSql =
!query.clickhouse_sql || query.clickhouse_sql.length === 0
? initialQueryState.clickhouse_sql
: query.clickhouse_sql;
const currentGeneratedQuery: Query = {
queryType,
builder,
promql,
clickhouse_sql: clickhouseSql,
id: uuid(),
unit: query.unit || initialQueryState.unit,
};
const pagination = urlQuery.get(QueryParams.pagination);
if (pagination) {
const parsedPagination = JSON.parse(pagination);
urlQuery.set(
QueryParams.pagination,
JSON.stringify({
limit: parsedPagination.limit,
offset: 0,
}),
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);
const generatedUrl = redirectingUrl
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
commitCompositeQuery(normalizeCompositeQuery(query), {
searchParams,
redirectingUrl,
shouldNotStringify,
newTab,
});
},
[location.pathname, safeNavigate, urlQuery],
[commitCompositeQuery],
);
const handleSetConfig = useCallback(
@@ -1075,12 +1037,16 @@ export function QueryBuilderProvider({
if (location.pathname !== currentPathnameRef.current) {
currentPathnameRef.current = location.pathname;
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
setCalledFromHandleRunQuery(false);
// In memory mode the store lives and dies with the provider mount,
// so navigation must not clear the staged query.
if (compositeQueryStore.mode === 'url') {
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
setCalledFromHandleRunQuery(false);
}
}
}, [location.pathname]);
}, [location.pathname, compositeQueryStore.mode]);
// Separate useEffect to handle initQueryBuilderData after pathname changes
useEffect(() => {
@@ -1159,6 +1125,8 @@ export function QueryBuilderProvider({
const contextValues: QueryBuilderContextType = useMemo(
() => ({
mode: compositeQueryStore.mode,
committedQuery: compositeQueryStore.query,
currentQuery: query,
supersetQuery: superQuery,
lastUsedQuery,
@@ -1193,6 +1161,8 @@ export function QueryBuilderProvider({
isStagedQueryUpdated,
}),
[
compositeQueryStore.mode,
compositeQueryStore.query,
query,
superQuery,
lastUsedQuery,

View File

@@ -19,14 +19,17 @@ import {
} from 'components/CustomTimePicker/timezoneUtils';
import { LOCALSTORAGE } from 'constants/localStorage';
import useTimezoneFormatter, {
FormatTimezoneAdjustedTimestamp,
TimestampInput,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
export interface TimezoneContextType {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: Dispatch<SetStateAction<boolean>>;
}

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryBuilderProvider } from '../QueryBuilder';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockOnStagedQueryChange = jest.fn();
function createMemoryModeWrapper(
initialQuery?: Query,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<MemoryRouter initialEntries={['/dashboard/test-dashboard']}>
<QueryBuilderProvider
mode="memory"
initialQuery={initialQuery}
initialPanelType={PANEL_TYPES.TABLE}
onStagedQueryChange={mockOnStagedQueryChange}
>
{children}
</QueryBuilderProvider>
</MemoryRouter>
);
};
}
describe('QueryBuilderProvider in memory mode', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
mockOnStagedQueryChange.mockClear();
});
it('exposes memory mode and seeds staged/current query from initialQuery', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
expect(result.current.mode).toBe('memory');
expect(result.current.panelType).toBe(PANEL_TYPES.TABLE);
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id);
expect(result.current.currentQuery.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).not.toHaveBeenCalled();
});
it('stages on handleRunQuery without navigating and notifies the host', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
const seededStagedQueryId = result.current.stagedQuery?.id;
act(() => {
result.current.handleRunQuery();
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).toHaveBeenCalledTimes(1);
const stagedByHost = mockOnStagedQueryChange.mock.calls[0][0];
expect(stagedByHost.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
expect(result.current.stagedQuery?.id).toBe(stagedByHost.id);
expect(result.current.stagedQuery?.id).not.toBe(seededStagedQueryId);
});
it('useShareBuilderUrl seeds the default query in memory without navigating', () => {
const { result } = renderHook(
(): QueryBuilderContextType => {
useShareBuilderUrl({ defaultValue: initialQueriesMap.traces });
return useQueryBuilder();
},
{ wrapper: createMemoryModeWrapper() },
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(result.current.committedQuery).not.toBeNull();
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.traces.builder.queryData[0].dataSource,
);
});
it('useShareBuilderUrl does not clobber a seeded initialQuery', () => {
const { result } = renderHook(
(): QueryBuilderContextType => {
useShareBuilderUrl({ defaultValue: initialQueriesMap.traces });
return useQueryBuilder();
},
{ wrapper: createMemoryModeWrapper(initialQueriesMap.logs) },
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.logs.builder.queryData[0].dataSource,
);
});
it('redirectWithQueryBuilderData commits in memory without navigating', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: createMemoryModeWrapper(initialQueriesMap.logs),
});
act(() => {
result.current.redirectWithQueryBuilderData(initialQueriesMap.traces);
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(mockOnStagedQueryChange).toHaveBeenCalledTimes(1);
expect(result.current.stagedQuery?.builder.queryData[0].dataSource).toBe(
initialQueriesMap.traces.builder.queryData[0].dataSource,
);
});
});

View File

@@ -233,7 +233,16 @@ export type QueryBuilderData = {
queryTraceOperator: IBuilderTraceOperator[];
};
export type CompositeQueryStoreMode = 'url' | 'memory';
export type QueryBuilderContextType = {
/** Where the staged query is persisted: the URL (default) or in memory. */
mode: CompositeQueryStoreMode;
/**
* The query currently persisted in the store (URL param or memory),
* synchronously available — unlike stagedQuery, which is set by effects.
*/
committedQuery: Query | null;
currentQuery: Query;
stagedQuery: Query | null;
lastUsedQuery: number | null;

View File

@@ -62,7 +62,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
numericColsCount := 0
for i, ct := range colTypes {
slots[i] = reflect.New(ct.ScanType()).Interface()
if isNumericKind(ct.ScanType()) {
if numericKind(ct.ScanType().Kind()) {
numericColsCount++
}
}
@@ -270,14 +270,8 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
}, nil
}
func isNumericKind(t reflect.Type) bool {
if t == nil {
return false
}
for t.Kind() == reflect.Ptr || t.Kind() == reflect.UnsafePointer {
t = t.Elem()
}
switch t.Kind() {
func numericKind(k reflect.Kind) bool {
switch k {
case reflect.Float32, reflect.Float64,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
@@ -296,13 +290,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
var aggIndex int64
for i, name := range colNames {
colType := qbtypes.ColumnTypeGroup
// Builder queries aliases aggregation columns as __result_N (always numeric) and wraps group-by keys with toString (always string);
// Raw ClickHouse queries may use any aliases.
// Handling Builder queries, If name like __result_N -> aggregation, otherwise group-by column
// Handling Raw ClickHouse queries, If type is numeric -> aggregation, otherwise group-by column
// NOTE: For clickhouse queries, its wrong to assume that numeric columns are always aggregations, user might be grouping by on integer status_code.
// However, we are fine with this for now. If need arises, simplest way would be to solve this on the frontend side by asking user a mapping of column names to column types.
if aggRe.MatchString(name) || isNumericKind(colTypes[i].ScanType()) {
if aggRe.MatchString(name) {
colType = qbtypes.ColumnTypeAggregation
}
cd[i] = &qbtypes.ColumnDescriptor{

View File

@@ -1,74 +0,0 @@
"""
Integration tests for raw ClickHouse SQL queries in the querier.
"""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""A numeric column aliased ``__result_0`` is classified as an aggregation."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = querier.make_query_request(
signoz,
token,
int((now - timedelta(hours=1)).timestamp() * 1000),
int(now.timestamp() * 1000),
[
{
"type": "clickhouse_sql",
"spec": {
"name": "A",
"query": "SELECT toFloat64(1.5) AS `__result_0`",
"disabled": False,
},
}
],
request_type=querier.RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
columns = querier.get_scalar_columns(response.json())
assert len(columns) == 1
assert columns[0]["name"] == "__result_0"
assert columns[0]["columnType"] == "aggregation"
assert columns[0]["aggregationIndex"] == 0
response = querier.make_query_request(
signoz,
token,
int((now - timedelta(hours=1)).timestamp() * 1000),
int(now.timestamp() * 1000),
[
{
"type": "clickhouse_sql",
"spec": {
"name": "A",
"query": "SELECT toNullable(toFloat64(1.5)) AS value",
"disabled": False,
},
}
],
request_type=querier.RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
columns = querier.get_scalar_columns(response.json())
assert len(columns) == 1
assert columns[0]["name"] == "value"
assert columns[0]["columnType"] == "aggregation"
assert columns[0]["aggregationIndex"] == 0