Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhatkal
cae7123c39 feat(alerts): remove the classic alert form 2026-10-28 07:42:52 +05:30
Ashwin Bhatkal
7faf122e93 refactor(alerts): use the generated rule type enum for threshold and anomaly rules 2026-10-28 07:42:52 +05:30
Ashwin Bhatkal
d7435a9748 feat(alerts): stop sending version and source on alert rules 2026-10-28 07:32:31 +05:30
100 changed files with 301 additions and 5676 deletions

View File

@@ -31970,132 +31970,6 @@ paths:
content:
application/json:
examples:
ai_traces_raw:
summary: 'Raw: LLM, tool, and agent spans of one service''s AI traces
with many tool calls'
value:
compositeQuery:
queries:
- spec:
filter:
expression: service.name = 'ai-agent' AND trace.tool_call_count
> 10
limit: 100
name: A
order:
- direction: desc
key:
fieldContext: span
name: timestamp
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: raw
schemaVersion: v1
start: 1640995200000
ai_traces_raw_select_fields:
summary: 'Raw: the same spans with the span name and gen_ai model,
tool, and token fields selected'
value:
compositeQuery:
queries:
- spec:
filter:
expression: service.name = 'ai-agent' AND trace.tool_call_count
> 10
limit: 100
name: A
order:
- direction: desc
key:
fieldContext: span
name: timestamp
selectFields:
- fieldContext: span
name: name
- fieldContext: attribute
name: gen_ai.request.model
- fieldContext: attribute
name: gen_ai.tool.name
- fieldContext: attribute
name: gen_ai.usage.input_tokens
- fieldContext: attribute
name: gen_ai.usage.output_tokens
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: raw
schemaVersion: v1
start: 1640995200000
ai_traces_scalar:
summary: 'Scalar: average per-trace AI cost by model'
value:
compositeQuery:
queries:
- spec:
aggregations:
- expression: avg(trace.estimated_total_cost)
groupBy:
- fieldContext: attribute
name: gen_ai.request.model
limit: 10
name: A
order:
- direction: desc
key:
name: avg(trace.estimated_total_cost)
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: scalar
schemaVersion: v1
start: 1640995200000
ai_traces_time_series:
summary: 'Time series: per-trace AI cost by model'
value:
compositeQuery:
queries:
- spec:
aggregations:
- expression: sum(trace.estimated_total_cost)
groupBy:
- fieldContext: attribute
name: gen_ai.request.model
limit: 10
name: A
order:
- direction: desc
key:
name: sum(trace.estimated_total_cost)
signal: traces
stepInterval: 60s
type: builder_ai_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
ai_traces_trace:
summary: 'Trace list: AI traces with many tool calls, most expensive
first'
value:
compositeQuery:
queries:
- spec:
filter:
expression: trace.tool_call_count > 10 AND trace.distinct_tool_count
< 3
limit: 10
name: A
order:
- direction: desc
key:
name: trace.estimated_total_cost
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: trace
schemaVersion: v1
start: 1640995200000
clickhouse_sql_logs_raw:
summary: 'ClickHouse SQL: raw logs with resource filter'
value:

View File

@@ -17,9 +17,11 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
@@ -80,6 +82,7 @@
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"field_incidentio_metadata": "Additional metadata",
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
@@ -89,8 +92,8 @@
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -109,10 +112,13 @@
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",

View File

@@ -25,9 +25,11 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
@@ -88,8 +90,7 @@
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
"incidentio_token_bearer_prefix": "Paste the token only, without the Bearer prefix",
"incidentio_token_whitespace": "Remove the spaces around the token",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"field_incidentio_metadata": "Additional metadata",
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
@@ -115,7 +116,6 @@
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"slack_field_value_required": "Enter a value for this field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"label_slack_action_text": "Text",
@@ -133,7 +133,6 @@
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"slack_action_url_or_name_required": "Enter a URL or a name for this button",
"remove_slack_action": "Remove action",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
@@ -144,15 +143,9 @@
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_webhook_bearer_token": "Bearer token",
"field_webhook_auth": "Authentication",
"option_webhook_auth_none": "None",
"option_webhook_auth_basic": "Basic auth",
"option_webhook_auth_bearer": "Bearer token",
"webhook_basic_auth_required": "Enter a username or password",
"webhook_bearer_token_required": "Enter a bearer token",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_webhook_bearer_token": "Bearer token (optional)",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -175,7 +168,9 @@
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header.",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
@@ -183,6 +178,7 @@
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",

View File

@@ -1,9 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -59,8 +64,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
}
handleSetConfig(newPanelType, currentDataSource);
}
}, [
@@ -69,6 +86,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {

View File

@@ -1,109 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render } from 'tests/test-utils';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const aiQuery: Query = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
...query,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
})),
},
};
function mockContext({
panelType = null,
initialDataSource = null,
currentQuery = initialQueriesMap.metrics,
}: {
panelType?: PANEL_TYPES | null;
initialDataSource?: DataSource | null;
currentQuery?: Query;
}): jest.Mock {
const handleSetConfig = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType,
initialDataSource,
handleSetConfig,
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
addTraceOperator: jest.fn(),
handleRunQuery: jest.fn(),
} as unknown as ReturnType<typeof useQueryBuilder>);
return handleSetConfig;
}
describe('QueryBuilderV2 provider config sync', () => {
it('leaves the data source unset for a bar panel without a static config', () => {
const handleSetConfig = mockContext({});
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
});
it('syncs the static data source', () => {
const handleSetConfig = mockContext({});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.LOGS,
);
});
it('keeps an AI query on traces for a bar panel', () => {
const handleSetConfig = mockContext({ currentQuery: aiQuery });
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.TRACES,
);
});
it('does not reset the config when the provider already matches', () => {
const handleSetConfig = mockContext({
panelType: PANEL_TYPES.TIME_SERIES,
initialDataSource: DataSource.TRACES,
});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.TIME_SERIES}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).not.toHaveBeenCalled();
});
});

View File

