mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-12 20:00:28 +01:00
Compare commits
3 Commits
main
...
feat/qb-du
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39d978601 | ||
|
|
8e9857ccfa | ||
|
|
3c9c3c98b1 |
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
spaceAggregation: 'avg',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -388,6 +388,8 @@ function LogsExplorerWithMockContext({
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
mode: 'url' as const,
|
||||
committedQuery: stagedQuery,
|
||||
isDefaultQuery: (): boolean => false,
|
||||
currentQuery,
|
||||
stagedQuery,
|
||||
|
||||
@@ -61,6 +61,8 @@ export const logsQueryRangeSuccessNewFormatResponse = {
|
||||
};
|
||||
|
||||
export const mockQueryBuilderContextValue = {
|
||||
mode: 'url' as const,
|
||||
committedQuery: null,
|
||||
isDefaultQuery: (): boolean => false,
|
||||
currentQuery: {
|
||||
...initialQueriesMap.logs,
|
||||
|
||||
38
frontend/src/hooks/__tests__/useBeforeUnloadWarning.test.ts
Normal file
38
frontend/src/hooks/__tests__/useBeforeUnloadWarning.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
31
frontend/src/hooks/queryBuilder/compositeQueryStore/types.ts
Normal file
31
frontend/src/hooks/queryBuilder/compositeQueryStore/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
26
frontend/src/hooks/useBeforeUnloadWarning.ts
Normal file
26
frontend/src/hooks/useBeforeUnloadWarning.ts
Normal 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;
|
||||
@@ -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>(), []);
|
||||
|
||||
185
frontend/src/lib/compositeQuery/__tests__/compositeQuery.test.ts
Normal file
185
frontend/src/lib/compositeQuery/__tests__/compositeQuery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
60
frontend/src/lib/compositeQuery/migrateCompositeQuery.ts
Normal file
60
frontend/src/lib/compositeQuery/migrateCompositeQuery.ts
Normal 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;
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
40
frontend/src/lib/compositeQuery/normalizeCompositeQuery.ts
Normal file
40
frontend/src/lib/compositeQuery/normalizeCompositeQuery.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -177,6 +177,8 @@ describe('Logs Explorer Tests', () => {
|
||||
>
|
||||
<QueryBuilderContext.Provider
|
||||
value={{
|
||||
mode: 'url',
|
||||
committedQuery: null,
|
||||
isDefaultQuery: (): boolean => false,
|
||||
currentQuery: {
|
||||
...initialQueriesMap.metrics,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
135
frontend/src/providers/__tests__/QueryBuilderMemoryMode.test.tsx
Normal file
135
frontend/src/providers/__tests__/QueryBuilderMemoryMode.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user