Compare commits

...

7 Commits

Author SHA1 Message Date
Gaurav Tewari
b3cd4abd89 Merge remote-tracking branch 'origin/main' into fix/ai-assistant-explorer-time-range
# Conflicts:
#	frontend/src/container/AIAssistant/components/ActionsSection/ActionsSection.tsx
#	frontend/src/container/AIAssistant/components/ActionsSection/__tests__/ActionsSection.test.tsx
2026-11-04 23:57:49 +05:30
Gaurav Tewari
c0fe80226c fix(ai-assistant): apply the answer's time range when the explorer is already open
Explorer global time is Redux-only, so useDispatch uses the no-restricted-imports escape hatch.
redirectWithQueryBuilderData drops null params, so query and time land in one history entry.
2026-11-03 17:41:47 +05:30
Gaurav Tewari
f2c813c7b1 fix(ai-assistant): open explorer chips on the answer's time range 2026-11-03 17:41:33 +05:30
Gaurav Tewari
d1f4f9d28d Merge remote-tracking branch 'origin/main' into fix/ai-assistant-explorer-panel-type
# Conflicts:
#	frontend/src/container/AIAssistant/components/ActionsSection/__tests__/ActionsSection.test.tsx
2026-11-03 17:14:35 +05:30
Gaurav Tewari
4080c64411 fix(ai-assistant): open trace queries in the Traces view 2026-11-03 01:10:24 +05:30
makeavish
2c590c6ceb test: assert buildExplorerNavigationUrl JSON-encodes panelTypes
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 01:10:12 +05:30
makeavish
5129467384 fix(ai-assistant): open apply_filter chips in the view their requestType implies
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 01:10:10 +05:30
6 changed files with 296 additions and 23 deletions

View File