@@ -53,7 +53,6 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -26,7 +26,6 @@ export const REACT_QUERY_KEY = {
REMOVE_ALERT_RULE: 'REMOVE_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',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',

View File

@@ -2,7 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import { ChannelFormValues, ChannelKind } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
@@ -56,22 +56,6 @@ describe('toChannelConfig', () => {
});
});
it.each([
[WebhookAuth.none, {}],
[WebhookAuth.basic, { username: 'u', password: 'p' }],
[WebhookAuth.bearer, { bearerToken: 't' }],
])('sends only the %s webhook credentials', (webhookAuth, credentials) => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
webhookAuth,
username: 'u',
password: 'p',
bearerToken: 't',
}).spec,
).toStrictEqual({ url: 'https://example.com/hook', ...credentials });
});
it('drops an attachment list the user emptied instead of sending []', () => {
expect(
toChannelConfig(ChannelKind.slack, {

View File

@@ -18,17 +18,8 @@ import {
import { omit, pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import {
ChannelFormValues,
ChannelKind,
ChannelSpecFormValues,
WebhookAuth,
} from './types';
import {
canonicalJiraReopenDuration,
filledRows,
webhookAuthOf,
} from './utils';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration, filledRows } from './utils';
const SPEC_FIELDS = {
[ChannelKind.slack]: [
@@ -219,17 +210,6 @@ export function toChannelConfig(
);
}
if (kind === ChannelKind.webhook) {
const auth = webhookAuthOf(values);
if (auth !== WebhookAuth.basic) {
delete picked.username;
delete picked.password;
}
if (auth !== WebhookAuth.bearer) {
delete picked.bearerToken;
}
}
const spec: Record<string, unknown> = omitBlank(picked);
// the API answers a read with the duration prometheus would print, and

View File

@@ -1,7 +1,6 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
import { webhookAuthOf } from './utils';
export interface ChannelFormState {
kind: ChannelKind;
@@ -23,15 +22,11 @@ function toChannelKind(kind: string): ChannelKind {
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
const kind = toChannelKind(channel.config.kind);
const values: ChannelFormValues = {
...channel.config.spec,
name: channel.displayName,
return {
kind: toChannelKind(channel.config.kind),
values: {
...channel.config.spec,
name: channel.displayName,
},
};
if (kind === ChannelKind.webhook) {
values.webhookAuth = webhookAuthOf(values);
}
return { kind, values };
}

View File

@@ -1,5 +1,4 @@
import { TFunction } from 'i18next';
import { get, set } from 'lodash-es';
import type { FieldErrors, Resolver } from 'react-hook-form';
import type { $ZodIssue } from 'zod/v4/core';
@@ -11,12 +10,12 @@ const toFieldErrors = (
issues: $ZodIssue[],
t: TFunction,
): FieldErrors<ChannelFormValues> => {
const errors = {};
const errors: Record<string, { type: string; message: string }> = {};
issues.forEach((issue) => {
const path = issue.path.length ? issue.path : ['name'];
if (!get(errors, path)) {
set(errors, path, { type: 'validation', message: t(issue.message) });
const field = issue.path.join('.') || 'name';
if (!errors[field]) {
errors[field] = { type: 'validation', message: t(issue.message) };
}
});

View File

@@ -23,6 +23,7 @@ const SPECS: Record<string, Record<string, unknown>> = {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {

View File

@@ -1,7 +1,11 @@
import { z } from 'zod';
import { isValidGoogleChatWebhookURL } from '../utils';
import { requiredString } from './shared';
export const googleChatSchema = z.object({
webhookUrl: requiredString('webhook_url_required'),
webhookUrl: requiredString('webhook_url_required').refine(
isValidGoogleChatWebhookURL,
{ message: 'google_chat_webhook_url_invalid' },
),
});

View File

@@ -1,19 +1,13 @@
import { z } from 'zod';
import { isValidIncidentIOURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('incidentio_required_fields');
export const incidentIOSchema = z.object({
url: required,
token: z
.string({ error: 'incidentio_required_fields' })
.refine((token) => token === token.trim(), {
message: 'incidentio_token_whitespace',
})
.pipe(
required.refine((token) => !/^bearer( |$)/i.test(token), {
message: 'incidentio_token_bearer_prefix',
}),
),
url: required.refine(isValidIncidentIOURL, {
message: 'incidentio_url_invalid',
}),
token: required,
});

View File

@@ -1,12 +1,12 @@
import { z } from 'zod';
import { isValidJiraReopenDuration } from '../utils';
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('jira_required_fields');
export const jiraSchema = z.object({
site: required,
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
email: required,
apiToken: required,
project: required,

View File

@@ -2,30 +2,6 @@ import { z } from 'zod';
import { requiredString } from './shared';
// rows with a blank title or text are dropped on save, so only the rest are checked
const fieldRow = z
.object({ title: z.string().optional(), value: z.string().optional() })
.refine((row) => !row.title?.trim() || !!row.value?.trim(), {
message: 'slack_field_value_required',
path: ['value'],
});
const actionRow = z
.object({
text: z.string().optional(),
url: z.string().optional(),
name: z.string().optional(),
})
.refine(
(row) => !row.text?.trim() || !!row.url?.trim() || !!row.name?.trim(),
{
message: 'slack_action_url_or_name_required',
path: ['url'],
},
);
export const slackSchema = z.object({
apiUrl: requiredString('webhook_url_required'),
fields: z.array(fieldRow).nullish(),
actions: z.array(actionRow).nullish(),
});

View File

@@ -1,26 +1,16 @@
import { z } from 'zod';
import { WebhookAuth } from '../types';
import { webhookAuthOf } from '../utils';
import { requiredString } from './shared';
export const webhookSchema = z
.object({
url: requiredString('webhook_url_required'),
webhookAuth: z.enum(WebhookAuth).optional(),
username: z.string().optional(),
password: z.string().optional(),
bearerToken: z.string().optional(),
})
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.basic ||
!!values.username?.trim() ||
!!values.password?.trim(),
{ message: 'webhook_basic_auth_required', path: ['username'] },
)
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.bearer || !!values.bearerToken?.trim(),
{ message: 'webhook_bearer_token_required', path: ['bearerToken'] },
);
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
.refine((v) => !v.username || !!v.password, {
message: 'username_no_password',
path: ['password'],
});

View File

@@ -31,15 +31,6 @@ export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelTelegramConfigDTO
>;
export const WebhookAuth = {
none: 'none',
basic: 'basic',
bearer: 'bearer',
} as const;
export type WebhookAuth = (typeof WebhookAuth)[keyof typeof WebhookAuth];
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
/** Never sent: the API reads the mode from which credentials are set. */
webhookAuth?: WebhookAuth;
};

View File

@@ -1,4 +1,29 @@
import { ChannelFormValues, WebhookAuth } from './types';
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' &&
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
);
} catch {
return false;
}
};
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
@@ -69,21 +94,21 @@ export const canonicalJiraReopenDuration = (value: string): string => {
}, '');
};
export const webhookAuthOf = (
values: Pick<
ChannelFormValues,
'webhookAuth' | 'username' | 'password' | 'bearerToken'
>,
): WebhookAuth => {
if (values.webhookAuth) {
return values.webhookAuth;
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
// the backend enforces the same rule, this is only for a nicer error experience
export const isValidIncidentIOURL = (url: string): boolean => {
try {
const { protocol, pathname } = new URL(url);
const idx = pathname.indexOf(INCIDENTIO_EVENTS_PATH_PREFIX);
return (
protocol === 'https:' &&
idx !== -1 &&
pathname.length > idx + INCIDENTIO_EVENTS_PATH_PREFIX.length
);
} catch {
return false;
}
if (values.bearerToken) {
return WebhookAuth.bearer;
}
return values.username || values.password
? WebhookAuth.basic
: WebhookAuth.none;
};
export const filledRows = <T>(

View File

@@ -1,93 +0,0 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import { validateChannel } from './validation';
const t = ((key: string) => key) as TFunction;
describe('validateChannel', () => {
it.each<[string, ChannelKind, ChannelFormValues, string | null]>([
[
'a google chat webhook behind a proxy',
ChannelKind.googlechat,
{ webhookUrl: 'http://relay.internal/chat' },
null,
],
[
'a jira site that is not on atlassian.net',
ChannelKind.jira,
{
site: 'https://jira-proxy.acme.com/',
email: 'a@acme.com',
apiToken: 't',
project: 'OPS',
issueType: 'Bug',
},
null,
],
[
'any incident.io url',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok' },
null,
],
[
'an incident.io token pasted with its Bearer prefix',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'Bearer tok' },
'incidentio_token_bearer_prefix',
],
[
'an incident.io token with a trailing space',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok ' },
'incidentio_token_whitespace',
],
[
'a webhook username without a password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic, username: 'u' },
null,
],
[
'basic auth with neither username nor password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic },
'webhook_basic_auth_required',
],
[
'bearer auth without a token',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.bearer, username: 'u' },
'webhook_bearer_token_required',
],
[
'a slack field without a value',
ChannelKind.slack,
{ apiUrl: 'https://hooks', fields: [{ title: 'env', value: '' }] },
'slack_field_value_required',
],
[
'a slack button with neither url nor name',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Runbook', url: '' }],
},
'slack_action_url_or_name_required',
],
[
'a slack button with a name only',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Ack', name: 'ack' }],
},
null,
],
])('%s', (_, kind, values, expected) => {
expect(validateChannel(kind, { name: 'channel', ...values }, t)).toBe(
expected,
);
});
});

View File

@@ -1,148 +0,0 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: '',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
let mockWindowOpen: jest.Mock;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function findLinkForAlertType(
links: HTMLElement[],
alertType: AlertTypes,
): HTMLElement {
const link = links.find(
(el) =>
el.closest('[data-testid]')?.getAttribute('data-testid') ===
`alert-type-card-${alertType}`,
);
expect(link).toBeTruthy();
return link as HTMLElement;
}
function clickLinkAndVerifyRedirect(
link: HTMLElement,
expectedUrl: string,
): void {
fireEvent.click(link);
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
}
describe('Alert rule documentation redirection', () => {
let renderResult: ReturnType<typeof render>;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
beforeEach(() => {
act(() => {
renderResult = render(
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
},
);
});
});
it('should render alert type cards', () => {
const { getByText, getAllByText } = renderResult;
// Check for the heading
expect(getByText('choose_alert_type')).toBeInTheDocument();
// Check for alert type titles and descriptions
Object.values(AlertTypes).forEach((alertType) => {
const title = ALERT_TYPE_TO_TITLE[alertType];
expect(getByText(title)).toBeInTheDocument();
expect(getByText(`${title}_desc`)).toBeInTheDocument();
});
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
expect(clickHereLinks).toHaveLength(5);
});
it('should redirect to correct documentation for each alert type', () => {
const { getAllByText } = renderResult;
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
const alertTypeCount = Object.keys(AlertTypes).length;
expect(clickHereLinks).toHaveLength(alertTypeCount);
Object.values(AlertTypes).forEach((alertType) => {
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
});
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
});
});
describe('Create alert page redirection', () => {
Object.values(AlertTypes)
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
.forEach((alertType) => {
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
const { getByRole } = render(
<CreateAlertPage />,
{},
{
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
},
);
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});
});

View File

@@ -1,85 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup guide button only exists in the classic form.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
hash: '',
state: null,
})),
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
describe('Anomaly Alert Documentation Redirection', () => {
let mockWindowOpen: jest.Mock;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
it('should handle anomaly alert documentation redirection correctly', () => {
const { getByRole } = render(
<MemoryRouter initialEntries={['/alerts/new']}>
<Route path={ROUTES.ALERTS_NEW}>
<CreateAlertPage />
</Route>
</MemoryRouter>,
);
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});

View File

@@ -35,25 +35,6 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
},
}));
jest.mock('container/FormAlertRules', () => ({
__esModule: true,
default: function MockFormAlertRules({
alertType,
}: {
alertType: AlertTypes;
}): JSX.Element {
return (
<div>
<h1>Form Alert Rules</h1>
<p>{alertType}</p>
</div>
);
},
AlertDetectionTypes: {
THRESHOLD_ALERT: 'threshold_rule',
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
},
}));
jest.mock('container/CreateAlertV2', () => ({
__esModule: true,
default: function MockCreateAlertV2({
@@ -88,7 +69,6 @@ const mockGetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockDeleteUrlQuery = jest.fn();
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
describe('CreateAlertRule', () => {
@@ -107,20 +87,6 @@ describe('CreateAlertRule', () => {
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render new flow when alertType is provided', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
@@ -136,7 +102,6 @@ describe('CreateAlertRule', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
@@ -161,9 +126,6 @@ describe('CreateAlertRule', () => {
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
}
if (key === QueryParams.ruleType) {
return 'anomaly_rule';
}
@@ -172,7 +134,6 @@ describe('CreateAlertRule', () => {
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {
@@ -296,27 +257,5 @@ describe('CreateAlertRule', () => {
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,10 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AlertDef,
@@ -63,7 +63,7 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
condition: {
compositeQuery: {
builderQueries: {
@@ -201,14 +201,6 @@ export const exceptionAlertDefaults: AlertDef = {
alert: '',
};
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
};
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',

View File

@@ -1,14 +1,13 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Form, Tabs, TabsProps } from 'antd';
import { Tabs, TabsProps } from 'antd';
import logEvent from 'api/common/logEvent';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -16,25 +15,20 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import SelectAlertType from './SelectAlertType';
import './CreateAlertRule.styles.scss';
function CreateRules(): JSX.Element {
const [formInstance] = Form.useForm();
const compositeQuery = useGetCompositeQueryParam();
const queryParams = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
const version = queryParams.get(QueryParams.version);
const showClassicCreateAlertsPageFlag =
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
const isTypeSelectionMode =
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
@@ -46,7 +40,7 @@ function CreateRules(): JSX.Element {
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
if (!alertTypeFromURL) {
@@ -59,14 +53,6 @@ function CreateRules(): JSX.Element {
return alertTypeFromURL as AlertTypes;
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
const initialAlertValue: AlertDef = useMemo(
() => ({
...ALERTS_VALUES_MAP[alertType],
version: version || ENTITY_VERSION_V5,
}),
[alertType, version],
);
const handleTabChange = useCallback(
(tab: string): void => {
queryParams.set('tab', tab);
@@ -80,13 +66,10 @@ function CreateRules(): JSX.Element {
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
queryParams.set(QueryParams.alertType, type);
}
@@ -99,26 +82,8 @@ function CreateRules(): JSX.Element {
if (isTypeSelectionMode) {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
return <CreateAlertV2 alertType={alertType} />;
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
}, [isTypeSelectionMode, handleSelectType, alertType]);
const items: TabsProps['items'] = [
{

View File

@@ -1,14 +1,7 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,20 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -105,37 +97,4 @@ describe('CreateAlertHeader', () => {
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
).toHaveValue('TEST_ALERT');
});
it('should navigate to classic experience when button is clicked', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();
});
});

View File

@@ -556,8 +556,6 @@ describe('Footer utils', () => {
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});

View File

@@ -1,6 +1,6 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
@@ -266,7 +266,7 @@ export function buildCreateThresholdAlertRulePayload(
// Evaluation
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
let ruleType: string = RuletypesRuleTypeDTO.threshold_rule;
if (query.queryType === EQueryType.PROM) {
ruleType = 'promql_rule';
}
@@ -292,9 +292,7 @@ export function buildCreateThresholdAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
@@ -321,7 +319,7 @@ export function buildCreateAnomalyAlertRulePayload(
return {
...payload,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
alertType: AlertTypes.METRICS_BASED_ALERT,
condition: {
...payload.condition,

View File

@@ -4,12 +4,12 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
initialClickHouseData,
initialQueryPromQLData,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import store from 'store';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
@@ -237,7 +237,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,
@@ -252,7 +252,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,
@@ -376,7 +376,7 @@ describe('QuerySection', () => {
expect(result[1]).toStrictEqual({
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
});
});

View File

@@ -1,4 +1,4 @@
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -18,8 +18,8 @@ export function buildAlertDefForChartPreview({
...initialAlertDef,
ruleType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
condition: {
...initialAlertDef.condition,
targetUnit: thresholdState.thresholds?.[0].unit,

View File

@@ -1,4 +1,3 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
@@ -39,7 +38,6 @@ const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {

View File

@@ -11,13 +11,13 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
useCreateRule,
useTestRule,
useUpdateRuleByID,
} from 'api/generated/services/rules';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -186,8 +186,8 @@ export function CreateAlertProvider(
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
value === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,

View File

@@ -1,10 +1,10 @@
import { createPortal } from 'react-dom';
import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -250,8 +250,7 @@ export {
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
@@ -286,7 +285,7 @@ export function getThresholdStateFromAlertDef(
export function getAlertTypeFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): AlertTypes {
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
return alertDef?.alertType as AlertTypes;

View File

@@ -1,50 +0,0 @@
import { Form } from 'antd';
import EditAlertV2 from 'container/EditAlertV2';
import FormAlertRules from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
function EditRules({
initialValue,
ruleId,
initialV2AlertValue,
}: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm();
if (
initialV2AlertValue !== null &&
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
) {
return (
<EditAlertV2
initialAlert={initialV2AlertValue}
alertType={initialValue.alertType as AlertTypes}
/>
);
}
return (
<FormAlertRules
alertType={
initialValue.alertType
? (initialValue.alertType as AlertTypes)
: AlertTypes.METRICS_BASED_ALERT
}
formInstance={formInstance}
initialValue={initialValue}
ruleId={ruleId}
/>
);
}
interface EditRulesProps {
initialValue: AlertDef;
ruleId: string;
initialV2AlertValue: PostableAlertRuleV2 | null;
}
export default EditRules;

View File

@@ -26,11 +26,7 @@ const COLUMNS = [
function SlackActions(): JSX.Element {
const { t } = useTranslation('channels');
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
@@ -55,9 +51,6 @@ function SlackActions(): JSX.Element {
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
error={
column === 'url' ? errors.actions?.[index]?.url?.message : undefined
}
>
<Input
{...register(`actions.${index}.${column}` as const)}

View File

@@ -14,11 +14,7 @@ import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
function SlackFields(): JSX.Element {
const { t } = useTranslation('channels');
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
@@ -50,7 +46,6 @@ function SlackFields(): JSX.Element {
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
error={errors.fields?.[index]?.value?.message}
>
<Input
{...register(`fields.${index}.value`)}

View File

@@ -1,3 +0,0 @@
.auth {
--radio-group-display: flex;
}

View File

@@ -1,30 +1,17 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { RadioGroup } from '@signozhq/ui/radio-group';
import {
ChannelFormValues,
WebhookAuth,
} from '../../CreateAlertChannels/types';
import { webhookAuthOf } from '../../CreateAlertChannels/utils';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './Webhook.module.scss';
function WebhookSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelFormValues>();
const [webhookAuth, username, password, bearerToken] = useWatch({
control,
name: ['webhookAuth', 'username', 'password', 'bearerToken'],
});
const auth = webhookAuthOf({ webhookAuth, username, password, bearerToken });
} = useFormContext<ChannelSpecFormValues>();
return (
<>
@@ -41,75 +28,35 @@ function WebhookSettings(): JSX.Element {
data-testid="webhook-url-textbox"
/>
</FormField>
<FormField label={t('field_webhook_auth')}>
<Controller
control={control}
name="webhookAuth"
render={({ field }): JSX.Element => (
<div className={styles.auth}>
<RadioGroup
color="primary"
aria-label={t('field_webhook_auth')}
value={auth}
onChange={field.onChange}
testId="webhook-auth-radio"
items={[
{
value: WebhookAuth.none,
label: t('option_webhook_auth_none'),
},
{
value: WebhookAuth.basic,
label: t('option_webhook_auth_basic'),
},
{
value: WebhookAuth.bearer,
label: t('option_webhook_auth_bearer'),
},
]}
/>
</div>
)}
<FormField
label={t('field_webhook_username')}
help={t('help_webhook_username')}
error={errors.username?.message}
>
<Input {...register('username')} data-testid="webhook-username-textbox" />
</FormField>
<FormField
label="Password (optional)"
help={t('help_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
>
<Input
{...register('bearerToken')}
type="password"
data-testid="webhook-bearer-token-textbox"
/>
</FormField>
{auth === WebhookAuth.basic && (
<>
<FormField
label={t('field_webhook_username')}
error={errors.username?.message}
>
<Input
{...register('username')}
data-testid="webhook-username-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
</>
)}
{auth === WebhookAuth.bearer && (
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
required
>
<Input
{...register('bearerToken')}
type="password"
aria-required
data-testid="webhook-bearer-token-textbox"
/>
</FormField>
)}
</FormCard>
</>
);

View File

@@ -1,268 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef, Labels } from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
import LabelSelect from './labels';
import {
FormContainer,
FormItemMedium,
InputSmall,
SeveritySelect,
StepHeading,
TextareaMedium,
} from './styles';
import './FormAlertRules.styles.scss';
const { Option } = Select;
interface BasicInfoProps {
isNewRule: boolean;
alertDef: AlertDef;
setAlertDef: (a: AlertDef) => void;
}
function BasicInfo({
isNewRule,
alertDef,
setAlertDef,
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
useState(false);
useEffect(() => {
const hasPreferredChannels =
(alertDef.preferredChannels && alertDef.preferredChannels.length > 0) ||
isNewRule;
setShouldBroadCastToAllChannels(!hasPreferredChannels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleBroadcastToAllChannels = (shouldBroadcast: boolean): void => {
setShouldBroadCastToAllChannels(shouldBroadcast);
setAlertDef({
...alertDef,
broadcastToAll: shouldBroadcast,
});
};
const noChannels = channels.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);
useEffect(() => {
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: channels.length,
});
hasLoggedEvent.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
const refetchChannels = async (): Promise<void> => {
await refetch();
};
return (
<>
<StepHeading> {t('alert_form_step4')} </StepHeading>
<FormContainer>
<Form.Item
label={t('field_severity')}
labelAlign="left"
name={['labels', 'severity']}
>
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
...alertDef,
labels: {
...alertDef.labels,
severity: s,
},
});
}}
>
<Option value="critical">{t('option_critical')}</Option>
<Option value="error">{t('option_error')}</Option>
<Option value="warning">{t('option_warning')}</Option>
<Option value="info">{t('option_info')}</Option>
</SeveritySelect>
</Form.Item>
<Form.Item
required
name="alert"
labelAlign="left"
label={t('field_alert_name')}
rules={[
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
alert: e.target.value,
});
}}
/>
</Form.Item>
<Form.Item
label={t('field_alert_desc')}
labelAlign="left"
name={['annotations', 'description']}
>
<TextareaMedium
data-testid="alert-description-input"
onChange={(e): void => {
setAlertDef({
...alertDef,
annotations: {
...alertDef.annotations,
description: e.target.value,
},
});
}}
/>
</Form.Item>
<FormItemMedium label={t('field_labels')}>
<LabelSelect
onSetLabels={(l: Labels): void => {
setAlertDef({
...alertDef,
labels: {
...l,
},
});
}}
initialValues={alertDef.labels}
/>
</FormItemMedium>
<FormItemMedium
name="alert_all_configured_channels"
label="Alert all the configured channels"
>
<Switch
color="primary"
textPlacement="right"
disabledTooltip={
isLoading
? 'Wait for the channels to load'
: 'No channels. Ask an admin to create a notification channel'
}
value={shouldBroadCastToAllChannels}
onChange={handleBroadcastToAllChannels}
disabled={noChannels || !!isLoading}
testId="alert-broadcast-to-all-channels"
/>
</FormItemMedium>
{!shouldBroadCastToAllChannels && (
<Tooltip
title={
noChannels && !addNewChannelPermission
? 'No channels. Ask an admin to create a notification channel'
: undefined
}
placement="right"
>
<FormItemMedium
label="Notification Channels"
name="notification_channels"
required
rules={[
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
]}
>
<ChannelSelect
onDropdownOpen={refetchChannels}
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={channels}
isLoading={isLoading}
hasError={isError}
error={error as APIError}
onSelectChannels={(preferredChannels): void => {
setAlertDef({
...alertDef,
preferredChannels,
});
}}
/>
</FormItemMedium>
</Tooltip>
)}
{noChannels && (
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create a notification channel'
: undefined
}
placement="right"
>
<Button
onClick={handleCreateNewChannels}
className="create-notification-btn"
disabled={!addNewChannelPermission}
>
<Flex align="center" justify="center">
<Plus size="md" />
Create a notification channel
</Flex>
</Button>
</Tooltip>
)}
</FormContainer>
</>
);
}
export default BasicInfo;

View File

@@ -1,116 +0,0 @@
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import { useNotifications } from 'hooks/useNotifications';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
export interface ChannelSelectProps {
disabled?: boolean;
currentValue?: string[];
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: ChannelOption[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;
}
function ChannelSelect({
disabled,
currentValue,
onSelectChannels,
onDropdownOpen,
channels,
isLoading,
hasError,
error,
handleCreateNewChannels,
}: ChannelSelectProps): JSX.Element | null {
// init namespace for translations
const { t } = useTranslation('alerts');
const { notifications } = useNotifications();
const handleChange = (value: string[]): void => {
if (value.includes('add-new-channel')) {
handleCreateNewChannels();
return;
}
onSelectChannels(value);
};
if (hasError) {
notifications.error({
message: error?.getErrorCode?.() || 'Error',
description: error?.getErrorMessage?.() || 'Something went wrong',
});
}
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const renderOptions = (): ReactNode[] => {
const children: ReactNode[] = [];
if (!isLoading && addNewChannelPermission) {
children.push(
<Select.Option key="add-new-channel" value="add-new-channel">
<StyledCreateChannelOption>
<Plus size="md" />
Create a new channel
</StyledCreateChannelOption>
</Select.Option>,
);
}
if (isLoading || channels.length === 0) {
return children;
}
channels.forEach((o) => {
children.push(
<Select.Option key={o.id} value={o.name}>
{o.name}
</Select.Option>,
);
});
return children;
};
return (
<StyledSelect
disabled={disabled}
status={hasError ? 'error' : ''}
mode="multiple"
style={{ width: '100%' }}
placeholder={t('placeholder_channel_select')}
data-testid="alert-channel-select"
value={currentValue}
notFoundContent={isLoading && <Spin size="small" />}
onDropdownVisibleChange={(open): void => {
if (open) {
onDropdownOpen();
}
}}
onChange={(value): void => {
handleChange(value as string[]);
}}
optionLabelProp="label"
>
{renderOptions()}
</StyledSelect>
);
}
ChannelSelect.defaultProps = {
disabled: false,
currentValue: [],
};
export default ChannelSelect;

View File

@@ -1,13 +0,0 @@
import { Select } from 'antd';
import styled from 'styled-components';
export const StyledSelect = styled(Select)`
border-radius: 4px;
`;
export const StyledCreateChannelOption = styled.div`
color: var(--accent-primary);
display: flex;
align-items: center;
gap: 8px;
`;

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import Spinner from 'components/Spinner';
@@ -45,7 +46,6 @@ import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import ChartContent from './ChartContent';
import { ChartContainer } from './styles';
import { getThresholds } from './utils';
@@ -279,7 +279,7 @@ function ChartPreview({
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
const isAnomalyDetectionAlert =
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
const chartDataAvailable =
chartData &&

View File

@@ -1,146 +0,0 @@
.create-alert-modal {
.ant-modal-content {
background-color: var(--l3-background);
.ant-modal-confirm-title {
color: var(--l1-foreground);
}
.ant-modal-confirm-content {
.ant-typography {
color: var(--l1-foreground);
}
}
.ant-modal-confirm-btns {
button:nth-of-type(1) {
background-color: var(--l1-border);
border: none;
color: var(--l1-foreground);
}
}
}
}
.form-alert-rules-container {
&.create-mode {
padding: 16px;
}
}
.triggered-alerts-container {
padding: 8px;
}
.alert-rules-list-container {
padding: 8px;
}
.planned-downtime-container {
padding: 8px;
}
.steps-container {
width: 80%;
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
}
}
.qb-chart-preview-container {
margin-bottom: 1rem;
display: flex;
flex-direction: row;
gap: 1rem;
}
.overview-header {
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
.alert-type-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.alert-type-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.ant-typography {
margin: 0;
}
}
}
.chart-preview-container {
position: relative;
display: flex;
flex-direction: row;
gap: 1rem;
.ant-card {
flex: 1;
}
}
.detection-method-container {
margin: 24px 0;
.ant-tabs-nav {
margin-bottom: 0;
.ant-tabs-tab {
padding: 12px 0;
}
}
.detection-method-description {
padding: 8px 0;
font-size: 12px;
}
}
.info-help-btns {
display: grid;
grid-template-columns: auto auto;
gap: 12px;
margin-top: 20px;
.doc-redirection-btn {
color: var(--bg-aqua-500) !important;
border-color: var(--bg-aqua-500) !important;
}
.facing-issue-btn {
width: 100% !important;
}
}
.create-notification-btn {
box-shadow: none;
}

View File

@@ -1,10 +0,0 @@
.rule-definition {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

@@ -1,525 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
Checkbox,
Collapse,
Form,
InputNumber,
InputNumberProps,
Select,
SelectProps,
Space,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
} from 'container/CreateAlertV2/AlertCondition/utils';
import {
AlertDef,
defaultAlgorithm,
defaultCompareOp,
defaultEvalWindow,
defaultFrequency,
defaultMatchType,
defaultSeasonality,
} from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import { popupContainer } from 'utils/selectPopupContainer';
import { AlertDetectionTypes } from '.';
import {
FormContainer,
InlineSelect,
StepHeading,
VerticalLine,
} from './styles';
import './RuleOptions.styles.scss';
function RuleOptions({
alertDef,
setAlertDef,
queryCategory,
queryOptions,
yAxisUnit,
}: RuleOptionsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { ruleType } = alertDef;
const handleMatchOptChange = (value: string | unknown): void => {
const m = (value as string) || alertDef.condition?.matchType;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
matchType: m,
},
});
};
const onChangeSelectedQueryName = (value: string | unknown): void => {
if (typeof value !== 'string') {
return;
}
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
selectedQueryName: value,
},
});
};
const renderCompareOps = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultCompareOp}
value={alertDef.condition?.op}
style={{ minWidth: '120px' }}
data-testid="alert-threshold-op-select"
onChange={(value: string | unknown): void => {
const newOp = (value as string) || '';
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
op: newOp,
},
});
}}
>
<Select.Option value="1">{t('option_above')}</Select.Option>
<Select.Option value="2">{t('option_below')}</Select.Option>
{/* hide equal and not eqaul in case of analmoy based alert */}
{ruleType !== 'anomaly_rule' && (
<>
<Select.Option value="3">{t('option_equal')}</Select.Option>
<Select.Option value="4">{t('option_notequal')}</Select.Option>
</>
)}
{/* the value 5 and 6 are reserved for above or equal and below or equal */}
{ruleType === 'anomaly_rule' && (
<Select.Option value="7">{t('option_above_below')}</Select.Option>
)}
</InlineSelect>
);
const renderMatchOpts = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultMatchType}
style={{ minWidth: '130px' }}
value={alertDef.condition?.matchType}
data-testid="alert-threshold-match-type-select-v1"
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
>
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
<Select.Option value="2">{t('option_allthetimes')}</Select.Option>
{ruleType !== 'anomaly_rule' && (
<>
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
<Select.Option value="4">{t('option_intotal')}</Select.Option>
<Select.Option value="5">{t('option_last')}</Select.Option>
</>
)}
</InlineSelect>
);
const onChangeEvalWindow = (value: string | unknown): void => {
const ew = (value as string) || alertDef.evalWindow;
setAlertDef({
...alertDef,
evalWindow: ew,
});
};
const onChangeAlgorithm = (value: string | unknown): void => {
const alg = (value as string) || alertDef.condition.algorithm;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
algorithm: alg,
},
});
};
const onChangeSeasonality = (value: string | unknown): void => {
const seasonality = (value as string) || alertDef.condition.seasonality;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
seasonality,
},
});
};
const onChangeDeviation = (value: number): void => {
const target = value || alertDef.condition.target || 3;
setAlertDef({
...alertDef,
condition: { ...alertDef.condition, target: Number(target) },
});
};
const renderEvalWindows = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultEvalWindow}
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
<Select.Option value="4h0m0s">{t('option_4hours')}</Select.Option>
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
</InlineSelect>
);
const renderPromEvalWindows = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultEvalWindow}
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
</InlineSelect>
);
const renderAlgorithms = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultAlgorithm}
style={{ minWidth: '120px' }}
value={alertDef.condition.algorithm}
onChange={onChangeAlgorithm}
>
<Select.Option value="standard">Standard</Select.Option>
</InlineSelect>
);
const renderDeviationOpts = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={3}
style={{ minWidth: '120px' }}
value={alertDef.condition.target}
onChange={(value: number | unknown): void => {
if (typeof value === 'number') {
onChangeDeviation(value);
}
}}
>
<Select.Option value={1}>1</Select.Option>
<Select.Option value={2}>2</Select.Option>
<Select.Option value={3}>3</Select.Option>
<Select.Option value={4}>4</Select.Option>
<Select.Option value={5}>5</Select.Option>
<Select.Option value={6}>6</Select.Option>
<Select.Option value={7}>7</Select.Option>
</InlineSelect>
);
const renderSeasonality = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultSeasonality}
style={{ minWidth: '120px' }}
value={alertDef.condition.seasonality}
onChange={onChangeSeasonality}
>
<Select.Option value="hourly">Hourly</Select.Option>
<Select.Option value="daily">Daily</Select.Option>
<Select.Option value="weekly">Weekly</Select.Option>
</InlineSelect>
);
const renderThresholdRuleOpts = (): JSX.Element => (
<Form.Item>
<Typography.Text>
{t('text_condition1')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
<Typography.Text>is</Typography.Text>
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}{' '}
{t('text_condition3')} {renderEvalWindows()}
</Typography.Text>
</Form.Item>
);
const renderPromRuleOptions = (): JSX.Element => (
<Form.Item>
<Typography.Text>
{t('text_condition1')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
<Typography.Text>is</Typography.Text>
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}
{t('text_condition3')} {renderPromEvalWindows()}
</Typography.Text>
</Form.Item>
);
const onChange: InputNumberProps['onChange'] = (value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
op: alertDef.condition?.op || defaultCompareOp,
matchType: alertDef.condition?.matchType || defaultMatchType,
target: Number(value) || 0,
},
});
};
const onChangeAlertUnit: SelectProps['onChange'] = (value) => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
targetUnit: value as string,
},
});
};
const onChangeFrequency = (value: string | unknown): void => {
const freq = (value as string) || alertDef.frequency;
setAlertDef({
...alertDef,
frequency: freq,
});
};
const renderAnomalyRuleOpts = (): JSX.Element => (
<Form.Item>
<Typography.Text className="rule-definition">
{t('text_condition1_anomaly')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
{t('text_condition3')} {renderEvalWindows()}
<Typography.Text>is</Typography.Text>
{renderDeviationOpts()}
<Typography.Text>deviations</Typography.Text>
{renderCompareOps()}
<Typography.Text>the predicted data</Typography.Text>
{renderMatchOpts()}
using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '}
seasonality
</Typography.Text>
</Form.Item>
);
const renderFrequency = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultFrequency}
style={{ minWidth: '120px' }}
value={alertDef.frequency}
onChange={onChangeFrequency}
>
<Select.Option value="1m0s">{t('option_1min')}</Select.Option>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
<Select.Option value="30m0s">{t('option_30min')}</Select.Option>
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
<Select.Option value="3h0m0s">{t('option_3hours')}</Select.Option>
<Select.Option value="6h0m0s">{t('option_6hours')}</Select.Option>
<Select.Option value="12h0m0s">{t('option_12hours')}</Select.Option>
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
</InlineSelect>
);
const selectedCategory = getCategoryByOptionId(yAxisUnit);
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
return (
<>
<StepHeading>{t('alert_form_step3', { step: step3Label })}</StepHeading>
<FormContainer>
{queryCategory === EQueryType.PROM && renderPromRuleOptions()}
{queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<>{renderAnomalyRuleOpts()}</>
)}
{queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.THRESHOLD_ALERT &&
renderThresholdRuleOpts()}
<Space direction="vertical" size="large">
{ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<Space direction="horizontal" align="center">
<Form.Item noStyle>
<InputNumber
addonBefore={t('field_threshold')}
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
data-testid="alert-threshold-target-input"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Form.Item noStyle>
<Select
className="rule-unit-selector"
getPopupContainer={popupContainer}
allowClear
showSearch
options={categorySelectOptions}
placeholder={t('field_unit')}
value={alertDef.condition.targetUnit}
onChange={onChangeAlertUnit}
/>
</Form.Item>
</Space>
)}
<Collapse>
<Collapse.Panel header={t('More options')} key="1">
<Space direction="vertical" size="large">
<VerticalLine>
<Space direction="horizontal" align="center">
<Typography.Text>{t('text_alert_frequency')}</Typography.Text>
{renderFrequency()}
</Space>
</VerticalLine>
<VerticalLine>
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'alertOnAbsent']}>
<Checkbox
checked={alertDef?.condition?.alertOnAbsent}
onChange={(e): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
alertOnAbsent: e.target.checked,
},
});
}}
/>
</Form.Item>
<Typography.Text>{t('text_alert_on_absent')}</Typography.Text>
<Form.Item noStyle name={['condition', 'absentFor']}>
<InputNumber
min={1}
value={alertDef?.condition?.absentFor}
onChange={(value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
absentFor: Number(value) || 0,
},
});
}}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Typography.Text>{t('text_for')}</Typography.Text>
</Space>
</VerticalLine>
<VerticalLine>
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'requireMinPoints']}>
<Checkbox
checked={alertDef?.condition?.requireMinPoints}
onChange={(e): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
requireMinPoints: e.target.checked,
},
});
}}
/>
</Form.Item>
<Typography.Text>{t('text_require_min_points')}</Typography.Text>
<Form.Item noStyle name={['condition', 'requiredNumPoints']}>
<InputNumber
min={1}
value={alertDef?.condition?.requiredNumPoints}
onChange={(value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
requiredNumPoints: Number(value) || 0,
},
});
}}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Typography.Text>{t('text_num_points')}</Typography.Text>
</Space>
</VerticalLine>
</Space>
</Collapse.Panel>
</Collapse>
</Space>
</FormContainer>
</>
);
}
interface RuleOptionsProps {
alertDef: AlertDef;
setAlertDef: (a: AlertDef) => void;
queryCategory: EQueryType;
queryOptions: DefaultOptionType[];
yAxisUnit: string;
}
export default RuleOptions;

