mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-08 01:10:57 +00:00
Compare commits
7 Commits
fix/genera
...
fix/ai-ass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3cd4abd89 | ||
|
|
c0fe80226c | ||
|
|
f2c813c7b1 | ||
|
|
d1f4f9d28d | ||
|
|
4080c64411 | ||
|
|
2c590c6ceb | ||
|
|
5129467384 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user