@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
@@ -26,6 +28,7 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { UpdateTimeInterval } from 'store/actions';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
@@ -52,6 +55,7 @@ import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -278,6 +282,7 @@ interface ApplyFilterDeps {
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
setCustomTimeRange: (start: number, end: number) => void;
}
/**
@@ -343,17 +348,27 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
requestTypeFromActionQuery(action.query),
action.signal,
);
const timeRange = getTimeRangeFromActionQuery(action.query);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
if (timeRange) {
deps.setCustomTimeRange(timeRange.start, timeRange.end);
}
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
// relativeTime outranks startTime/endTime in the time picker, so drop it.
[QueryParams.relativeTime]: null,
}),
});
return;
}
@@ -367,6 +382,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
}),
});
navigate(url);
}
@@ -401,6 +420,13 @@ export default function ActionsSection({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
const dispatch = useDispatch();
const setCustomTimeRange = useCallback(
(start: number, end: number): void => {
dispatch(UpdateTimeInterval('custom', [start, end]));
},
[dispatch],
);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
@@ -432,9 +458,16 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
});
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
}, [
actions,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
]);
if (actions.length === 0) {
return null;
@@ -573,6 +606,7 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
break;
}

View File

@@ -9,6 +9,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
@@ -24,7 +25,17 @@ jest.mock('api/common/logEvent', () => ({
default: jest.fn(),
}));
jest.mock('store/actions', () => {
const actual =
jest.requireActual<typeof import('store/actions')>('store/actions');
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
});
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
@@ -32,26 +43,37 @@ const UNDO_ACTION = {
actionMetadataId: 'exec-1',
} as MessageActionDTO;
interface ActionOverrides {
signal?: ApplyFilterSignalDTO;
start?: number;
end?: number;
}
function renderUndoChip(): HTMLElement {
render(<ActionsSection actions={[UNDO_ACTION]} messageId="m1" />);
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
function countByServiceAction(
label: string,
{ signal = ApplyFilterSignalDTO.logs, start, end }: ActionOverrides = {},
): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
signal,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
start,
end,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
signal,
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
@@ -62,10 +84,30 @@ function countByServiceAction(label: string): MessageActionDTO {
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
function currentParam(param: string): string | null {
return new URLSearchParams(getCurrentLocation().search).get(param);
}
function renderOnLogsExplorer(action: MessageActionDTO): {
redirectWithQueryBuilderData: jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
} {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection actions={[action]} messageId="message-on-page" />,
undefined,
{
initialRoute: `${ROUTES.LOGS_EXPLORER}?${QueryParams.relativeTime}=6h`,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
return { redirectWithQueryBuilderData };
}
describe('ActionsSection rollback chip', () => {
@@ -139,27 +181,97 @@ describe('ActionsSection apply_filter', () => {
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
expect(currentParam(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TABLE),
);
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
it.each([
[ApplyFilterSignalDTO.logs, ROUTES.LOGS_EXPLORER],
[ApplyFilterSignalDTO.traces, ROUTES.TRACES_EXPLORER],
[ApplyFilterSignalDTO.metrics, ROUTES.METRICS_EXPLORER_EXPLORER],
])(
'opens the %s explorer on the time range that produced the answer',
async (signal, route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[
countByServiceAction(`Open ${signal}`, {
signal,
start: START_MS,
end: END_MS,
}),
]}
messageId={`message-${signal}`}
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() => expect(getCurrentLocation().pathname).toBe(route));
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
},
);
it('still opens the explorer, without a time range, when the action has none', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
actions={[countByServiceAction('Open without time')]}
messageId="message-no-time"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.compositeQuery)).not.toBeNull();
expect(currentParam(QueryParams.startTime)).toBeNull();
expect(currentParam(QueryParams.endTime)).toBeNull();
});
it('switches the open explorer to the Table view and the answer time range', async () => {
mockUpdateTimeInterval.mockClear();
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
countByServiceAction('Apply with time', { start: START_MS, end: END_MS }),
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
[QueryParams.startTime]: START_MS,
[QueryParams.endTime]: END_MS,
[QueryParams.relativeTime]: null,
},
);
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
START_MS,
END_MS,
]);
expect(mockUpdateTimeInterval.mock.invocationCallOrder[0]).toBeLessThan(
redirectWithQueryBuilderData.mock.invocationCallOrder[0],
);
// The redirect is the only navigation, so Back still restores the old entry.
expect(currentParam(QueryParams.relativeTime)).toBe('6h');
});
it('keeps the open explorer time when the action has no time range', async () => {
mockUpdateTimeInterval.mockClear();
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
countByServiceAction('Apply without time'),
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
@@ -168,5 +280,6 @@ describe('ActionsSection apply_filter', () => {
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import { getTimeRangeFromActionQuery } from '../getTimeRangeFromActionQuery';
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
describe('getTimeRangeFromActionQuery', () => {
it('returns the absolute range from the query envelope', () => {
expect(
getTimeRangeFromActionQuery({ start: START_MS, end: END_MS }),
).toStrictEqual({ start: START_MS, end: END_MS });
});
it('normalizes nanosecond timestamps to milliseconds', () => {
expect(
getTimeRangeFromActionQuery({
start: START_MS * 1_000_000,
end: END_MS * 1_000_000,
}),
).toStrictEqual({ start: START_MS, end: END_MS });
});
it.each([
['a missing query', undefined],
['a null query', null],
['missing timestamps', {}],
['a missing end', { start: START_MS }],
['string timestamps', { start: String(START_MS), end: String(END_MS) }],
[
'non-finite timestamps',
{ start: Number.NaN, end: Number.POSITIVE_INFINITY },
],
['non-positive timestamps', { start: 0, end: -1 }],
['an empty range', { start: START_MS, end: START_MS }],
['an inverted range', { start: END_MS, end: START_MS }],
])('returns null for %s', (_, query) => {
expect(getTimeRangeFromActionQuery(query)).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { normalizeTimeToMs } from 'utils/timeUtils';
export interface ActionTimeRange {
start: number;
end: number;
}
function toTimestampMs(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
}
return normalizeTimeToMs(Math.trunc(value));
}
export function getTimeRangeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): ActionTimeRange | null {
const start = toTimestampMs(query?.start);
const end = toTimestampMs(query?.end);
if (start === null || end === null || end <= start) {
return null;
}
return { start, end };
}

View File

@@ -1001,14 +1001,19 @@ export function QueryBuilderProvider({
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
Object.keys(searchParams).forEach((param) => {
// null removes the param instead of writing "null".
if (searchParams[param] === null) {
urlQuery.delete(param);
return;
}
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);

View File

@@ -0,0 +1,58 @@
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getCurrentLocation } from 'lib/router/navigation';
import {
act,
AllTheProviders,
renderHook,
RenderHookResult,
waitFor,
} from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
jest.mock('hooks/useSafeNavigate', () => {
const { navigate } = jest.requireActual<
typeof import('lib/router/navigation')
>('lib/router/navigation');
return {
useSafeNavigate: (): unknown => ({
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
navigate(to, { replace: opts?.replace });
},
}),
};
});
const currentParams = (): URLSearchParams =>
new URLSearchParams(getCurrentLocation().search);
const renderOn = (
url: string,
): RenderHookResult<QueryBuilderContextType, unknown> =>
renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
),
});
describe('redirectWithQueryBuilderData search params', () => {
it('removes params passed as null and JSON-encodes the rest', async () => {
const { result } = renderOn(
`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`,
);
act(() => {
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
[QueryParams.relativeTime]: null,
[QueryParams.startTime]: 1000,
});
});
await waitFor(() =>
expect(currentParams().get(QueryParams.startTime)).toBe('1000'),
);
expect(currentParams().has(QueryParams.relativeTime)).toBe(false);
expect(currentParams().get(QueryParams.viewName)).toBe('kept');
});
});