View File

@@ -1,126 +0,0 @@
import { renderHook } from '@testing-library/react';
import { ReduceOperators } from 'types/common/queryBuilder';
import { usePrefillAlertConditions } from '../usePrefillAlertConditions';
const TEST_MAPPINGS = {
op: {
'>': '1',
'<': '2',
'=': '3',
},
matchType: {
avg: '3',
sum: '4',
},
};
jest.mock('react-router-dom-v5-compat', () => {
const mockThreshold1 = {
index: '0d11f426-a02e-48da-867c-b79c6ef1ff06',
isEditEnabled: false,
keyIndex: 1,
selectedGraph: 'graph',
thresholdColor: 'Orange',
thresholdFormat: 'Text',
thresholdLabel: 'Caution',
thresholdOperator: '>',
thresholdTableOptions: 'A',
thresholdUnit: 'rpm',
thresholdValue: 800,
};
const mockThreshold2 = {
index: 'edbe8ef2-fa54-4cb9-b343-7afe883bb714',
isEditEnabled: false,
keyIndex: 0,
selectedGraph: 'graph',
thresholdColor: 'Red',
thresholdFormat: 'Text',
thresholdLabel: 'Danger',
thresholdOperator: '<',
thresholdTableOptions: 'A',
thresholdUnit: 'rpm',
thresholdValue: 900,
};
return {
...jest.requireActual('react-router-dom-v5-compat'),
useLocation: jest.fn().mockReturnValue({
state: {
thresholds: [mockThreshold1, mockThreshold2],
},
}),
};
});
const mockStagedQuery = {
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
],
},
};
describe('usePrefillAlertConditions', () => {
it('returns the correct matchType for a single query', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions(mockStagedQuery as any),
);
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
});
it('returns null matchType for a single query with unsupported time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: { queryData: [{ reduceTo: 'p90' }] },
} as any),
);
expect(result.current.matchType).toBeNull();
});
it('returns the correct matchType for multiple queries with same time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
{
reduceTo: ReduceOperators.AVG,
},
],
},
} as any),
);
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
});
it('returns null matchType for multiple queries with different time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
{
reduceTo: ReduceOperators.SUM,
},
],
},
} as any),
);
expect(result.current.matchType).toBeNull();
});
it('returns the correct op, target, targetUnit from the higher priority threshold for multiple thresholds', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions(mockStagedQuery as any),
);
expect(result.current.op).toBe(TEST_MAPPINGS.op['<']);
expect(result.current.target).toBe(900);
expect(result.current.targetUnit).toBe('rpm');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CircleAlert, CircleX } from '@signozhq/icons';
import { Button, Input, message, Modal } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { map } from 'lodash-es';
import { Labels } from 'types/api/alerts/def';
import { v4 as uuid } from 'uuid';
import { Badge } from '@signozhq/ui/badge';
import { Pill } from '@signozhq/ui/pill';
import { QueryChipContainer, QueryChipItem, SearchContainer } from './styles';
import { ILabelRecord } from './types';
import { createQuery, flattenLabels, prepareLabels } from './utils';
type LabelStep = 'Idle' | 'LabelKey' | 'LabelValue';
type LabelEvent = 'NEXT' | 'onBlur' | 'RESET';
interface LabelSelectProps {
onSetLabels: (q: Labels) => void;
initialValues: Labels | undefined;
}
function LabelSelect({
onSetLabels,
initialValues,
}: LabelSelectProps): JSX.Element | null {
const { t } = useTranslation('alerts');
const isDarkMode = useIsDarkMode();
const [currentVal, setCurrentVal] = useState('');
const [staging, setStaging] = useState<string[]>([]);
const [queries, setQueries] = useState<ILabelRecord[]>(
initialValues ? flattenLabels(initialValues) : [],
);
const [step, setStep] = useState<LabelStep>('Idle');
const dispatchChanges = (updatedRecs: ILabelRecord[]): void => {
onSetLabels(prepareLabels(updatedRecs, initialValues));
setQueries(updatedRecs);
};
const onSelectLabelValue = (): void => {
if (currentVal !== '') {
setStaging((prevState) => [...prevState, currentVal]);
} else {
return;
}
setCurrentVal('');
};
const onValidateQuery = (): void => {
if (currentVal === '') {
return;
}
const generatedQuery = createQuery([...staging, currentVal]);
if (generatedQuery) {
dispatchChanges([...queries, generatedQuery]);
setStaging([]);
setCurrentVal('');
setStep('Idle');
}
};
const send = (event: LabelEvent): void => {
if (event === 'RESET') {
setStep('Idle');
return;
}
if (event === 'NEXT') {
if (step === 'Idle') {
setStep('LabelKey');
} else if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
return;
}
if (event === 'onBlur') {
if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
}
};
const handleFocus = (): void => {
if (step === 'Idle') {
send('NEXT');
}
};
const handleBlur = useCallback((): void => {
if (staging.length === 1 && staging[0] !== undefined) {
send('onBlur');
}
}, [staging]);
useEffect(() => {
handleBlur();
}, [handleBlur]);
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
// Remove the colon if it's the last character.
// As the colon is used to separate the key and value in the query.
setCurrentVal(
event.target?.value.endsWith(':')
? event.target?.value.slice(0, -1)
: event.target?.value,
);
};
const handleClose = (key: string): void => {
dispatchChanges(queries.filter((queryData) => queryData.key !== key));
};
const handleClearAll = (): void => {
Modal.confirm({
title: 'Confirm',
icon: <CircleAlert size="md" />,
content: t('remove_label_confirm'),
onOk() {
send('RESET');
dispatchChanges([]);
setStaging([]);
message.success(t('remove_label_success'));
},
okText: t('button_yes'),
cancelText: t('button_no'),
});
};
const renderPlaceholder = useCallback((): string => {
if (step === 'LabelKey') {
return 'Enter a label key then press ENTER.';
}
if (step === 'LabelValue') {
return `Enter a value for label key(${staging[0]}) then press ENTER.`;
}
return t('placeholder_label_key_pair');
}, [t, step, staging]);
return (
<SearchContainer isDarkMode={isDarkMode} disabled={false}>
<div style={{ display: 'inline-flex', flexWrap: 'wrap' }}>
{queries.length > 0 &&
map(queries, (query): JSX.Element => {
const isClosable =
query.key !== 'severity' && query.key !== 'description';
return (
<QueryChipContainer key={query.key}>
{isClosable ? (
<Pill.Closeable
closeAriaLabel={`Remove ${query.key}`}
onClose={(): void => handleClose(query.key)}
>
{query.key}: {query.value}
</Pill.Closeable>
) : (
<Badge textTransform="none" variant="solid" color="secondary">
{query.key}: {query.value}
</Badge>
)}
</QueryChipContainer>
);
})}
</div>
<div>
{map(staging, (item) => (
<QueryChipItem key={uuid()}>{item}</QueryChipItem>
))}
</div>
<div style={{ display: 'flex', width: '100%' }}>
<Input
data-testid="alert-labels-input-v1"
placeholder={renderPlaceholder()}
onChange={handleLabelChange}
onKeyUp={(e): void => {
if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') {
send('NEXT');
}
if (step === 'Idle') {
send('NEXT');
}
}}
bordered={false}
value={currentVal as never}
style={{ flex: 1 }}
onFocus={handleFocus}
onBlur={handleBlur}
/>
{queries.length > 0 || staging.length > 0 || currentVal ? (
<Button
onClick={handleClearAll}
icon={<CircleX size="md" />}
type="text"
/>
) : null}
</div>
</SearchContainer>
);
}
export default LabelSelect;

