Compare commits

...

2 Commits

Author SHA1 Message Date
Ashu Ghildiyal
e0f6e844fe feat(alerts): adopt server-side clone API in frontend
Switch alert rule cloning from client-side re-posting to the
new POST /api/v2/rules/{id}/clone server-side endpoint.

- Update ActionsMenu in ListAlertRules to use cloneRuleByID.
- Update useAlertRuleDuplicate to navigate via clone response ID,
  removing the fragile list refetch.
- Update ListAlertRules.actions.test.tsx to test clone endpoint.
2026-10-19 19:15:59 +05:30
Aditya Singh
e4e58e2349 fix(explorer): stop reporting dropped order by to sentry (#13153)
#### Description
- The explorer order-by sanitizer sent a Sentry warning each time it
dropped an order-by column. Each new order made a new Sentry issue, and
each new issue paged.
- Dropping a stale order is expected. Since #13085, it happens when a
user switches from List to Time Series or Table.
- The sanitizer no longer reports to Sentry. The filtering is unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/180
2026-10-19 11:03:04 +00:00
7 changed files with 57 additions and 66 deletions

View File

@@ -25,6 +25,7 @@ export const REACT_QUERY_KEY = {
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -121,22 +122,26 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
);
renderListAlertRules();
@@ -145,9 +150,7 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
expect(capturedRuleId).toBe('rule-1');
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,10 +62,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

@@ -7,8 +7,8 @@ import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
@@ -38,9 +38,7 @@ function AlertActionButtons({
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: alertDetails as unknown as AlertDef,
});
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
@@ -72,10 +70,10 @@ function AlertActionButtons({
menuItems.push(
{
type: 'item',
value: 'duplicate-rule',
label: 'Duplicate',
value: 'clone-rule',
label: 'Clone',
prefix: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertDuplicate,
onClick: handleAlertClone,
},
{
type: 'item',

View File

@@ -6,7 +6,7 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
cloneRuleByID,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
@@ -17,7 +17,6 @@ import {
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
@@ -478,37 +477,33 @@ export const useAlertRuleStatusToggle = ({
return { handleAlertStateToggle };
};
export const useAlertRuleDuplicate = ({
alertDetails,
export const useAlertRuleClone = ({
ruleId,
}: {
alertDetails: AlertDef;
ruleId: string;
}): {
handleAlertDuplicate: () => void;
handleAlertClone: () => void;
} => {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
});
const { showErrorModal } = useErrorModal();
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
const { mutate: cloneAlert } = useMutation(
[REACT_QUERY_KEY.CLONE_ALERT_RULE],
() => cloneRuleByID({ id: ruleId }),
{
onSuccess: async () => {
onSuccess: async (response) => {
notifications.success({
message: `Success`,
});
const { data: allAlertsData } = await refetch();
await invalidateListRules(queryClient);
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
const clonedRule = response?.data;
if (clonedRule?.id) {
params.set(QueryParams.ruleId, String(clonedRule.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
},
@@ -519,15 +514,15 @@ export const useAlertRuleDuplicate = ({
},
);
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
const handleAlertClone = (): void => {
cloneAlert();
};
return { handleAlertDuplicate };
return { handleAlertClone };
};
// Kept for backwards compatibility
export const useAlertRuleDuplicate = useAlertRuleClone;
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -7,6 +8,10 @@ import { DataSource } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
jest.mock('@sentry/react', () => ({
captureEvent: jest.fn(),
}));
jest.mock('utils/aggregationConverter', () => ({
getParsedAggregationOptionsForOrderBy: jest.fn(),
}));
@@ -117,6 +122,7 @@ describe('sanitizeOrderByForExplorer', () => {
const result = sanitizeOrderByForExplorer(query);
expect(result).toStrictEqual([]);
expect(Sentry.captureEvent).not.toHaveBeenCalled();
});
it('handles missing orderBy by returning an empty array', () => {

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import {
IBuilderQuery,
OrderByPayload,
@@ -20,15 +19,5 @@ export function sanitizeOrderByForExplorer(
const current = query.orderBy || [];
const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName));
if (hasInvalidOrderBy) {
Sentry.captureEvent({
message: `Invalid orderBy: current: ${JSON.stringify(
current,
)} - allowed: ${JSON.stringify(Array.from(allowed))}`,
level: 'warning',
});
}
return current.filter((o) => allowed.has(o.columnName));
}