mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-28 12:00:42 +00:00
Compare commits
3 Commits
main
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae7123c39 | ||
|
|
7faf122e93 | ||
|
|
d7435a9748 |
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,6 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' },
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'] = [
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,8 +556,6 @@ describe('Footer utils', () => {
|
||||
},
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: 'http://localhost/',
|
||||
version: 'v5',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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`)}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.auth {
|
||||
--radio-group-display: flex;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.rule-definition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-unit-selector {
|
||||
width: 150px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILabelRecord {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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>' },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}` }),
|
||||
);
|
||||
};
|
||||
@@ -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?',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -59,8 +59,6 @@ export interface PostableAlertRuleV2 {
|
||||
};
|
||||
usePolicy?: boolean;
|
||||
};
|
||||
version?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user