View File

@@ -1,41 +0,0 @@
import { grey } from '@ant-design/colors';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
interface SearchContainerProps {
isDarkMode: boolean;
disabled: boolean;
}
export const SearchContainer = styled.div<SearchContainerProps>`
border-radius: 4px;
background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')};
flex: 1;
display: flex;
flex-direction: column;
padding: 0.2rem;
border: 1px solid #ccc5;
${({ disabled }): string => (disabled ? `cursor: not-allowed;` : '')}
`;
export const QueryChipContainer = styled.span`
display: flex;
align-items: center;
margin-right: 0.5rem;
&:hover {
& > * {
background: ${grey.primary}44;
}
}
[data-slot='badge'] {
margin-right: 0.1rem;
}
`;
export const QueryChipItem = styled(Badge).attrs({
color: 'secondary' as const,
variant: 'solid' as const,
textTransform: 'none' as const,
})`
margin-right: 0.1rem;
`;

View File

@@ -1,9 +0,0 @@
export interface ILabelRecord {
key: string;
value: string;
}
export interface IOption {
label: string;
value: string;
}

View File

@@ -1,54 +0,0 @@
import { Labels } from 'types/api/alerts/def';
import { ILabelRecord } from './types';
const hiddenLabels = ['severity', 'description'];
export const createQuery = (
selectedItems: Array<string | string[]> = [],
): ILabelRecord | null => {
if (selectedItems.length === 2) {
return {
key: selectedItems[0] as string,
value: selectedItems[1] as string,
};
}
return null;
};
export const flattenLabels = (labels: Labels): ILabelRecord[] => {
const recs: ILabelRecord[] = [];
Object.keys(labels).forEach((key) => {
if (!hiddenLabels.includes(key)) {
recs.push({
key,
value: labels[key] || '',
});
}
});
return recs;
};
export const prepareLabels = (
recs: ILabelRecord[],
alertLabels: Labels | undefined,
): Labels => {
const labels: Labels = {};
recs.forEach((rec) => {
if (!hiddenLabels.includes(rec.key)) {
labels[rec.key] = rec.value;
}
});
if (alertLabels) {
Object.keys(alertLabels).forEach((key) => {
if (hiddenLabels.includes(key)) {
labels[key] = alertLabels[key];
}
});
}
return labels;
};

View File

@@ -1,80 +1,11 @@
import { Button, Card, Col, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Card } from 'antd';
import styled from 'styled-components';
const { TextArea } = Input;
const { Item } = Form;
export const StyledLeftContainer = styled(Col)`
&&& {
margin-right: 1rem;
}
`;
export const MainFormContainer = styled(Form)``;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 1rem;
margin-bottom: 3rem;
}
`;
export const ActionButton = styled(Button)`
margin-right: 1rem;
`;
export const QueryButton = styled(Button)`
&&& {
display: flex;
align-items: center;
margin-right: 1rem;
}
`;
export const QueryContainer = styled(Card)`
&&& {
margin-top: 1rem;
min-height: 23.5%;
}
`;
export const Container = styled.div`
margin-top: 1rem;
display: flex;
flex-direction: column;
`;
export const StepHeading = styled.p`
margin-top: 1rem;
font-weight: bold;
`;
export const InlineSelect = styled(Select)`
display: inline-block;
width: 10% !important;
margin-left: 0.3em;
margin-right: 0.3em;
`;
export const SeveritySelect = styled(Select)`
width: 25% !important;
`;
export const VerticalLine = styled.div`
border-left: 2px solid #e8e8e8; /* Adjust color and thickness as desired */
padding-left: 20px; /* Adjust spacing to content as needed */
margin-left: 20px; /* Adjust margin as desired */
height: 100%; /* Adjust based on your layout needs */
`;
export const InputSmall = styled(Input)`
width: 40% !important;
`;
export const FormContainer = styled(Card)`
margin-top: 1rem;
display: flex;
@@ -85,19 +16,3 @@ export const FormContainer = styled(Card)`
padding: 12px;
}
`;
export const TextareaMedium = styled(TextArea)`
width: 70%;
`;
export const FormItemMedium = styled(Item)`
width: 70%;
`;
export const ChannelSelectTip = styled(Typography.Text)`
color: hsla(0, 0%, 100%, 0.3);
`;
export const StepContainer = styled.div`
margin-top: 2rem;
`;

View File

@@ -1,91 +0,0 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const THRESHOLD_COLORS_SORTING_ORDER = ['Red', 'Orange', 'Green', 'Blue'];
export const usePrefillAlertConditions = (
stagedQuery: Query | null,
): {
matchType: string | null;
op: string | null;
target: number | undefined;
targetUnit: string | undefined;
} => {
const location = useLocation();
// Extract and set match type
const reduceTo = useMemo(() => {
if (!stagedQuery) {
return null;
}
const isSameTimeAggregation = stagedQuery.builder.queryData.every(
(queryData) =>
queryData?.reduceTo === stagedQuery.builder.queryData[0]?.reduceTo,
);
return isSameTimeAggregation
? stagedQuery.builder.queryData[0]?.reduceTo
: null;
}, [stagedQuery]);
const matchType = useMemo(() => {
switch (reduceTo) {
case 'avg':
return '3';
case 'sum':
return '4';
default:
return null;
}
}, [reduceTo]);
// Extract and set threshold operator, value and unit
const threshold = useMemo(() => {
const { thresholds } = (location.state as {
thresholds: ThresholdProps[];
}) || {
thresholds: null,
};
if (!thresholds || thresholds.length === 0) {
return null;
}
const sortedThresholds = thresholds.sort((a, b) => {
const aIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
a.thresholdColor || '',
);
const bIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
b.thresholdColor || '',
);
return aIndex - bIndex;
});
return sortedThresholds[0];
}, [location.state]);
const thresholdOperator = useMemo(() => {
const op = threshold?.thresholdOperator;
switch (op) {
case '>':
case '>=':
return '1';
case '<':
case '<=':
return '2';
case '=':
return '3';
default:
return null;
}
}, [threshold]);
const thresholdUnit = useMemo(() => threshold?.thresholdUnit, [threshold]);
const thresholdValue = useMemo(() => threshold?.thresholdValue, [threshold]);
return {
matchType,
op: thresholdOperator,
target: thresholdValue,
targetUnit: thresholdUnit,
};
};

View File

@@ -1,14 +0,0 @@
// Write a test for getUpdatedStepInterval function in src/container/FormAlertRules/utils.ts
import { getUpdatedStepInterval } from './utils';
describe('getUpdatedStepInterval', () => {
it('should return 60', () => {
const result = getUpdatedStepInterval('5m0s');
expect(result).toBe(60);
});
it('should return 60 for 10m0s', () => {
const result = getUpdatedStepInterval('10m0s');
expect(result).toBe(60);
});
});

View File

@@ -1,7 +1,4 @@
import { SelectProps } from 'antd';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import getStep from 'lib/getStep';
import {
IBuilderFormula,
IBuilderQuery,
@@ -10,48 +7,6 @@ import {
IPromQLQuery,
} from 'types/api/queryBuilder/queryBuilderData';
// toChartInterval converts eval window to chart selection time interval
export const toChartInterval = (evalWindow: string | undefined): Time => {
switch (evalWindow) {
case '1m0s':
return '1m';
case '5m0s':
return '5m';
case '10m0s':
return '10m';
case '15m0s':
return '15m';
case '30m0s':
return '30m';
case '1h0m0s':
return '1h';
case '3h0m0s':
return '3h';
case '4h0m0s':
return '4h';
case '6h0m0s':
return '6h';
case '12h0m0s':
return '12h';
case '24h0m0s':
return '1d';
default:
return '5m';
}
};
export const getUpdatedStepInterval = (evalWindow?: string): number => {
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: toChartInterval(evalWindow),
});
return getStep({
start,
end,
inputFormat: 'ns',
});
};
export const getSelectedQueryOptions = (
queries:
| Array<

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Divider } from '@signozhq/ui/divider';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import RouteTab from 'components/RouteTab';
@@ -17,7 +16,6 @@ import {
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import AlertHeader from './AlertHeader/AlertHeader';
@@ -80,8 +78,6 @@ function AlertDetails(): JSX.Element {
}
};
const isV2Alert = alertRuleDetails?.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
// Show spinner until we have alert data loaded
if (isLoading && !alertRuleDetails) {
return <Spinner />;
@@ -95,9 +91,9 @@ function AlertDetails(): JSX.Element {
initialAlertState={initialAlertState}
>
<div
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
className="alert-details alert-details-v2"
data-testid="alert-details-root"
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
data-schema-version={alertRuleDetails?.schemaVersion}
>
<AlertBreadcrumb
className="alert-details__breadcrumb"

View File

@@ -5,20 +5,16 @@ import { Divider } from '@signozhq/ui/divider';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import { Copy, Ellipsis, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAlertRule } from 'providers/Alert';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
import { AlertHeaderProps } from '../AlertHeader';
import RenameModal from './RenameModal';
import './ActionButtons.styles.scss';
@@ -29,45 +25,14 @@ function AlertActionButtons({
ruleId: string;
alertDetails: AlertHeaderProps['alertDetails'];
}): JSX.Element {
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
useAlertRule();
const [intermediateName, setIntermediateName] = useState<string>(
alertDetails.alert,
);
const [isRenameAlertOpen, setIsRenameAlertOpen] = useState<boolean>(false);
const { alertRuleState, setAlertRuleState } = useAlertRule();
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
setAlertRuleName,
intermediateName,
});
const handleRename = useCallback(() => {
setIsRenameAlertOpen(true);
}, []);
const onNameChangeHandler = useCallback(() => {
handleAlertUpdate();
setIsRenameAlertOpen(false);
}, [handleAlertUpdate]);
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const menuItems: DropdownItemType[] = [];
if (!isV2Alert) {
menuItems.push({
type: 'item',
value: 'rename-rule',
label: 'Rename',
prefix: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: handleRename,
});
}
menuItems.push(
const menuItems: DropdownItemType[] = [
{
type: 'item',
value: 'clone-rule',
@@ -83,7 +48,7 @@ function AlertActionButtons({
onClick: handleAlertDelete,
danger: true,
},
);
];
// state for immediate UI feedback rather than waiting for onSuccess of handleAlertStateTiggle to updating the alertRuleState
const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState<
@@ -97,12 +62,6 @@ function AlertActionButtons({
}
}, [setAlertRuleState, alertRuleState, alertDetails.state]);
useEffect(() => {
if (alertRuleName !== undefined) {
setIntermediateName(alertRuleName);
}
}, [alertRuleName]);
// on unmount remove the alert state
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => (): void => setAlertRuleState(undefined), []);
@@ -113,50 +72,39 @@ function AlertActionButtons({
}, [handleAlertStateToggle]);
return (
<>
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch
color="primary"
textPlacement="right"
onChange={toggleAlertRule}
value={!isAlertRuleDisabled}
testId="alert-actions-toggle"
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch
color="primary"
textPlacement="right"
onChange={toggleAlertRule}
value={!isAlertRuleDisabled}
testId="alert-actions-toggle"
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" className="alert-action-buttons__divider" />
<Tooltip title="More options">
<span className="dropdown-trigger-wrapper">
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
type="text"
data-testid="alert-actions-menu"
icon={
<Ellipsis
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
/>
}
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" className="alert-action-buttons__divider" />
<Tooltip title="More options">
<span className="dropdown-trigger-wrapper">
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
type="text"
data-testid="alert-actions-menu"
icon={
<Ellipsis
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
/>
}
/>
</Dropdown>
</span>
</Tooltip>
</div>
<RenameModal
isOpen={isRenameAlertOpen}
setIsOpen={setIsRenameAlertOpen}
isLoading={isLoading}
onNameChangeHandler={onNameChangeHandler}
intermediateName={intermediateName}
setIntermediateName={setIntermediateName}
/>
</>
</Dropdown>
</span>
</Tooltip>
</div>
);
}

View File

@@ -1,99 +0,0 @@
.rename-alert {
.ant-modal-content {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
.ant-modal-header {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
.ant-modal-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
.ant-modal-body {
padding: 16px;
.alert-content {
display: flex;
flex-direction: column;
gap: 8px;
.name-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.alert-name-input {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}
}
}
.ant-modal-footer {
padding: 16px;
margin-top: 0px;
.alert-rename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancel-btn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.rename-btn {
display: flex;
align-items: center;
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}
}

View File

@@ -1,96 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button, Input, InputRef, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Check, X } from '@signozhq/icons';
import './RenameModal.styles.scss';
type Props = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
onNameChangeHandler: () => void;
isLoading: boolean;
intermediateName: string;
setIntermediateName: (name: string) => void;
};
function RenameModal({
isOpen,
setIsOpen,
onNameChangeHandler,
isLoading,
intermediateName,
setIntermediateName,
}: Props): JSX.Element {
const inputRef = useRef<InputRef>(null);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleClose = useCallback((): void => setIsOpen(false), [setIsOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (isOpen) {
if (e.key === 'Enter') {
onNameChangeHandler();
} else if (e.key === 'Escape') {
handleClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onNameChangeHandler, handleClose]);
return (
<Modal
open={isOpen}
title="Rename Alert"
onOk={onNameChangeHandler}
onCancel={handleClose}
rootClassName="rename-alert"
footer={
<div className="alert-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={isLoading}
>
Rename Alert
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={handleClose}
>
Cancel
</Button>
</div>
}
>
<div className="alert-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
ref={inputRef}
data-testid="alert-name"
className="alert-name-input"
value={intermediateName}
onChange={(e): void => setIntermediateName(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameModal;

View File

@@ -7,35 +7,4 @@
.alert-header {
flex: 1;
}
&__info-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
height: 54px;
flex: 1;
.top-section {
display: flex;
align-items: center;
justify-content: space-between;
.alert-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
.alert-title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
line-height: 24px;
letter-spacing: -0.08px;
}
}
}
.bottom-section {
display: flex;
align-items: center;
gap: 24px;
}
}
}

View File

@@ -1,17 +1,10 @@
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useAlertRule } from 'providers/Alert';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
import './AlertHeader.styles.scss';
@@ -19,8 +12,8 @@ export type AlertHeaderProps = {
alertDetails: RuletypesRuleDTO | PostableAlertRuleV2;
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert: alertName, labels } = alertDetails;
const { alertRuleState, alertRuleName, setAlertRuleName } = useAlertRule();
const { alert: alertName } = alertDetails;
const { alertRuleName, setAlertRuleName } = useAlertRule();
useEffect(() => {
if (alertRuleName === undefined && alertName) {
@@ -30,50 +23,9 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
useEffect(() => (): void => setAlertRuleName(undefined), [setAlertRuleName]);
const displayName = alertRuleName ?? alertName;
const labelsWithoutSeverity = useMemo(() => {
if (labels) {
return Object.fromEntries(
Object.entries(labels).filter(([key]) => key !== 'severity'),
);
}
return {};
}, [labels]);
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const CreateAlertV1Header = (
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState
state={alertRuleState ?? state ?? ''}
testId="alert-header-state"
/>
<div className="alert-title" data-testid="alert-header-title">
<LineClampedText text={displayName || ''} />
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && (
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
)}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
</div>
</div>
);
return (
<div className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<CreateAlertV2Header />
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}

View File

@@ -1,49 +0,0 @@
@mixin severity-styles($background, $text-color) {
.alert-severity__icon {
background: $background;
}
.alert-severity__text {
color: $text-color;
}
}
.alert-severity {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
&__icon {
display: flex;
align-items: center;
justify-content: center;
height: 14px;
width: 14px;
border-radius: 3.5px;
}
&__text {
color: var(--text-sakura-400);
font-size: 14px;
line-height: 18px;
}
&--critical,
&--error {
@include severity-styles(
color-mix(in srgb, var(--bg-sakura-400) 20%, transparent),
var(--text-sakura-400)
);
}
&--warning {
@include severity-styles(
color-mix(in srgb, var(--bg-amber-400) 20%, transparent),
var(--text-amber-400)
);
}
&--info {
@include severity-styles(
color-mix(in srgb, var(--bg-robin-400) 20%, transparent),
var(--text-robin-400)
);
}
}

View File

@@ -1,69 +0,0 @@
import { useMemo } from 'react';
import * as Sentry from '@sentry/react';
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
import './AlertSeverity.styles.scss';
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
},
};
export default function AlertSeverity({
severity,
testId,
}: {
severity: string;
testId?: string;
}): JSX.Element {
const severityDetails = useMemo(() => {
if (severityConfig[severity]) {
return severityConfig[severity];
}
Sentry.captureEvent({
message: `Received unknown severity on Alert Details: ${severity}`,
level: 'error',
});
return {
text: severity,
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
};
}, [severity]);
return (
<div
className={`alert-severity ${severityDetails.className}`}
data-testid={testId}
>
<div className="alert-severity__icon">{severityDetails.icon}</div>
<div className="alert-severity__text">{severityDetails.text}</div>
</div>
);
}
AlertSeverity.defaultProps = {
testId: undefined,
};

View File

@@ -8,11 +8,9 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
cloneRuleByID,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
@@ -52,8 +50,7 @@ import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { nanoToMilli } from 'utils/timeUtils';
import { Typography } from '@signozhq/ui/typography';
@@ -518,60 +515,6 @@ export const useAlertRuleClone = ({
return { handleAlertClone };
};
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,
intermediateName,
}: {
alertDetails: AlertDef;
setAlertRuleName: (name: string | undefined) => void;
intermediateName: string;
}): {
handleAlertUpdate: () => void;
isLoading: boolean;
} => {
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const { mutate: updateAlertRule, isLoading } = useMutation(
[REACT_QUERY_KEY.UPDATE_ALERT_RULE, alertDetails.id],
(args: { data: AlertDef; id: string }) =>
updateRuleByID({ id: args.id }, toPostableRuleDTOFromAlertDef(args.data)),
{
onMutate: () => setAlertRuleName(intermediateName),
onSuccess: () => {
const ruleId = alertDetails.id || '';
const ruleQueryKey = getGetRuleByIDQueryKey({ id: ruleId });
const existingRule = queryClient.getQueryData<GetRuleByID200>(ruleQueryKey);
if (existingRule) {
queryClient.setQueryData<GetRuleByID200>(ruleQueryKey, {
...existingRule,
data: { ...existingRule.data, alert: intermediateName },
});
}
void invalidateListRules(queryClient);
notifications.success({ message: 'Alert renamed successfully' });
},
onError: (error) => {
setAlertRuleName(alertDetails.alert);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
},
);
const handleAlertUpdate = (): void => {
updateAlertRule({
data: { ...alertDetails, alert: intermediateName },
id: alertDetails.id || '',
});
};
return { handleAlertUpdate, isLoading };
};
export const useAlertRuleDelete = ({
ruleId,
}: {

View File

@@ -6,7 +6,7 @@
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { rest } from 'msw';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
@@ -23,11 +23,9 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
notificationChannelsResponse,
CHANNEL_MAX,
FIRST_RULE_NAME,
type AlertSchema,
} from '../../stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
@@ -77,13 +75,6 @@ export const alertHistoryMocks = defineStoryMocks({
value: 30,
max: 60,
}),
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: TIMELINE,
description:
'Which form the Overview tab opens the rule in. The history tab only shows it in the breadcrumb and the header.',
options: ALERT_SCHEMAS,
value: 'v2',
}),
},
handlers: (values, response) => [
rest.get(
@@ -142,7 +133,6 @@ export const alertHistoryMocks = defineStoryMocks({
alertRuleByIdResponse(String(req.params.id), {
severity: 'mixed',
state: 'mixed',
schema: values.alertSchema,
}),
),
),

View File

@@ -11,13 +11,11 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
notificationChannelsResponse,
CHANNEL_MAX,
RULE_KINDS,
RULE_STATE_CHOICES,
SEVERITY_CHOICES,
type AlertSchema,
type RuleKind,
type RuleStateChoice,
type SeverityChoice,
@@ -41,13 +39,6 @@ type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
export const alertOverviewMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: RULE,
description:
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
options: ALERT_SCHEMAS,
value: 'v2',
}),
ruleKind: choiceControl<RuleKind>('Rule kind', {
group: RULE,
description:
@@ -94,7 +85,6 @@ export const alertOverviewMocks = defineStoryMocks({
alertRuleByIdResponse(String(req.params.id), {
severity: values.ruleSeverity,
state: values.ruleState,
schema: values.alertSchema,
kind: values.ruleKind,
}),
),

View File

@@ -39,11 +39,6 @@ type Story = StoryObj<AlertOverviewArgs>;
*/
export const Default: Story = {};
/** A rule written before the current schema, which opens in the classic form. */
export const ClassicSchema: Story = {
args: { alertSchema: 'classic' },
};
/**
* An anomaly rule in the current schema: the condition reads in deviations from
* the forecast, and the preview draws the band the rule compares against.
@@ -68,11 +63,6 @@ export const AnomalyRuleForecastBand: Story = {
},
};
/** An anomaly rule written before the current schema, still in the classic form. */
export const ClassicAnomalyRule: Story = {
args: { ruleKind: 'anomaly', alertSchema: 'classic' },
};
/** A rule someone turned off: the toggle in the header is what turns it back on. */
export const Disabled: Story = {
args: { ruleState: 'disabled' },

View File

@@ -16,8 +16,6 @@ import {
notificationChannelResponse,
type ChannelActionOutcome,
type ChannelType,
WEBHOOK_AUTHS,
type WebhookAuthChoice,
} from '../../stories/__story_mockdata__/alerts';
const STORY_CHANNEL_ID = '1';
@@ -40,13 +38,6 @@ export const channelsEditMocks = defineStoryMocks({
options: CHANNEL_TYPES,
value: 'slack',
}),
webhookAuth: choiceControl<WebhookAuthChoice>('Webhook credentials', {
group: CHANNEL,
description:
'What the saved webhook authenticates with, which picks the auth option the form opens on. Only read when the channel type is webhook.',
options: WEBHOOK_AUTHS,
value: 'basic',
}),
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
@@ -62,11 +53,7 @@ export const channelsEditMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v2/notification_channels/:id',
response.json((req) =>
notificationChannelResponse(
String(req.params.id),
values.channelType,
values.webhookAuth,
),
notificationChannelResponse(String(req.params.id), values.channelType),
),
),

View File

@@ -117,16 +117,6 @@ export const Webhook: Story = {
args: { channelType: 'webhook' },
};
/** The webhook channel saved with a bearer token: only the token field shows. */
export const WebhookBearerToken: Story = {
args: { channelType: 'webhook', webhookAuth: 'bearer' },
};
/** The webhook channel saved without credentials: the auth option is None. */
export const WebhookWithoutAuth: Story = {
args: { channelType: 'webhook', webhookAuth: 'none' },
};
/**
* The Opsgenie channel, whose form carries the integration API key and the
* priority the alert is raised at.

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { expect, screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -42,24 +42,6 @@ const selectChannelType = async (
await within(trigger).findByText(label);
};
const fillName = async (
canvasElement: HTMLElement,
name: string,
): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByTestId('channel-name-textbox'),
name,
);
};
const sendTest = async (canvasElement: HTMLElement): Promise<void> => {
const test = within(canvasElement).getByTestId('test-channel-button');
await waitFor(() => expect(test).toHaveAttribute('aria-disabled', 'false'));
await userEvent.click(test);
await screen.findByText('An alert has been sent to this channel');
};
/** A new notification channel, on the Slack form the page opens with. */
export const Default: Story = {};
@@ -141,79 +123,6 @@ export const Webhook: Story = {
},
};
/**
* Basic auth picked on the webhook form: the username and password show, and
* saving with both empty asks for one of them.
*/
export const WebhookBasicAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-basic'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a username or password');
await expect(
canvas.queryByTestId('webhook-bearer-token-textbox'),
).not.toBeInTheDocument();
},
};
/**
* Bearer auth picked on the webhook form: only the token shows, and saving
* without one asks for it.
*/
export const WebhookBearerAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-bearer'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a bearer token');
await expect(
canvas.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
},
};
/**
* A Slack field with a title but no value, and a button with text but neither
* a URL nor a name. Each is flagged under its row rather than sent for the API
* to reject.
*/
export const SlackRowErrors: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await fillName(canvasElement, 'alerts-slack');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('slack-field-add'));
await userEvent.type(canvas.getByTestId('slack-field-title-0'), 'Region');
await userEvent.click(canvas.getByTestId('slack-action-add'));
await userEvent.type(canvas.getByTestId('slack-action-text-0'), 'Dashboard');
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a value for this field');
await canvas.findByText('Enter a URL or a name for this button');
},
};
/** The PagerDuty form: routing key, severity and the incident details. */
export const PagerDuty: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -242,30 +151,16 @@ export const MicrosoftTeams: Story = {
},
};
/** The Google Chat form: the space's incoming webhook and the message text. */
/**
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
* on `chat.googleapis.com`.
*/
export const GoogleChat: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
},
};
/**
* The Google Chat form pointed at a relay rather than `chat.googleapis.com`.
* The API accepts any webhook URL, so the test send goes through.
*/
export const GoogleChatRelayURL: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
await fillName(canvasElement, 'alerts-google-chat');
await userEvent.type(
within(canvasElement).getByTestId('webhook-url-textbox'),
'http://chat-relay.internal/v1/spaces/OPS/messages',
);
await sendTest(canvasElement);
},
};
/**
* The Jira form: where the issue is filed, the transitions that close and
* reopen it, and the Atlassian account the API token belongs to.
@@ -276,29 +171,6 @@ export const Jira: Story = {
},
};
/**
* The Jira form pointed at a proxy rather than an `atlassian.net` site. The API
* accepts any site URL, so the test send goes through.
*/
export const JiraProxySite: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Jira$/);
await fillName(canvasElement, 'alerts-jira');
await userEvent.type(
canvas.getByTestId('jira-site-textbox'),
'https://jira-proxy.acme.internal',
);
await userEvent.type(canvas.getByTestId('jira-email-textbox'), 'ops@acme.io');
await userEvent.type(canvas.getByTestId('jira-api-token-textbox'), 'token');
await userEvent.type(canvas.getByTestId('jira-project-textbox'), 'OPS');
await userEvent.type(canvas.getByTestId('jira-issue-type-textbox'), 'Bug');
await sendTest(canvasElement);
},
};
/** The Jira Service Management Ops form: the API key, priority and tags. */
export const JiraServiceManagementOps: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -313,31 +185,6 @@ export const IncidentIO: Story = {
},
};
/**
* The incident.io form pointed at a relay, which the API accepts, with the token
* pasted as the setup page shows it. The `Bearer ` prefix is flagged, since it
* would be sent twice.
*/
export const IncidentIOBearerToken: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^incident\.io$/);
await fillName(canvasElement, 'alerts-incidentio');
await userEvent.type(
canvas.getByTestId('incidentio-url-textbox'),
'https://incidentio-relay.internal/alerts',
);
await userEvent.type(
canvas.getByTestId('incidentio-token-textbox'),
'Bearer story-token',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Paste the token only, without the Bearer prefix');
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -45,18 +45,15 @@ const CASES: PrefillCase[] = [
url: 'https://example.com/hook',
username: 'hook-user',
password: 'hook-pass',
bearerToken: 'hook-token',
},
fields: [
['webhook-url-textbox', 'https://example.com/hook'],
['webhook-username-textbox', 'hook-user'],
['webhook-password-textbox', 'hook-pass'],
['webhook-bearer-token-textbox', 'hook-token'],
],
},
{
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'hook-token' },
fields: [['webhook-bearer-token-textbox', 'hook-token']],
},
{
kind: 'email',
spec: { to: 'oncall@signoz.io', html: '<p>alert</p>' },

View File

@@ -118,104 +118,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends only the credentials of the picked webhook auth',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Webhook$/ }));
await user.type(screen.getByTestId('channel-name-textbox'), 'hook alerts');
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await user.click(screen.getByTestId('webhook-auth-radio-item-basic'));
await user.type(screen.getByTestId('webhook-username-textbox'), 'hook-user');
await user.click(screen.getByTestId('webhook-auth-radio-item-bearer'));
expect(
screen.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'hook-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
expect(
(body as { config: { spec: Record<string, unknown> } }).config.spec,
).toStrictEqual({
url: 'https://example.com/hook',
bearerToken: 'hook-token',
sendResolved: true,
});
},
TEST_TIMEOUT,
);
it(
'flags a slack field without a value instead of sending it',
async () => {
const onCreate = jest.fn();
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
(_, res, ctx) => {
onCreate();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(
await screen.findByTestId('channel-name-textbox', {}, FIND),
'slack alerts',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await user.click(screen.getByTestId('slack-field-add'));
await user.type(screen.getByTestId('slack-field-title-0'), 'env');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await screen.findByText('slack_field_value_required', {}, FIND);
expect(onCreate).not.toHaveBeenCalled();
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -263,74 +165,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps bearer auth while a saved token is cleared and replaced',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.get(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: '3',
name: 'webhook-channel',
displayName: 'webhook channel',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'old-token' },
},
},
}),
),
),
rest.put(
'http://localhost/api/v2/notification_channels/:id',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
},
),
);
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const token = await screen.findByTestId(
'webhook-bearer-token-textbox',
{},
FIND,
);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.clear(token);
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'new-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
expect(body).toStrictEqual({
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'new-token' },
},
});
},
TEST_TIMEOUT,
);
it(
'opens read-only, with saving disabled rather than hidden, when update is denied',
async () => {

View File

@@ -70,10 +70,6 @@ export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
export const RULE_KINDS = ['threshold', 'anomaly'] as const;
export type RuleKind = (typeof RULE_KINDS)[number];
@@ -355,7 +351,6 @@ const ruleName = (index: number): string => {
export interface RuleShape {
severity: SeverityChoice;
state: RuleStateChoice;
schema?: AlertSchema;
kind?: RuleKind;
}
@@ -398,8 +393,7 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
updatedAt: ago((index + 1) * HOUR),
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
schemaVersion:
shape.schema === 'classic' ? undefined : NEW_ALERT_SCHEMA_VERSION,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
version: 'v5',
source: 'http://localhost/alerts',
preferredChannels: ['ops-slack'],
@@ -853,22 +847,6 @@ export const channelNames = (count: number): string[] =>
(_unused, index) => CHANNEL_SEEDS[index % CHANNEL_SEEDS.length].name,
);
export const WEBHOOK_AUTHS = ['basic', 'bearer', 'none'] as const;
export type WebhookAuthChoice = (typeof WEBHOOK_AUTHS)[number];
const WEBHOOK_CREDENTIALS: Record<WebhookAuthChoice, Record<string, string>> = {
basic: { username: 'story-user' },
bearer: { bearerToken: 'story-bearer-token' },
none: {},
};
const webhookSpec = (auth: WebhookAuthChoice): Record<string, unknown> => ({
url: 'https://example.com/story-hook',
...WEBHOOK_CREDENTIALS[auth],
sendResolved: true,
});
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
@@ -897,7 +875,11 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
],
sendResolved: true,
},
webhook: webhookSpec('basic'),
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
@@ -961,7 +943,6 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
export const notificationChannelResponse = (
id: string,
type: ChannelType,
webhookAuth: WebhookAuthChoice = 'basic',
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
@@ -982,7 +963,7 @@ export const notificationChannelResponse = (
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: type === 'webhook' ? webhookSpec(webhookAuth) : CHANNEL_SPECS[type],
spec: CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};

View File

@@ -3,9 +3,9 @@
* Do not hand-edit: regenerate instead.
*/
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { rest } from 'msw';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -28,8 +28,7 @@ import {
/**
* Which alert the page is building. The page reads this off the URL, so the
* control is a route rather than a response: with no type at all it stays on
* the picker, `classic-form` opts back into the classic form, and everything else
* opens the current one.
* the picker and everything else opens the form.
*/
const ALERT_MODES = [
'select-type',
@@ -38,7 +37,6 @@ const ALERT_MODES = [
'traces',
'exceptions',
'anomaly',
'classic-form',
] as const;
type AlertMode = (typeof ALERT_MODES)[number];
@@ -49,7 +47,6 @@ const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
traces: AlertTypes.TRACES_BASED_ALERT,
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
anomaly: AlertTypes.METRICS_BASED_ALERT,
'classic-form': AlertTypes.METRICS_BASED_ALERT,
};
const routeFor = (mode: AlertMode): string => {
@@ -63,15 +60,11 @@ const routeFor = (mode: AlertMode): string => {
[QueryParams.alertType]: alertType,
[QueryParams.ruleType]:
mode === 'anomaly'
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
[QueryParams.relativeTime]: '6h',
});
if (mode === 'classic-form') {
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
}
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
};

View File

@@ -81,11 +81,6 @@ export const AnomalyAlertRoutingPolicies: Story = {
},
};
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
export const ClassicForm: Story = {
args: { alertMode: 'classic-form' },
};
/**
* The match-type tooltip on the threshold sentence: a paragraph on what an
* aggregated data point is, a worked example over five of them, and the docs

View File

@@ -11,19 +11,12 @@ import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import EditRulesContainer from 'container/EditRules';
import EditAlertV2 from 'container/EditAlertV2';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import {
fromRuleDTOToAlertDef,
fromRuleDTOToPostableRuleV2,
} from 'types/api/alerts/convert';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import './EditRules.styles.scss';
@@ -96,17 +89,13 @@ function EditRules(): JSX.Element {
return <Spinner tip="Loading Rules..." />;
}
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
if (ruleData.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = fromRuleDTOToPostableRuleV2(ruleData);
}
const initialAlert = fromRuleDTOToPostableRuleV2(ruleData);
return (
<div className="edit-rules-container">
<EditRulesContainer
ruleId={ruleId || ''}
initialValue={fromRuleDTOToAlertDef(ruleData)}
initialV2AlertValue={initialV2AlertValue}
<EditAlertV2
initialAlert={initialAlert}
alertType={initialAlert.alertType}
/>
</div>
);

View File

@@ -1,150 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertFieldKeysResponse,
alertFieldValuesResponse,
alertMetricMetadataResponse,
alertMetricsResponse,
alertPreviewSeries,
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
notificationChannelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../../AlertList/stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
const RULE = 'Edit rule · rule';
export const editRulesMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
group: RULE,
description:
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
options: ALERT_SCHEMAS,
value: 'classic',
}),
channels: countControl('Notification channels', {
group: RULE,
value: 5,
max: CHANNEL_MAX,
}),
previewSeries: countControl('Preview series', {
group: RULE,
description: 'Lines the chart above the condition draws.',
value: 3,
max: 6,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v2/rules/:id',
response.json((req) =>
alertRuleByIdResponse(String(req.params.id), {
severity: 'critical',
state: 'firing',
schema: values.alertSchema,
}),
),
),
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
}),
),
),
rest.get(
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
),
rest.get(
'http://localhost/api/v2/metrics',
response.json((req) =>
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/keys',
response.json((req) =>
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
alertFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
),
),
),
],
config: () => ({
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
}),
});
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LABEL_INPUT = 'alert-labels-input-v1';
/** The labels field, scrolled into view once the rule has loaded. */
export const findLabelInput = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const input = await within(canvasElement).findByTestId(
LABEL_INPUT,
undefined,
untilLoaded,
);
input.scrollIntoView({ block: 'center' });
return input;
};
export const removeLabel = async (
canvasElement: HTMLElement,
key: string,
): Promise<void> => {
await userEvent.click(
within(canvasElement).getByRole('button', { name: `Remove ${key}` }),
);
};

View File

@@ -1,137 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
editRulesMocks,
findLabelInput,
removeLabel,
} from './EditRules.stories.mocks';
import EditRules from '../index';
type EditRulesArgs = PageStoryArgs<typeof editRulesMocks>;
const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
/**
* An existing rule in the builder that created it, loaded from
* `/api/v2/rules/:id`.
*
* Route: `/alerts/edit?ruleId=...`.
*/
const meta = {
title: 'Pages/Alerts/Edit',
tags: ['play'],
component: EditRules,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<EditRulesArgs>;
export default meta;
type Story = StoryObj<EditRulesArgs>;
/**
* The alert form on its own route, without the alert-details tabs around it.
* Metrics Explorer and the assistant link here when they send someone to a rule.
* The rule is on the classic schema, which is the only one this route renders:
* see the Alert schema control for what a current-schema rule does here.
*/
export const Default: Story = {};
/** The rule id in the URL does not resolve, so the page offers the way back. */
export const RuleNotFound: Story = {
args: { dataState: 'error' },
// The mocked rule request intentionally fails; the resulting console error is
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
};
/**
* The preview chart's legend, one tooltip per series carrying the full label a
* truncated legend entry falls back to. The Preview series control is turned up
* to its maximum so the legend wraps to a second row, which is where a label
* gets clipped.
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true, previewSeries: 6 },
};
/** The rule's own labels, each with its remove button, above the empty input. */
export const Labels: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
const canvas = within(canvasElement);
await canvas.findByText('team: platform');
await canvas.findByText('env: prod');
},
};
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
export const LabelKeyEntered: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await within(canvasElement).findByPlaceholderText(
'Enter a value for label key(region) then press ENTER.',
);
},
};
/** A key and its value confirmed, added after the rule's own labels. */
export const LabelAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await userEvent.type(input, 'us-east-1{enter}');
await within(canvasElement).findByText('region: us-east-1');
},
};
/** One of the rule's labels removed, the other left in place. */
export const LabelRemoved: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team');
await waitFor(() =>
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
);
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
},
};
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
export const NoLabels: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team');
await removeLabel(canvasElement, 'env');
await within(canvasElement).findByPlaceholderText(
'Click here to enter a label (key value pairs)',
);
await expect(input.parentElement?.querySelector('button')).toBeNull();
},
};
/** The confirmation the clear-all button opens before it drops every label. */
export const ClearLabelsConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
const clearAll = input.parentElement?.querySelector('button');
if (!clearAll) {
throw new Error('Clear-all labels button did not render');
}
await userEvent.click(clearAll);
await screen.findByText(
'This action will remove all the labels. Do you want to proceed?',
);
},
};

View File

@@ -59,8 +59,6 @@ export interface PostableAlertRuleV2 {
};
usePolicy?: boolean;
};
version?: string;
source?: string;
state?: string;
disabled?: boolean;
}

View File

@@ -16,7 +16,7 @@ import { EQueryType } from 'types/common/dashboard';
import { AlertTypes } from './alertTypes';
import { PostableAlertRuleV2 } from './alertTypesV2';
import { ICompositeMetricQuery } from './compositeQuery';
import { AlertDef, Labels } from './def';
import { Labels } from './def';
function toRuleTypeDTO(ruleType: string | undefined): RuletypesRuleTypeDTO {
switch (ruleType) {
@@ -69,7 +69,10 @@ function stripUndefinedLabels(
export function toPostableRuleDTO(
local: PostableAlertRuleV2,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
const payload: Record<
Exclude<keyof RuletypesPostableRuleDTO, 'source' | 'version'>,
any
> = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
@@ -83,49 +86,18 @@ export function toPostableRuleDTO(
notificationSettings: local.notificationSettings,
evaluation: local.evaluation,
schemaVersion: local.schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
};
return payload as unknown as RuletypesPostableRuleDTO;
}
export function toPostableRuleDTOFromAlertDef(
local: AlertDef,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
condition: local.condition,
annotations: local.annotations,
labels: stripUndefinedLabels(local.labels),
evalWindow: local.evalWindow,
frequency: local.frequency,
preferredChannels: local.preferredChannels,
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
.notificationSettings,
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
};
return payload;
}
export function fromRuleDTOToPostableRuleV2(
dto: RuletypesRuleDTO,
): PostableAlertRuleV2 {
return dto as unknown as PostableAlertRuleV2;
}
export function fromRuleDTOToAlertDef(dto: RuletypesRuleDTO): AlertDef {
return dto as unknown as AlertDef;
}
function toEQueryType(queryType: RuletypesQueryTypeDTO): EQueryType {
switch (queryType) {
case RuletypesQueryTypeDTO.builder:

View File

@@ -254,150 +254,6 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
},
},
},
{
Name: "ai_traces_trace",
Summary: "Trace list: AI traces with many tool calls, most expensive first",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "trace",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_ai_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "trace.tool_call_count > 10 AND trace.distinct_tool_count < 3"},
"order": []any{
map[string]any{"key": map[string]any{"name": "trace.estimated_total_cost"}, "direction": "desc"},
},
"limit": 10,
},
},
},
},
},
},
{
Name: "ai_traces_raw",
Summary: "Raw: LLM, tool, and agent spans of one service's AI traces with many tool calls",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_ai_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'ai-agent' AND trace.tool_call_count > 10"},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "span"}, "direction": "desc"},
},
"limit": 100,
},
},
},
},
},
},
{
Name: "ai_traces_raw_select_fields",
Summary: "Raw: the same spans with the span name and gen_ai model, tool, and token fields selected",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_ai_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'ai-agent' AND trace.tool_call_count > 10"},
"selectFields": []any{
map[string]any{"name": "name", "fieldContext": "span"},
map[string]any{"name": "gen_ai.request.model", "fieldContext": "attribute"},
map[string]any{"name": "gen_ai.tool.name", "fieldContext": "attribute"},
map[string]any{"name": "gen_ai.usage.input_tokens", "fieldContext": "attribute"},
map[string]any{"name": "gen_ai.usage.output_tokens", "fieldContext": "attribute"},
},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "span"}, "direction": "desc"},
},
"limit": 100,
},
},
},
},
},
},
{
Name: "ai_traces_time_series",
Summary: "Time series: per-trace AI cost by model",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_ai_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "sum(trace.estimated_total_cost)"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "gen_ai.request.model", "fieldContext": "attribute"}},
"order": []any{
map[string]any{"key": map[string]any{"name": "sum(trace.estimated_total_cost)"}, "direction": "desc"},
},
"limit": 10,
},
},
},
},
},
},
{
Name: "ai_traces_scalar",
Summary: "Scalar: average per-trace AI cost by model",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_ai_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "avg(trace.estimated_total_cost)"},
},
"groupBy": []any{map[string]any{"name": "gen_ai.request.model", "fieldContext": "attribute"}},
"order": []any{
map[string]any{"key": map[string]any{"name": "avg(trace.estimated_total_cost)"}, "direction": "desc"},
},
"limit": 10,
},
},
},
},
},
},
{
Name: "logs_scalar",
Summary: "Scalar: total error log count",

View File

@@ -55,23 +55,16 @@ const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
const TRACES_HISTORY_SERVICES = 3;
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
export interface AlertHistorySeed {
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
ruleId: string;
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
ruleIdV1: string;
channelName: string;
/** The `body CONTAINS` marker both rules match. */
/** The `body CONTAINS` marker the rule matches. */
marker: string;
/** The seeded `service.name` values, in creation order. */
services: string[];
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
total: number;
/** Baseline `total` for {@link ruleIdV1}. */
totalV1: number;
}
export interface MetricsHistorySeed {
@@ -154,33 +147,20 @@ async function createAlertHistorySeed(
name: `e2e-ah-rule-v2-${stamp}`,
marker,
channels: [channel.name],
schema: 'v2',
});
const ruleIdV1 = await createLogsAlertViaApi(page, {
name: `e2e-ah-rule-v1-${stamp}`,
marker,
channels: [channel.name],
schema: 'v1',
extraLabels: { team: V1_RULE_TEAM_LABEL },
});
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
await setRuleDisabledViaApi(page, ruleId, true);
await setRuleDisabledViaApi(page, ruleIdV1, true);
return {
seed: {
ruleId,
ruleIdV1,
channelName: channel.name,
marker,
services,
total: await readTimelineTotal(page, ruleId),
totalV1: await readTimelineTotal(page, ruleIdV1),
},
ruleIds: [ruleId, ruleIdV1],
ruleIds: [ruleId],
channelId: channel.id,
};
});

View File

@@ -9,7 +9,6 @@ import {
} from '../../helpers/alerts/api';
import { seedAlertRules } from '../../helpers/alerts/seeding';
import type {
AlertSchema,
LogsAlertSeed,
ThresholdAlertSeed,
} from '../../helpers/alerts/types';
@@ -57,22 +56,12 @@ export interface OwnedRules {
/**
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
* it never fires — enough for anything about the details shell.
*
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
* a v1 *prefill* assertion can be made against values the create form would
* not have produced by itself.
*/
logs(
options: {
name: string;
schema?: AlertSchema;
marker?: string;
} & Partial<
Pick<
LogsAlertSeed,
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
>
>,
} & Partial<Pick<LogsAlertSeed, 'severity' | 'extraLabels' | 'evalWindow'>>,
): Promise<string>;
/**
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
@@ -185,13 +174,12 @@ function createOwnedRulesFactory(
}),
),
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
logs: ({ name, marker, ...overrides }) =>
seed((page) =>
createLogsAlertViaApi(page, {
name,
marker: marker ?? `e2e alert never seeded ${name}`,
channels: [channelName],
schema,
...overrides,
}),
),

View File

@@ -44,10 +44,10 @@ export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
/**
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
* alertHistory: seeds 25 logs + 1 rule, waits for ruler evaluation.
*
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
* Breakdown: createChannel(2s) + seedLogs(10s) + createRule(2s) +
* waitForEntries(35s) + disableRule(2s) = ~51s.
* Timeout: 240s (~2.5x headroom).
*/
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;

View File

@@ -7,7 +7,6 @@ import {
RuleType,
STOCK_ALERT_TYPE_CARDS,
} from './constants';
import { v1SaveButton } from './v1';
// ─── Navigation ────────────────────────────────────────────────────────────
@@ -70,8 +69,6 @@ export async function expectAlertTypeCardSet(page: Page): Promise<void> {
export interface CreateAlertUrlOptions {
alertType?: AlertTypeValue;
ruleType?: string;
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
classic?: boolean;
/** Merged in last, so it can override anything above. */
params?: Record<string, string>;
}
@@ -79,13 +76,9 @@ export interface CreateAlertUrlOptions {
export function createAlertUrl({
alertType = AlertType.LOGS,
ruleType = RuleType.THRESHOLD,
classic = false,
params = {},
}: CreateAlertUrlOptions = {}): string {
const search = new URLSearchParams({ alertType, ruleType });
if (classic) {
search.set('showClassicCreateAlertsPage', 'true');
}
for (const [key, value] of Object.entries(params)) {
search.set(key, value);
}
@@ -101,18 +94,9 @@ export function createAlertUrl({
*/
export async function gotoCreateAlertV2(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
options: CreateAlertUrlOptions = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: false }));
await page.goto(createAlertUrl(options));
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
}
/** Open the **v1** classic create form and wait for its primary action. */
export async function gotoCreateAlertV1(
page: Page,
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
): Promise<void> {
await page.goto(createAlertUrl({ ...options, classic: true }));
await expect(v1SaveButton(page)).toBeVisible();
}

View File

@@ -63,11 +63,8 @@ export function dropdownOption(page: Page, label: string): Locator {
}
/**
* Pick a notification channel by exact name in one of the two channel selects —
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
* `mode="multiple"` antd selects over the *same* global channel list, so both need
* exactly this sequence; the shared body is why this is one function rather than
* two near-copies.
* Pick a notification channel by exact name in a threshold's channel select, a
* `mode="multiple"` antd select over the global channel list.
*
* The list must be **searched**, not scrolled. Channels are global while the
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
@@ -115,11 +112,10 @@ export async function pickChannelByName(
// Fail here rather than three assertions later: a silently-missed pick shows up
// as "Save is still disabled", which points at the validator instead of at this.
//
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
// renders empty. Neither can ever contain the full channel name. The name itself
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
// Counted, not name-matched: the select sets `maxTagTextLength={10}`
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…` and can never contain
// the full channel name. The name itself is verified where it actually matters,
// in the request body (CV2-20).
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
}

View File

@@ -1,10 +0,0 @@
import { type Locator, type Page } from '@playwright/test';
/**
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
* scenarios that care about the mode assert the text; the locator itself does not.
*/
export function v1SaveButton(page: Page): Locator {
return page.getByTestId('alert-save-button');
}

View File

@@ -7,11 +7,9 @@ import {
logsCompositeQuery,
metricsCompositeQuery,
tracesCompositeQuery,
v1RulePayload,
v2RulePayload,
} from './payloads';
import type {
AlertSchema,
LogsAlertSeed,
MetricAlertSeed,
ThresholdAlertSeed,
@@ -87,68 +85,52 @@ export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
async function postRule(
page: Page,
schema: AlertSchema,
payload: Record<string, unknown>,
): Promise<string> {
const token = await authToken(page);
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
const res = await page.request.post(path, {
const res = await page.request.post('/api/v2/rules', {
data: payload,
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return String(json.data.id);
}
/**
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
* history APIs, so history scenarios can be parameterised over them.
*/
/** Create a logs threshold rule grouped by `service.name`. */
export async function createLogsAlertViaApi(
page: Page,
{
name,
marker,
channels,
schema = 'v2',
evalWindow = '5m0s',
frequency = '15s',
severity = schema === 'v1' ? 'warning' : 'critical',
severity = 'critical',
extraLabels,
alertOnAbsent,
absentFor,
target,
op,
matchType,
}: LogsAlertSeed,
): Promise<string> {
const extraCondition =
alertOnAbsent === undefined
? undefined
: { alertOnAbsent, absentFor: absentFor ?? 1 };
const args = {
name,
alertType: 'LOGS_BASED_ALERT',
compositeQuery: logsCompositeQuery(marker),
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
target,
op,
matchType,
};
return postRule(
page,
schema,
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
v2RulePayload({
name,
alertType: 'LOGS_BASED_ALERT',
compositeQuery: logsCompositeQuery(marker),
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
}),
);
}
@@ -169,7 +151,6 @@ export async function createTracesAlertViaApi(
): Promise<string> {
return postRule(
page,
'v2',
v2RulePayload({
name,
alertType: 'TRACES_BASED_ALERT',
@@ -196,7 +177,6 @@ export async function createMetricAlertViaApi(
): Promise<string> {
return postRule(
page,
'v2',
v2RulePayload({
name,
alertType: 'METRIC_BASED_ALERT',

View File

@@ -9,7 +9,7 @@ const ANNOTATIONS = {
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
};
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
// A minimal but valid v2 (schemaVersion v2alpha1) threshold rule
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
// CreateAlertV2 UI posts to POST /api/v2/rules.
export function buildThresholdRulePayload({
@@ -43,9 +43,7 @@ export function buildThresholdRulePayload({
alertType: 'METRIC_BASED_ALERT',
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
...(labels ? { labels } : {}),
annotations: ANNOTATIONS,
evaluation: {
@@ -234,9 +232,7 @@ export function v2RulePayload({
alertType,
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
version: 'v5',
disabled: false,
source: '',
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
@@ -266,59 +262,3 @@ export function v2RulePayload({
},
};
}
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
// enum forms the v1 validator requires. `labels.severity` becomes the history
// `threshold.name`.
export function v1RulePayload({
name,
alertType,
compositeQuery,
channels,
severity,
extraLabels,
evalWindow,
frequency,
extraCondition,
target = 0,
op = '1',
matchType = '1',
}: {
name: string;
alertType: string;
compositeQuery: Record<string, unknown>;
channels: string[];
severity: string;
extraLabels?: Record<string, string>;
evalWindow: string;
frequency: string;
extraCondition?: Record<string, unknown>;
target?: number;
op?: string;
matchType?: string;
}): Record<string, unknown> {
return {
alert: name,
alertType,
ruleType: 'threshold_rule',
disabled: false,
source: '',
evalWindow,
frequency,
preferredChannels: channels,
labels: { severity, ...extraLabels },
annotations: ANNOTATIONS,
condition: {
selectedQueryName: 'A',
// Defaults match the history seeds' original shape — `target 0 / op above /
// matchType at_least_once` fires on the first evaluation that sees data — so
// overriding them is opt-in and cannot change what those seeds do.
op,
target,
matchType,
compositeQuery,
...extraCondition,
},
};
}

View File

@@ -65,24 +65,19 @@ export interface ThresholdSeedSpec {
recoveryTarget?: number | null;
}
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
export type AlertSchema = 'v1' | 'v2';
export interface LogsAlertSeed {
name: string;
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
marker: string;
/** Channel *names* (not ids) — the API validates the reference. */
channels: string[];
schema?: AlertSchema;
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
evalWindow?: string;
frequency?: string;
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
/** Becomes the threshold name, i.e. the history `threshold.name`. */
severity?: string;
/**
* Extra rule labels merged alongside `severity`. They show up in the details
* header's labels row (which renders `labels` minus `severity`) *and* as extra
* Extra rule labels merged alongside `severity`. They show up as extra
* history `filter_keys`, so add them only where a scenario needs them.
*/
extraLabels?: Record<string, string>;
@@ -90,19 +85,6 @@ export interface LogsAlertSeed {
alertOnAbsent?: boolean;
/** `condition.absentFor`, in minutes. */
absentFor?: number;
// ── SEED-RV1 extras ─────────────────────────────────────────────────────
// v1 only. Same reasoning as SEED-RV2's block: an `EV1-*` prefill assertion
// against the value the create form would have produced anyway proves nothing,
// so each of these exists to differ from `alertDefaults`
// (`container/CreateAlertRule/defaults.ts`).
/** `condition.target`. The v1 default is *absent*, so any number differs. */
target?: number;
/** `condition.op` as the legacy numeric string. `1` above, `2` below, … */
op?: string;
/** `condition.matchType`, same encoding. `1` at-least-once, `2` all-the-times. */
matchType?: string;
}
export interface TracesAlertSeed {

View File

@@ -1,10 +1,6 @@
import { expect, test } from '../../../fixtures/alerts/alert-rules';
import { AlertType } from '../../../helpers/alert-forms/constants';
import {
gotoCreateAlertV1,
gotoCreateAlertV2,
} from '../../../helpers/alert-forms/navigation';
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
import { gotoCreateAlertV2 } from '../../../helpers/alert-forms/navigation';
import {
elementAtPointClassName,
selectThresholdChannel,
@@ -15,10 +11,7 @@ import {
createEmailChannelViaApi,
deleteChannelViaApi,
} from '../../../helpers/alerts/api';
import {
gotoAlertDetails,
gotoAlertOverview,
} from '../../../helpers/alerts/navigation';
import { gotoAlertOverview } from '../../../helpers/alerts/navigation';
import { watchConsole } from '../../../helpers/common';
// TC-* — errors and edges that are not specific to one form.
@@ -54,8 +47,8 @@ test.describe('Alert create — errors and edges', () => {
]);
expect(response.status()).toBe(400);
// Both forms funnel every save error into the shared error modal, which is
// antd's wrapped in `.error-modal__wrap`.
// Every save error goes to the shared error modal, which is antd's wrapped in
// `.error-modal__wrap`.
await expect(page.locator('.error-modal__wrap')).toBeVisible();
await expect(page.getByText(/do not exist/)).toBeVisible();
@@ -66,7 +59,7 @@ test.describe('Alert create — errors and edges', () => {
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
});
test('TC-02 none of the four builder mounts logs a console error', async ({
test('TC-02 neither the create nor the edit builder logs a console error', async ({
authedPage: page,
ownedRules,
}) => {
@@ -74,24 +67,11 @@ test.describe('Alert create — errors and edges', () => {
// v2 create.
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
// v1 create. Metrics-based on purpose: it is the only alert type whose classic
// form renders the detection-method step and the PromQL tab, i.e. the most code.
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
// v2 edit.
const v2Rule = await ownedRules.threshold(`e2e-ce07-v2-${Date.now()}`);
await gotoAlertOverview(page, v2Rule);
// v1 edit. `gotoAlertOverview` is wrong here — it waits for
// `threshold-value-input`, which only the v2 builder renders — so the shell-level
// wait is used and the classic form is asserted directly.
const v1Rule = await ownedRules.logs({
name: `e2e-ce07-v1-${Date.now()}`,
schema: 'v1',
});
await gotoAlertDetails(page, v1Rule);
await expect(v1SaveButton(page)).toBeVisible();
expect(watch.errors).toEqual([]);
});

View File

@@ -10,15 +10,12 @@ import {
createAlertUrl,
expectAlertTypeCardSet,
gotoAlertTypeSelection,
gotoCreateAlertV1,
gotoCreateAlertV2,
hasAnomalyAlertTypeCard,
} from '../../../helpers/alert-forms/navigation';
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
// TC-* — the create *shell*: type selection, how a card click writes the
// URL, the breadcrumb, the surrounding alerts tab bar, and the two ways to reach
// the classic form. Nothing here saves a rule, so no scenario needs a channel.
// URL, the breadcrumb and the surrounding alerts tab bar. Nothing here saves a
// rule, so no scenario needs a channel.
test.describe('Alert create — shell & type selection', () => {
test('TC-01 bare /alerts/new lists exactly the expected alert-type cards', async ({
@@ -137,34 +134,4 @@ test.describe('Alert create — shell & type selection', () => {
expect(params.get('subTab')).toBeNull();
expect(params.get('search')).toBeNull();
});
test('TC-07 showClassicCreateAlertsPage=true renders the v1 form instead', async ({
authedPage: page,
}) => {
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
await expect(v1SaveButton(page)).toBeVisible();
// The clearest v1/v2 discriminator: the v2 header input simply is not there.
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
test('TC-08 Switch to Classic Experience replaces history, so Back does not return to v2', async ({
authedPage: page,
}) => {
await gotoCreateAlertV2(page, { alertType: AlertType.METRICS });
await page
.getByRole('button', { name: 'Switch to Classic Experience' })
.click();
await expect(v1SaveButton(page)).toBeVisible();
expect(
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
).toBe('true');
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
// entirely rather than bouncing between the two experiences.
await page.goBack();
await expect(page.getByTestId('alert-name-input')).toBeHidden();
});
});

View File

@@ -437,7 +437,7 @@ test.describe('Alert create — v2 builder', () => {
await page.getByTestId('alert-name-input').fill(name);
await selectThresholdChannel(page, 0, alertChannel.name);
// One click — v2 has no confirm dialog, unlike v1.
// One click, no confirm dialog.
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
@@ -451,7 +451,6 @@ test.describe('Alert create — v2 builder', () => {
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');
expect(body.version).toBe('v5');
expect(body.alert).toBe(name);
expect(body.condition.thresholds.kind).toBe('basic');
expect(body.condition.thresholds.spec[0]).toMatchObject({

View File

@@ -12,7 +12,6 @@ test.describe('Alert details — actions', () => {
}) => {
const ruleId = await ownedRules.logs({
name: `e2e-ad-toggle-${Date.now()}`,
schema: 'v2',
});
await gotoAlertDetails(page, ruleId);
@@ -48,7 +47,7 @@ test.describe('Alert details — actions', () => {
ownedRules,
}) => {
const name = `e2e-ad-clone-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
const ruleId = await ownedRules.logs({ name });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();
@@ -75,7 +74,7 @@ test.describe('Alert details — actions', () => {
ownedRules,
}) => {
const name = `e2e-ad-delete-${Date.now()}`;
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
const ruleId = await ownedRules.logs({ name });
await gotoAlertDetails(page, ruleId);
await page.getByTestId('alert-actions-menu').click();

View File

@@ -1,8 +1,4 @@
import {
expect,
V1_RULE_TEAM_LABEL,
test,
} from '../../../fixtures/alerts/alert-history';
import { expect, test } from '../../../fixtures/alerts/alert-history';
import { gotoAlertDetails } from '../../../helpers/alerts/navigation';
test.describe('Alert details — header', () => {
@@ -27,29 +23,4 @@ test.describe('Alert details — header', () => {
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
});
test('TC-02 v1 header shows static title with state, severity and labels', async ({
authedPage: page,
alertHistory,
}) => {
await gotoAlertDetails(page, alertHistory.ruleIdV1);
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
'data-schema-version',
'v1',
);
await expect(page.getByTestId('alert-header-title')).toBeVisible();
await expect(page.getByTestId('alert-header-state')).toBeVisible();
await expect(page.getByTestId('alert-header-severity')).toContainText(
'Warning',
);
await expect(page.getByTestId('alert-header-labels')).toContainText(
V1_RULE_TEAM_LABEL,
);
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
await page.getByTestId('alert-actions-menu').click();
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
});
});

View File

@@ -4,41 +4,14 @@ import { gotoAlertHistory } from '../../../helpers/alerts/history';
import { gotoAlertDetails } from '../../../helpers/alerts/navigation';
test.describe('Alert details — rename', () => {
test('TC-01 v1 rename via modal updates the rule name', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v1-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
await gotoAlertDetails(page, ruleId);
await expect(page.getByTestId('alert-header-title')).toContainText(original);
await page.getByTestId('alert-actions-menu').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
const modalInput = page.getByTestId('alert-name');
await expect(modalInput).toBeVisible();
await modalInput.fill(renamed);
await page.getByRole('button', { name: 'Rename Alert' }).click();
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
await expect(page.getByText(renamed)).toBeVisible();
});
test('TC-02 v2 inline rename saves via Overview footer button', async ({
test('TC-01 v2 inline rename saves via Overview footer button', async ({
authedPage: page,
ownedRules,
}) => {
const stamp = Date.now();
const original = `e2e-ad-rename-v2-${stamp}`;
const renamed = `${original}-renamed`;
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
const ruleId = await ownedRules.logs({ name: original });
await gotoAlertDetails(page, ruleId);

View File

@@ -41,7 +41,7 @@ const SEED_RV2 = {
absentFor: 7,
} as const;
/** PUT for one specific rule. v1 and v2 share this endpoint. */
/** PUT for one specific rule. */
function isRuleUpdate(url: string, method: string, ruleId: string): boolean {
return method === 'PUT' && url.includes(`/api/v2/rules/${ruleId}`);
}
@@ -61,15 +61,10 @@ test.describe('Alert edit — v2 rule', () => {
await expect(root).toHaveClass(/alert-details-v2/);
await expect(root).toHaveAttribute('data-schema-version', 'v2alpha1');
// `CreateAlertHeader` hides the whole tab bar in edit mode, which removes both
// the "New Alert Rule" chip and the classic-experience escape hatch. The escape
// hatch matters: switching experiences mid-edit would silently drop the loaded
// rule.
// `CreateAlertHeader` hides the whole tab bar in edit mode, which removes the
// "New Alert Rule" chip.
await expect(page.getByTestId('alert-name-input')).toBeVisible();
await expect(page.getByText('New Alert Rule')).toBeHidden();
await expect(
page.getByRole('button', { name: 'Switch to Classic Experience' }),
).toBeHidden();
});
test('TC-02 name and labels prefill from the rule', async ({
@@ -333,9 +328,8 @@ test.describe('Alert edit — v2 rule', () => {
await gotoAlertOverview(page, ruleId);
await page.getByTestId('alert-name-input').fill(renamed);
// AD-03/AD-04 cover renaming through the details header's RenameModal, which
// PATCHes on its own. This row is the other path: the editable header field is
// local state until the *Footer* saves it, so the assertion is on the PUT body.
// The editable header field is local state until the *Footer* saves it, so the
// assertion is on the PUT body.
const [response] = await Promise.all([
page.waitForResponse((r) =>
isRuleUpdate(r.url(), r.request().method(), ruleId),

View File

@@ -11,7 +11,6 @@ import {
isHistoryRequest,
sortTimelineDescending,
statsCard,
timelineFooterRange,
timelineRows,
} from '../../../helpers/alerts/history';
import {
@@ -126,27 +125,7 @@ test.describe('Alert history — cross-cutting', () => {
}
});
test('TC-06 v1 and v2 schema rules both render history correctly', async ({
authedPage: page,
alertHistory,
}) => {
for (const [variant, ruleId, total] of [
['v2', alertHistory.ruleId, alertHistory.total],
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
] as const) {
await gotoAlertHistory(page, ruleId);
await expect(
page.getByTestId('alert-details-root'),
`${variant} rule renders the details shell`,
).toHaveAttribute(
'data-schema-version',
variant === 'v2' ? 'v2alpha1' : 'v1',
);
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
}
});
test('TC-07 no legacy v1 history API calls during full session', async ({
test('TC-06 no legacy v1 history API calls during full session', async ({
authedPage: page,
alertHistory,
}) => {
@@ -176,7 +155,7 @@ test.describe('Alert history — cross-cutting', () => {
}
});
test('TC-08 history API endpoints carry expected params', async ({
test('TC-07 history API endpoints carry expected params', async ({
authedPage: page,
alertHistory,
}) => {

View File

@@ -187,17 +187,9 @@ test.describe('Alert history — expression filter', () => {
`of ${alertHistory.total}`,
);
await gotoAlertHistory(page, alertHistory.ruleIdV1);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'warning'`);
await responsePromise;
await expect(timelineFooterRange(page)).toContainText(
`of ${alertHistory.totalV1}`,
);
responsePromise = waitForHistoryResponse(page, 'timeline');
await runFilterExpression(page, `threshold.name = 'critical'`);
await responsePromise;
await expect(timelineRows(page)).toHaveCount(0);
});