Compare commits

..

40 Commits

Author SHA1 Message Date
Ashwin Bhatkal
a3cc69e93c chore(notification-channels): drop the codeowners entries and strings the v1 removal orphans 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
2e7c5b35ca chore(notification-channels): remove the v1 channel client and the old list 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
35a34fe51f test(notification-channels): add authz stories for the channels list, edit and new pages 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
580c612cbf fix(notification-channels): assert the gated buttons through aria-disabled 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
2a8907bbf1 refactor(authz): render the channel screens around the permission state instead of hiding them 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
d66cb80bdd refactor(authz): gate the channel actions with AuthZDropdown and expose the channel selector 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
e2d63ea6df fix(authz): move the channel action gating onto the @signozhq/ui 0.2.0 dropdown and button 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
e47ed83ac6 perf(authz): check a channel's permissions when its menu opens, not on every row 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
80cc9a2e5c test(notification-channels): wait for the gated button before the story clicks it 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
482445cc09 fix(authz): fall open when a channel permission check fails, and say why a control is dead 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
ada28611c4 refactor(authz): apply review feedback 2026-10-19 16:05:11 +05:30
Ashwin Bhatkal
636f8dda8e feat(authz): gate the notification channel screens on the new permissions 2026-10-19 16:02:20 +05:30
Ashwin Bhatkal
46e7e03955 feat(authz): add notification channel permission helpers 2026-10-19 16:02:20 +05:30
Ashwin Bhatkal
82de936e8d feat(authz): expose notification channels as a fine-grained resource 2026-10-19 16:02:20 +05:30
Ashwin Bhatkal
321ff56535 refactor(notification-channels): drive the channel form with react-hook-form 2026-10-19 16:01:30 +05:30
Ashwin Bhatkal
f0178377c3 refactor(notification-channels): build the channel fields from @signozhq/ui and validate them with zod 2026-10-19 16:01:30 +05:30
Ashwin Bhatkal
762028149f refactor(notification-channels): build the slack field and action rows from @signozhq/ui primitives 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
9812f2d04b fix(notification-channels): point the channel form test at the v2 kind and field name 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
752d60be63 refactor(notification-channels): move the incidentio form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
ce531c9165 refactor(notification-channels): move the jsmops form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
9423d8f5fe refactor(notification-channels): move the jira form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
aa6ed6d23a refactor(notification-channels): move the googlechat form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
f7e0bd25a3 refactor(notification-channels): move the msteams form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
b35facf1e7 refactor(notification-channels): move the opsgenie form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
2ececf7830 refactor(notification-channels): move the pagerduty form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
9d559aed56 refactor(notification-channels): move the email form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
aea795b428 refactor(notification-channels): move the webhook form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
130cbd578f refactor(notification-channels): move the slack form onto the v2 schemas 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
6223274fdd refactor(notification-channels): move the channel form onto the v2 schemas and API 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
9095ec2ba2 fix(e2e): stub the v2 channel list so the empty-channel scenarios still see no channels 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
57e87141db refactor(alerts): read the channel pickers from the v2 list API 2026-10-19 15:56:59 +05:30
Ashwin Bhatkal
97ba9e0a13 test(notification-channels): add stories for the rebuilt channels list 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
11c8ac90f8 fix: button size 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
22ff223a8c test(e2e): match the renamed New channel button on the alerts page 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
1b6b7d7a30 refactor(notification-channels): share the listed-channel type and read table cells off the typed row 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
48f05cb2c6 fix(notification-channels): move the channels list onto the @signozhq/ui 0.2.0 component API 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
53c5d0e1ea fix(notification-channels): show an error state when the channel JSON fails to load 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
939c261baa refactor(notification-channels): show the channel JSON with the shared editor theme 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
346d645d16 refactor(notification-channels): delete a channel through the shared destructive confirmation 2026-10-19 15:56:57 +05:30
Ashwin Bhatkal
7de58fd803 feat(notification-channels): rebuild the channels list on the v2 API and the shared table 2026-10-19 15:56:57 +05:30
178 changed files with 5654 additions and 6507 deletions

2
.github/CODEOWNERS vendored
View File

@@ -213,9 +213,7 @@ go.mod @therealpandey
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend

View File

@@ -93,18 +93,19 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceNotificationChannel).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -119,7 +119,7 @@ const preview: Preview = {
'Planned Downtime',
'Routing Policies',
'Channels',
['List', 'New', 'Edit'],
['List', 'List Authz', 'New', 'New Authz', 'Edit', 'Edit Authz'],
],
'Dashboards',
[

View File

@@ -1,20 +1,15 @@
{
"channel_delete_unexp_error": "Something went wrong",
"channel_delete_success": "Channel Deleted Successfully",
"column_channel_name": "Name",
"column_channel_type": "Type",
"column_channel_action": "Action",
"column_channel_edit": "Edit",
"button_new_channel": "New Alert Channel",
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"page_title_view": "View Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
"field_channel_name": "Name",
"field_channel_id": "Channel ID",
"copy_channel_id": "Copy channel ID",
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"field_webhook_url": "Webhook URL",
@@ -123,12 +118,9 @@
"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_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"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",
@@ -137,4 +129,4 @@
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}
}

View File

@@ -1,20 +1,16 @@
{
"channel_delete_unexp_error": "Something went wrong",
"channel_delete_success": "Channel Deleted Successfully",
"column_channel_name": "Name",
"column_channel_type": "Type",
"column_channel_action": "Action",
"column_channel_edit": "Edit",
"button_new_channel": "New Alert Channel",
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"page_title_view": "View Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
"field_channel_name": "Name",
"field_channel_id": "Channel ID",
"copy_channel_id": "Copy channel ID",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"field_webhook_url": "Webhook URL",
@@ -98,6 +94,33 @@
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_slack_title_link": "Title link",
"field_slack_color": "Color",
"help_slack_color": "good, warning, danger, or a hex value like #439FE0. Templates are allowed.",
"placeholder_slack_color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
"field_slack_pretext": "Pretext",
"help_slack_pretext": "Shown above the attachment block",
"field_slack_fallback": "Fallback text",
"help_slack_fallback": "Plain text shown where the attachment cannot render, such as push notifications",
"field_slack_footer": "Footer",
"field_slack_fields": "Fields",
"help_slack_fields": "Extra entries rendered as a table inside the attachment",
"placeholder_slack_field_title": "Title",
"placeholder_slack_field_value": "Value",
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"placeholder_slack_action_text": "Button text",
"placeholder_slack_action_url": "https://runbook.example.com",
"placeholder_slack_action_type": "button",
"placeholder_slack_action_name": "Name (Slack app callbacks)",
"placeholder_slack_action_value": "Value (Slack app callbacks)",
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"remove_slack_action": "Remove action",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
@@ -109,6 +132,7 @@
"placeholder_email_html": "Email body template",
"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",
@@ -133,23 +157,23 @@
"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_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",
"help_opsgenie_description": "Shows up as description in opsgenie",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"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",
"webhook_url_required": "Webhook URL is mandatory",
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}
}

View File

@@ -1454,10 +1454,9 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
it('lets a VIEWER reach /alerts/channels/new, which authz then gates', () => {
// CHANNELS_NEW runs on fine-grained authz, so the router no longer decides
// on the role: the page's own guard denies when `create` is not granted.
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1466,7 +1465,7 @@ describe('PrivateRoute', () => {
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
});
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
@@ -1550,6 +1549,11 @@ describe('PrivateRoute', () => {
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
CHANNELS_NEW: { path: ROUTES.CHANNELS_NEW, deniedRoles: DENIED_ROLES },
CHANNELS_EDIT: {
path: ROUTES.CHANNELS_EDIT.replace(':channelId', 'channel-id-1'),
deniedRoles: DENIED_ROLES,
},
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
DASHBOARD: {
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,43 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,48 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -1,30 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/delete';
/**
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteChannel = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default deleteChannel;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editEmail';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editEmail;

View File

@@ -1,40 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editMsTeams;

View File

@@ -1,44 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
opsgenie_configs: [
{
send_resolved: props.send_resolved,
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editOpsgenie;

View File

@@ -1,48 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editPager';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editPager;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editSlack';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editSlack;

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editWebhook;

View File

@@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/get';
import { Channels } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
try {
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default get;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Channels, PayloadProps } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
try {
const response = await axios.get<PayloadProps>('/channels');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getAll;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
const testEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
email_configs: [
{
send_resolved: true,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testEmail;

View File

@@ -1,33 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
const testMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: true,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testMsTeams;

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const testOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testOpsgenie;

View File

@@ -1,41 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
const testPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
pagerduty_configs: [
{
send_resolved: true,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testPager;

View File

@@ -1,34 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
const testSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
slack_configs: [
{
send_resolved: true,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testSlack;

View File

@@ -1,52 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
const testWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
webhook_configs: [
{
send_resolved: true,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testWebhook;

View File

@@ -1,5 +1,6 @@
.breadcrumb {
padding-left: 16px;
/* the bar sat flush against the tab strip above it */
padding: var(--spacing-4) 16px var(--spacing-2);
ol {
align-items: center;

View File

@@ -1,77 +0,0 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { Button } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import Delete from './Delete';
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { t } = useTranslation(['channels']);
const { notifications } = useNotifications();
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
);
}, []);
const columns: ColumnsType<Channels> = [
{
title: t('column_channel_name'),
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: t('column_channel_type'),
dataIndex: 'type',
key: 'type',
width: 80,
},
];
if (action) {
columns.push({
title: t('column_channel_action'),
dataIndex: 'id',
key: 'action',
align: 'center',
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />
</>
),
});
}
return (
<ResizeTable
columns={columns}
dataSource={allChannels}
rowKey="id"
bordered
/>
);
}
interface AlertChannelsProps {
allChannels: Channels[];
}
export default AlertChannels;

View File

@@ -1,4 +0,0 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -1,54 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
const { t } = useTranslation(['channels']);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const onClickHandler = async (): Promise<void> => {
try {
setLoading(true);
await deleteChannel({
id,
});
notifications.success({
message: 'Success',
description: t('channel_delete_success'),
});
// Invalidate and refetch
queryClient.invalidateQueries(['getChannels']);
setLoading(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setLoading(false);
}
};
return (
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
);
}
interface DeleteProps {
notifications: NotificationInstance;
id: string;
}
export default Delete;

View File

@@ -1,84 +0,0 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
});
it('Should check if "New Alert Channel" Button is visble', () => {
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.getByText('column_channel_action')).toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument();
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
});
it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => {
const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0];
expect(deleteButton).toBeInTheDocument();
act(() => {
fireEvent.click(deleteButton);
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_delete_success',
});
});
});
});
});

View File

@@ -1,78 +0,0 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('hooks/useComponentPermission', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => [false]),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
it('Should check if "New Alert Channel" Button is visble and disabled', async () => {
const newAlertButton = screen.getByRole('button', {
name: /button_new_channel/i,
});
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
expect(newAlertButton).toBeDisabled();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', async () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', async () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,914 +0,0 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
GoogleChatInitialConfig,
IncidentIOInitialConfig,
JiraInitialConfig,
JsmOpsInitialConfig,
} from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
it('Should check if saving the form without filling the name displays error notification', async () => {
const saveButton = screen.getByRole('button', {
name: 'button_save_channel',
});
fireEvent.click(saveButton);
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'channel_name_required',
}),
);
});
it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => {
server.use(
rest.post('http://localhost/api/v1/testChannel', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: 'test alert sent',
}),
),
),
);
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
fireEvent.click(testButton);
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_test_done',
}),
);
});
it('Should check if clicking on Test button shows "Something went wrong" error message if testing fails', async () => {
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
act(() => {
fireEvent.click(testButton);
});
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "msteams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
// paste instead of type: a per-keystroke re-render of the whole form
// pushes these tests past the 5s jest timeout on slower CI runners
async function fillField(
user: ReturnType<typeof userEvent.setup>,
testId: string,
value: string,
): Promise<void> {
await user.click(screen.getByTestId(testId));
await user.paste(value);
}
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', 'https://example.com/webhook');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Jira', () => {
const validSite = 'https://acme.atlassian.net';
const fillRequired = async (
user: ReturnType<typeof userEvent.setup>,
site: string,
): Promise<void> => {
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
await user.type(screen.getByTestId('jira-site-textbox'), site);
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
};
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Jira} />);
});
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
expect(screen.getByText('Jira')).toBeInTheDocument();
});
it('Should check if the Site URL field is displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jira_site',
testId: 'jira-site-textbox',
});
});
it('Should prefill the issue type with Task', () => {
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
});
it('Should show the service-account recommendation tip linking to the docs', () => {
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
);
});
it('Should display an error when the site is not an atlassian.net URL', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, 'https://example.com');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_site_invalid',
}),
);
}, 15000);
it('Should send a jira_configs payload with basic auth', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(
screen.getByTestId('jira-wont-fix-resolution-textbox'),
"Won't Do",
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: validSite,
project: 'KAN',
issue_type: 'Task',
summary: JiraInitialConfig.summary,
description: JiraInitialConfig.description,
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'me@acme.com', password: 'tok123' },
},
},
],
});
}, 15000);
it('Should block save when the reopen window is below the 1m minimum', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
// the rule surfaces an inline message, not just a red border
await expect(
screen.findByText('jira_reopen_duration_invalid'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_reopen_duration_invalid',
}),
);
}, 15000);
});
describe('JSM Ops', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
});
it('Should show "Jira Service Management Ops" as the selected type', () => {
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
});
it('Should display the API key field properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jsmops_api_key',
testId: 'jsmops-api-key-textbox',
});
});
it('Should show the tip linking to the JSM Ops docs', () => {
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jsmops_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
);
});
it('Should block save when the API key is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'api_key_required',
}),
);
});
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jsmops-channel',
jsmops_configs: [
{
api_key: 'key-abc',
send_resolved: true,
message: JsmOpsInitialConfig.message,
description: JsmOpsInitialConfig.description,
priority: JsmOpsInitialConfig.priority,
tags: JsmOpsInitialConfig.tags?.join(','),
},
],
});
});
});
describe('incident.io', () => {
const incidentIOURL =
'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.IncidentIO} />);
});
it('Should display the URL and token fields with the docs tip', () => {
testLabelInputAndHelpValue({
labelText: 'field_incidentio_url',
testId: 'incidentio-url-textbox',
});
testLabelInputAndHelpValue({
labelText: 'field_incidentio_token',
testId: 'incidentio-token-textbox',
});
expect(screen.getByTestId('incidentio-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'incidentio_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/incidentio/',
);
});
it('Should block save when the URL or token is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_required_fields',
}),
);
});
it('Should display an error when the URL is not an alert events URL', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
'https://api.incident.io/v2/incidents',
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_url_invalid',
}),
);
}, 15000);
it('Should send an incidentio_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
incidentIOURL,
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('incidentio-metadata-add'));
await user.type(screen.getByTestId('incidentio-metadata-key-0'), 'team');
await user.type(screen.getByTestId('incidentio-metadata-value-0'), 'core');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: incidentIOURL,
token: 'tok-abc',
send_resolved: true,
title: IncidentIOInitialConfig.title,
description: IncidentIOInitialConfig.description,
metadata: { team: 'core' },
},
],
});
}, 15000);
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -1,336 +0,0 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel (Normal User)', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "Microsoft Teams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_return' }),
).toBeInTheDocument();
});
it.skip('Should check if save and test buttons are disabled', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeDisabled();
});
});
});
});

View File

@@ -1,120 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import {
editAlertChannelInitialValue,
editSlackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "Edit Notification Channels"', () => {
expect(screen.getByText('page_title_edit')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
value: 'Dummy-Channel',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly and the checkbox is checked', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
expect(screen.getByTestId('field-send-resolved-checkbox')).toBeChecked();
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
value:
'https://discord.com/api/webhooks/dummy_webhook_id/dummy_webhook_token/slack',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
value: '#dummy_channel',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(
editSlackDescriptionDefaultValue,
);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});

View File

@@ -1,186 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
const edit = mockEditChannel();
const jiraInitialValue = {
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
reopen_duration: '30s',
};
const { unmount } = render(
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
expect(edit.calls).toHaveLength(0);
unmount();
render(
<EditAlertChannels
channelId="3"
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
/>,
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
});
it('preserves the jira wont-fix resolution on save', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={{
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].body).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: 'https://acme.atlassian.net',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'user@acme.io', password: 'token' },
},
},
],
});
});
it('sends an incidentio_configs payload when editing an incident.io channel', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="4"
initialValue={{
type: 'incidentio',
name: 'incidentio-channel',
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('4');
expect(edit.calls[0].body).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
},
],
});
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -1,31 +0,0 @@
import { screen } from 'tests/test-utils';
export const testLabelInputAndHelpValue = ({
labelText,
testId,
helpText,
required = false,
value,
}: {
labelText: string;
testId: string;
helpText?: string;
required?: boolean;
value?: string;
}): void => {
const label = screen.getByText(labelText);
expect(label).toBeInTheDocument();
const input = screen.getByTestId(testId);
expect(input).toBeInTheDocument();
if (helpText !== undefined) {
expect(screen.getByText(helpText)).toBeInTheDocument();
}
if (required) {
expect(input).toBeRequired();
}
if (value) {
expect(input).toHaveValue(value);
}
};

View File

@@ -1,95 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Tooltip, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import AlertChannelsComponent from './AlertChannels';
import { Button, ButtonContainer, RightActionContainer } from './styles';
import './AllAlertChannels.styles.scss';
const { Text } = Typography;
function AlertChannels(): JSX.Element {
const { t } = useTranslation(['channels']);
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
}, []);
const { isLoading, data, error } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
useEffect(() => {
if (!isUndefined(data?.data)) {
logEvent('Alert Channel: Channel list page visited', {
number: data?.data?.length,
});
}
}, [data?.data]);
if (error) {
return <Typography>{error.getErrorMessage()}</Typography>;
}
if (isLoading || isUndefined(data?.data)) {
return <Spinner tip={t('loading_channels_message')} height="90vh" />;
}
return (
<div className="alert-channels-container">
<ButtonContainer>
<Text truncate={1} color="muted">
{t('sending_channels_note')}
</Text>
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create alert channel'
: undefined
}
>
<Button onClick={onToggleHandler} disabled={!addNewChannelPermission}>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> {t('button_new_channel')}
</Flex>
</Button>
</Tooltip>
</RightActionContainer>
</ButtonContainer>
<AlertChannelsComponent allChannels={data?.data || []} />
</div>
);
}
export default AlertChannels;

View File

@@ -1,26 +0,0 @@
import { Button as ButtonComponent } from 'antd';
import styled from 'styled-components';
export const RightActionContainer = styled.div`
&&& {
display: flex;
align-items: center;
}
`;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
margin-bottom: 1rem;
padding-right: 1rem;
}
`;
export const Button = styled(ButtonComponent)`
&&& {
margin-left: 1rem;
}
`;

View File

@@ -1,13 +0,0 @@
.create-alert-channels-container {
width: 100%;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;
padding: 16px;
.form-alert-channels-title {
margin-top: 0px;
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,148 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
};
describe('toChannelConfig', () => {
it('sends every field the slack spec models, new ones included', () => {
expect(toChannelConfig(ChannelKind.slack, slackValues)).toStrictEqual({
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
});
});
it('drops what a user left blank but keeps a false they chose', () => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
username: '',
password: undefined,
bearerToken: '',
sendResolved: false,
}),
).toStrictEqual({
kind: 'webhook',
spec: { url: 'https://example.com/hook', sendResolved: false },
});
});
it('drops an attachment list the user emptied instead of sending []', () => {
expect(
toChannelConfig(ChannelKind.slack, {
...slackValues,
fields: [],
actions: [],
}).spec,
).not.toHaveProperty('fields');
});
it.each(Object.values(ChannelKind))('stamps the config as %s', (kind) => {
expect(toChannelConfig(kind, slackValues).kind).toBe(kind);
});
it('sends only the selected kind s fields, not another kind s leftovers', () => {
const config = toChannelConfig(ChannelKind.msteams, {
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
// left over from a kind the user switched away from
routingKey: 'pagerduty-key',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
});
expect(config.spec).toStrictEqual({
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
});
});
});
describe('toPostableChannel', () => {
it('lets the api generate the immutable name from the display name', () => {
expect(toPostableChannel(ChannelKind.slack, slackValues)).toMatchObject({
generateName: true,
displayName: 'prod alerts',
});
});
it('leaves the display name out of the spec', () => {
expect(
toPostableChannel(ChannelKind.slack, slackValues).config.spec,
).not.toHaveProperty('name');
});
});
describe('toChannelFormState', () => {
it('round-trips a channel back into the form it was built from', () => {
const channel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: toChannelConfig(ChannelKind.slack, slackValues),
} as AlertmanagertypesGettableNotificationChannelDTO;
const { kind, values } = toChannelFormState(channel);
expect(kind).toBe(ChannelKind.slack);
expect(toChannelConfig(kind, values)).toStrictEqual(channel.config);
expect(values.name).toBe('prod alerts');
});
});
describe('jira reopen duration', () => {
it.each([
['72h', '3d'],
['1440m', '1d'],
['7d', '1w'],
['90m', '1h30m'],
['3d', '3d'],
])('sends %s as %s, the only spelling the api accepts back', (typed, sent) => {
expect(
toChannelConfig(ChannelKind.jira, {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
reopenDuration: typed,
}).spec,
).toMatchObject({ reopenDuration: sent });
});
it('leaves a duration it cannot parse for the validator to reject', () => {
expect(
toChannelConfig(ChannelKind.jira, { reopenDuration: 'tomorrow' }).spec,
).toMatchObject({ reopenDuration: 'tomorrow' });
});
});

View File

@@ -0,0 +1,185 @@
import {
AlertmanagertypesChannelConfigDTO,
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { omit, pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration, filledRows } from './utils';
/**
* The fields each kind sends. `satisfies` ties every entry to that kind's
* generated spec, so a key the API does not model fails to compile.
* An untouched field is dropped rather than sent empty, because the API
* applies its own default only for an absent key.
*/
const SPEC_FIELDS = {
[ChannelKind.slack]: [
'apiUrl',
'channel',
'title',
'titleLink',
'text',
'pretext',
'fallback',
'footer',
'color',
'fields',
'actions',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelSlackConfigDTO)[],
[ChannelKind.webhook]: [
'url',
'username',
'password',
'bearerToken',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelWebhookConfigDTO)[],
[ChannelKind.email]: [
'to',
'html',
'headers',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelEmailConfigDTO)[],
[ChannelKind.pagerduty]: [
'routingKey',
'client',
'clientUrl',
'description',
'severity',
'component',
'group',
'class',
'url',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelPagerdutyConfigDTO)[],
[ChannelKind.opsgenie]: [
'apiKey',
'apiUrl',
'message',
'description',
'source',
'priority',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelOpsgenieConfigDTO)[],
[ChannelKind.msteams]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelMSTeamsConfigDTO)[],
[ChannelKind.googlechat]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
[ChannelKind.jira]: [
'site',
'project',
'issueType',
'email',
'apiToken',
'summary',
'description',
'priority',
'labels',
'resolveTransition',
'reopenTransition',
'wontFixResolution',
'reopenDuration',
'customFields',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJiraConfigDTO)[],
[ChannelKind.jsmops]: [
'apiKey',
'message',
'description',
'priority',
'tags',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJSMOpsConfigDTO)[],
[ChannelKind.incidentio]: [
'url',
'token',
'title',
'description',
'metadata',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelIncidentIOConfigDTO)[],
} satisfies Record<ChannelKind, readonly (keyof ChannelSpecFormValues)[]>;
export function toChannelConfig(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesChannelConfigDTO {
const picked: Record<string, unknown> = pick(values, SPEC_FIELDS[kind]);
if (kind === ChannelKind.slack) {
picked.fields = filledRows(
picked.fields as AlertmanagertypesChannelSlackConfigDTO['fields'],
'title',
);
// an untouched confirm box leaves an empty object the API would store
picked.actions = filledRows(
picked.actions as AlertmanagertypesChannelSlackConfigDTO['actions'],
'text',
).map((action) =>
action.confirm?.text?.trim() ? action : omit(action, 'confirm'),
);
}
const spec: Record<string, unknown> = omitBlank(picked);
// the API answers a read with the duration prometheus would print, and
// rejects anything spelled differently
if (kind === ChannelKind.jira && typeof spec.reopenDuration === 'string') {
spec.reopenDuration = canonicalJiraReopenDuration(spec.reopenDuration);
}
// The generated config type is a union whose every member fixes `kind` to a
// single literal, so a plain ChannelKind never matches one. Asserted once
// here instead of ten times.
return { kind, spec } as unknown as AlertmanagertypesChannelConfigDTO;
}
export function toPostableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesPostableNotificationChannelDTO {
return {
// the API derives the immutable dns1123 name from the display name
generateName: true,
displayName: values.name ?? '',
config: toChannelConfig(kind, values),
};
}
export function toUpdatableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesUpdatableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}
export function toTestableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesTestableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}

View File

@@ -0,0 +1,25 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
export interface ChannelFormState {
kind: ChannelKind;
values: ChannelFormValues;
}
/**
* The form edits the spec the API returns, so loading a channel lifts its
* display name alongside that spec rather than remapping field by field.
*/
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: channel.config.kind as string as ChannelKind,
values: {
...channel.config.spec,
// the API keeps `name` immutable and exposes the editable label separately
name: channel.displayName,
},
};
}

View File

@@ -0,0 +1,46 @@
import { TFunction } from 'i18next';
import type { FieldErrors, Resolver } from 'react-hook-form';
import type { $ZodIssue } from 'zod/v4/core';
import { channelNameSchema } from './schemas/shared';
import { ChannelFormValues, ChannelKind } from './types';
import { CHANNEL_SCHEMAS } from './validation';
const toFieldErrors = (
issues: $ZodIssue[],
t: TFunction,
): FieldErrors<ChannelFormValues> => {
const errors: Record<string, { type: string; message: string }> = {};
issues.forEach((issue) => {
// an object-level refinement reports no path, and belongs on the field the
// schema named in `path` or, failing that, on the name
const field = issue.path.join('.') || 'name';
if (!errors[field]) {
errors[field] = { type: 'validation', message: t(issue.message) };
}
});
return errors as FieldErrors<ChannelFormValues>;
};
/**
* Runs the kind's zod schema as a react-hook-form resolver, so the same schemas
* that mirror `Validate()` in `pkg/types/alertmanagertypes` drive the form's
* per-field errors. Messages arrive as i18n keys and are translated here.
*/
export const channelResolver =
(kind: ChannelKind, t: TFunction): Resolver<ChannelFormValues> =>
async (values) => {
const schema = CHANNEL_SCHEMAS[kind];
const issues: $ZodIssue[] = [
...(channelNameSchema.safeParse(values).error?.issues ?? []),
...(schema ? (schema.safeParse(values).error?.issues ?? []) : []),
];
if (issues.length === 0) {
return { values, errors: {} };
}
return { values: {}, errors: toFieldErrors(issues, t) };
};

View File

@@ -0,0 +1,138 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelKind } from './types';
/**
* Loading a channel and saving it again without touching anything must send the
* API exactly what it returned. A field the form model drops silently loses a
* user's configuration on the next save, because the update replaces the whole
* config.
*/
const SPECS: Record<string, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#ops',
title: 'title',
titleLink: 'https://signoz.io',
text: 'body',
pretext: 'pre',
fallback: 'fall',
footer: 'foot',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
webhook: {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {
to: 'oncall@signoz.io',
html: '<p>a</p>',
headers: { 'X-Team': 'ops' },
sendResolved: true,
},
pagerduty: {
routingKey: 'k',
client: 'c',
clientUrl: 'https://c',
description: 'd',
severity: 'critical',
component: 'comp',
group: 'grp',
class: 'cls',
url: 'https://events.pagerduty.com',
details: { firing: '{{ .Alerts.Firing }}' },
sendResolved: true,
},
opsgenie: {
apiKey: 'k',
apiUrl: 'https://api.opsgenie.com',
message: 'm',
description: 'd',
source: 's',
priority: 'P1',
details: { env: 'prod' },
sendResolved: true,
},
msteams: {
webhookUrl: 'https://teams',
title: 't',
text: 'b',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
title: 't',
text: 'b',
sendResolved: true,
},
jira: {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
summary: 's',
description: 'd',
priority: 'High',
labels: ['one', 'two'],
resolveTransition: 'Done',
reopenTransition: 'Reopen',
wontFixResolution: 'WontFix',
// the API only ever returns the canonical spelling
reopenDuration: '3d',
customFields: { customfield_1: 'v' },
sendResolved: true,
},
jsmops: {
apiKey: 'k',
message: 'm',
description: 'd',
priority: 'P2',
tags: 'prod,db',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/abc',
token: 'tok',
title: 't',
description: 'd',
metadata: { team: 'ops' },
sendResolved: true,
},
};
describe('load then save round trip', () => {
it.each(Object.keys(SPECS))('keeps every %s field', (kind) => {
const channel = {
id: '1',
name: `${kind}-channel`,
displayName: `${kind} channel`,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: { kind, spec: SPECS[kind] },
} as unknown as AlertmanagertypesGettableNotificationChannelDTO;
const { kind: loadedKind, values } = toChannelFormState(channel);
const saved = toUpdatableChannel(loadedKind, values);
expect(loadedKind).toBe(kind as ChannelKind);
expect(saved.config).toStrictEqual(channel.config);
});
it('never sends the display name inside the spec', () => {
const config = toChannelConfig(ChannelKind.slack, {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/x',
});
expect(config.spec).not.toHaveProperty('name');
});
});

View File

@@ -1,194 +0,0 @@
export interface Channel {
send_resolved?: boolean;
name: string;
filter?: Partial<Array<LabelFilterStatement>>;
}
export interface SlackChannel extends Channel {
api_url?: string;
channel?: string;
title?: string;
text?: string;
}
export interface WebhookChannel extends Channel {
api_url?: string;
// basic auth
username?: string;
password?: string;
}
// PagerChannel configures alert manager to send
// events to pagerduty
export interface PagerChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config
routing_key?: string;
// displays source of the event in pager duty
client?: string;
client_url?: string;
// A description of the incident
description?: string;
// Severity of the incident
severity?: string;
// The part or component of the affected system that is broken
component?: string;
// A cluster or grouping of sources
group?: string;
// The class/type of the event.
class?: string;
details?: string;
detailsArray?: Record<string, string>;
}
// OpsgenieChannel configures alert manager to send
// events to opsgenie
export interface OpsgenieChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
api_key: string;
message?: string;
// A description of the incident
description?: string;
// A backlink to the sender of the notification.
source?: string;
// A set of arbitrary key/value pairs that provide further detail
// about the alert.
details?: string;
detailsArray?: Record<string, string>;
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
priority?: string;
}
export interface EmailChannel extends Channel {
// comma separated list of email addresses to send alerts to
to: string;
// HTML body of the email notification.
html: string;
// Further headers email header key/value pairs.
// [ headers: { <string>: <tmpl_string>, ... } ]
headers: Record<string, string>;
}
export const ValidatePagerChannel = (p: PagerChannel): string => {
if (!p) {
return 'Received unexpected input for this channel, please contact your administrator ';
}
if (!p.name || p.name === '') {
return 'Name is mandatory for creating a channel';
}
if (!p.routing_key || p.routing_key === '') {
return 'Routing Key is mandatory for creating pagerduty channel';
}
// validate details json
try {
JSON.parse(p.details || '{}');
} catch (e) {
return 'failed to parse additional information, please enter a valid json';
}
return '';
};
export enum ChannelType {
Slack = 'slack',
Email = 'email',
Webhook = 'webhook',
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
IncidentIO = 'incidentio',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
export interface LabelFilterStatement {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher
// label name
name: string;
// comparators supported by promql are =, !=, =~, or !~. =
comparator: string;
// filter value
value: string;
}
export interface MsTeamsChannel extends Channel {
webhook_url?: string;
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// issues resolved with this resolution are never reopened; a refire
// creates a new issue instead
wont_fix_resolution?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// IncidentIOChannel configures the incident.io alert channel, backed by an
// incident.io HTTP alert source (Alert Events V2 API).
export interface IncidentIOChannel extends Channel {
// per-source alert events URL, e.g.
// https://api.incident.io/v2/alert_events/http/<source_config_id>
url: string;
// the alert source's secret token
token: string;
// alert title template
title?: string;
// alert body template (markdown, rendered natively by incident.io)
description?: string;
// extra metadata pairs merged over the alert's labels (channel wins on clash)
metadata?: Record<string, string>;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

@@ -1,20 +1,20 @@
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelKind, ChannelSpecFormValues } from './types';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
{
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
@@ -26,7 +26,7 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -37,27 +37,29 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
{{- end -}}
)
{{- end }}`,
};
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChatConfigDTO> =
{
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
};
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
// summary / description are left empty. The description is markdown here and is
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<JiraChannel> = {
issue_type: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
{
issueType: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}
@@ -65,10 +67,11 @@ export const JiraInitialConfig: Partial<JiraChannel> = {
**Description:** {{ .Annotations.description }}
{{ end }}
{{ end }}`,
};
};
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfigDTO> =
{
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -79,20 +82,21 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
{{- end -}}
)
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
client_url: 'https://enter-signoz-host-n-port-here/alerts',
details: JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
}),
};
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
details: {
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
},
};
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConfigDTO> =
{
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ range .Alerts.Firing }}
- Message: {{ .Annotations.description }}
@@ -114,19 +118,20 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
// description are left empty. send_resolved is seeded on so JSM alerts close on
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
// Opsgenie template mapping severity to P1-P5.
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
send_resolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDTO> =
{
sendResolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -140,19 +145,20 @@ export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: 'signoz-alert',
};
// mirrors DefaultIncidentIOTitleTemplate / DefaultIncidentIODescriptionTemplate
// in pkg/types/alertmanagertypes/incidentio.go, applied by the backend when
// title / description are left empty. send_resolved is seeded on so incident.io
// alerts resolve with the rule (the backend cannot default it).
export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
send_resolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIOConfigDTO> =
{
sendResolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -166,11 +172,12 @@ export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
};
};
export const EmailInitialConfig: Partial<EmailChannel> = {
send_resolved: true,
html: `<!--
export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO> =
{
sendResolved: true,
html: `<!--
Credits: https://github.com/mailgun/transactional-email-templates
-->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@@ -562,33 +569,20 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</table>
</body>
</html>`,
};
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Jira]: JiraInitialConfig,
[ChannelType.JsmOps]: JsmOpsInitialConfig,
[ChannelType.IncidentIO]: IncidentIOInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};
export const ChannelInitialConfig: Record<ChannelKind, ChannelSpecFormValues> =
{
[ChannelKind.slack]: SlackInitialConfig,
[ChannelKind.msteams]: SlackInitialConfig,
[ChannelKind.googlechat]: GoogleChatInitialConfig,
[ChannelKind.jira]: JiraInitialConfig,
[ChannelKind.jsmops]: JsmOpsInitialConfig,
[ChannelKind.incidentio]: IncidentIOInitialConfig,
[ChannelKind.pagerduty]: PagerInitialConfig,
[ChannelKind.opsgenie]: OpsgenieInitialConfig,
[ChannelKind.email]: EmailInitialConfig,
[ChannelKind.webhook]: {},
};

View File

@@ -1,818 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import createEmail from 'api/channels/createEmail';
import createMsTeamsApi from 'api/channels/createMsTeams';
import createOpsgenie from 'api/channels/createOpsgenie';
import createPagerApi from 'api/channels/createPager';
import createSlackApi from 'api/channels/createSlack';
import createWebhookApi from 'api/channels/createWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsGenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
function CreateAlertChannels({
preType = ChannelType.Slack,
}: CreateAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const { showErrorModal } = useErrorModal();
const [formInstance] = Form.useForm();
useEffect(() => {
logEvent('Alert Channel: Create channel page visited', {});
}, []);
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>(() => ({
send_resolved: true,
...ChannelInitialConfig[preType],
}));
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const nextType = value as ChannelType;
if (nextType === type) {
return;
}
setType(nextType);
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
},
[type, formInstance],
);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onSlackHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
const prepareWebhookRequest = useCallback(() => {
// initial api request without auth params
let request: WebhookChannel = {
api_url: selectedConfig?.api_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
};
if (selectedConfig?.username !== '' || selectedConfig?.password !== '') {
if (selectedConfig?.username !== '') {
// if username is not null then password must be passed
if (selectedConfig?.password !== '') {
request = {
...request,
username: selectedConfig.username,
password: selectedConfig.password,
};
} else {
notifications.error({
message: 'Error',
description: t('username_no_password'),
});
}
} else if (selectedConfig?.password !== '') {
// only password entered, set bearer token
request = {
...request,
username: '',
password: selectedConfig.password,
};
}
}
return request;
}, [notifications, t, selectedConfig]);
const onWebhookHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareWebhookRequest();
await createWebhookApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_url,
notifications,
t,
prepareWebhookRequest,
showErrorModal,
]);
const preparePagerRequest = useCallback(() => {
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return null;
}
return {
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig?.routing_key || '',
client: selectedConfig?.client || '',
client_url: selectedConfig?.client_url || '',
description: selectedConfig?.description || '',
severity: selectedConfig?.severity || '',
component: selectedConfig?.component || '',
group: selectedConfig?.group || '',
class: selectedConfig?.class || '',
details: selectedConfig.details || '',
detailsArray: JSON.parse(selectedConfig.details || '{}'),
};
}, [selectedConfig, notifications]);
const onPagerHandler = useCallback(async () => {
setSavingState(true);
const request = preparePagerRequest();
try {
if (request) {
await createPagerApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
}
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [preparePagerRequest, t, notifications, showErrorModal]);
const prepareOpsgenieRequest = useCallback(
() => ({
api_key: selectedConfig?.api_key || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
description: selectedConfig?.description || '',
message: selectedConfig?.message || '',
priority: selectedConfig?.priority || '',
}),
[selectedConfig],
);
const onOpsgenieHandler = useCallback(async () => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return;
}
setSavingState(true);
try {
await createOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_key,
notifications,
t,
prepareOpsgenieRequest,
showErrorModal,
]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig?.to || '',
html: selectedConfig?.html || '',
headers: selectedConfig?.headers || {},
}),
[selectedConfig],
);
const onEmailHandler = useCallback(async () => {
if (!selectedConfig.to) {
notifications.error({
message: 'Error',
description: t('to_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareEmailRequest();
await createEmail(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onMsTeamsHandler = useCallback(async () => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.webhook_url,
notifications,
t,
prepareMsTeamsRequest,
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJiraConfig = useCallback((): boolean => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
notifications.error({
message: 'Error',
description: t('jira_required_fields'),
});
return false;
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
notifications.error({
message: 'Error',
description: t('jira_site_invalid'),
});
return false;
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
notifications.error({
message: 'Error',
description: t('jira_reopen_duration_invalid'),
});
return false;
}
return true;
}, [selectedConfig, notifications, t]);
const onJiraHandler = useCallback(async () => {
if (!validateJiraConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJiraRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJsmOpsConfig = useCallback((): boolean => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return false;
}
return true;
}, [selectedConfig.api_key, notifications, t]);
const onJsmOpsHandler = useCallback(async () => {
if (!validateJsmOpsConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateIncidentIOConfig = useCallback((): boolean => {
if (!selectedConfig.url || !selectedConfig.token) {
notifications.error({
message: 'Error',
description: t('incidentio_required_fields'),
});
return false;
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
notifications.error({
message: 'Error',
description: t('incidentio_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.url, selectedConfig.token, notifications, t]);
const onIncidentIOHandler = useCallback(async () => {
if (!validateIncidentIOConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareIncidentIORequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
notifications.error({
message: 'Error',
description: t('channel_name_required'),
});
return;
}
const functionMapper = {
[ChannelType.Slack]: onSlackHandler,
[ChannelType.Webhook]: onWebhookHandler,
[ChannelType.Pagerduty]: onPagerHandler,
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
[ChannelType.Jira]: onJiraHandler,
[ChannelType.JsmOps]: onJsmOpsHandler,
[ChannelType.IncidentIO]: onIncidentIOHandler,
};
if (isChannelType(value)) {
const functionToCall = functionMapper[value as keyof typeof functionMapper];
if (functionToCall) {
const result = await functionToCall();
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: result?.status,
statusMessage: result?.statusMessage,
});
} else {
notifications.error({
message: 'Error',
description: t('selected_channel_invalid'),
});
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackHandler,
onWebhookHandler,
onPagerHandler,
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
onJiraHandler,
onJsmOpsHandler,
onIncidentIOHandler,
notifications,
t,
],
);
const performChannelTest = useCallback(
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
await testMsTeamsApi(request);
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
await testOpsGenie(request);
break;
case ChannelType.Email:
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
case ChannelType.Jira:
if (!validateJiraConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
case ChannelType.JsmOps:
if (!validateJsmOpsConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
case ChannelType.IncidentIO:
if (!validateIncidentIOConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test success',
});
} catch (error) {
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
prepareWebhookRequest,
t,
preparePagerRequest,
prepareOpsgenieRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<div className="create-alert-channels-container">
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
savingState,
testingState,
title: t('page_title_create'),
initialValue: {
type,
...selectedConfig,
},
}}
/>
</div>
);
}
interface CreateAlertChannelsProps {
preType: ChannelType;
}
export default CreateAlertChannels;

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const emailSchema = z.object({
to: requiredString('to_required'),
});

View File

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

View File

@@ -0,0 +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.refine(isValidIncidentIOURL, {
message: 'incidentio_url_invalid',
}),
token: required,
});

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
import { requiredString } from './shared';
// the API rejects a partial Jira config outright, so all five report one message
const required = requiredString('jira_required_fields');
export const jiraSchema = z.object({
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
email: required,
apiToken: required,
project: required,
issueType: required,
reopenDuration: z
.string()
.optional()
.refine((v) => !v || isValidJiraReopenDuration(v), {
message: 'jira_reopen_duration_invalid',
}),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const jsmOpsSchema = z.object({
apiKey: requiredString('api_key_required'),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const msTeamsSchema = z.object({
webhookUrl: requiredString('webhook_url_required'),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const opsgenieSchema = z.object({
apiKey: requiredString('api_key_required'),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const pagerdutySchema = z.object({
routingKey: requiredString('routing_key_required'),
});

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
/**
* Messages are i18n keys, not sentences: the form translates whatever a schema
* reports, so the schemas stay free of the `channels` namespace.
*/
export const requiredString = (messageKey: string): z.ZodString =>
// an untouched field arrives as undefined, which would otherwise report zod's
// own "expected string" instead of the message the form should show
z.string({ error: messageKey }).trim().min(1, messageKey);
/** The display name the API carries outside the config, required for every kind. */
export const channelNameSchema = z.object({
name: requiredString('channel_name_required'),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const slackSchema = z.object({
apiUrl: requiredString('webhook_url_required'),
});

View File

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

View File

@@ -0,0 +1,43 @@
import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/** The API's own vocabulary for a channel's kind. */
export const ChannelKind = AlertmanagertypesChannelKindDTO;
export type ChannelKind = AlertmanagertypesChannelKindDTO;
/**
* Every kind's spec merged into one object, so switching kind mid-form keeps the
* fields the kinds share (title, text, description). Each field is typed by the
* generated schema, so what is optional here is what the API models as optional.
*/
export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelSlackConfigDTO &
AlertmanagertypesChannelWebhookConfigDTO &
AlertmanagertypesChannelEmailConfigDTO &
AlertmanagertypesChannelPagerdutyConfigDTO &
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
AlertmanagertypesChannelJiraConfigDTO &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO
>;
/**
* The form edits a spec plus the channel's display name, which the API carries
* outside the config.
*/
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
};

View File

@@ -1,23 +1,3 @@
import {
AlertmanagertypesIncidentIOReceiverConfigDTO,
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ChannelType,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
@@ -32,22 +12,6 @@ export const isValidGoogleChatWebhookURL = (url: string): boolean => {
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -93,85 +57,51 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
return totalMs >= JIRA_MIN_REOPEN_MS;
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
/**
* How prometheus `model.Duration` prints itself, which is the only spelling the
* API accepts back: years and weeks only when the remainder divides exactly,
* then days, hours, minutes, seconds and milliseconds greedily. "72h" prints as
* "3d", "7d" as "1w".
*/
const JIRA_DURATION_FORMAT: { unit: string; ms: number; exact: boolean }[] = [
{ unit: 'y', ms: JIRA_DURATION_UNIT_MS.y, exact: true },
{ unit: 'w', ms: JIRA_DURATION_UNIT_MS.w, exact: true },
{ unit: 'd', ms: JIRA_DURATION_UNIT_MS.d, exact: false },
{ unit: 'h', ms: JIRA_DURATION_UNIT_MS.h, exact: false },
{ unit: 'm', ms: JIRA_DURATION_UNIT_MS.m, exact: false },
{ unit: 's', ms: JIRA_DURATION_UNIT_MS.s, exact: false },
{ unit: 'ms', ms: JIRA_DURATION_UNIT_MS.ms, exact: false },
];
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.wont_fix_resolution) {
jira.wont_fix_resolution = config.wont_fix_resolution;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
/**
* The API rejects a duration it would print differently, so the user's spelling
* is normalised before it is sent. A value it cannot parse is left alone for the
* validator to reject.
*/
export const canonicalJiraReopenDuration = (value: string): string => {
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
return value;
}
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
let remaining = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
}
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
if (remaining === 0) {
return '0s';
}
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
return JIRA_DURATION_FORMAT.reduce((canonical, { unit, ms, exact }) => {
if (exact && remaining % ms !== 0) {
return canonical;
}
const count = Math.floor(remaining / ms);
if (count === 0) {
return canonical;
}
remaining -= count * ms;
return `${canonical}${count}${unit}`;
}, '');
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
@@ -191,32 +121,9 @@ export const isValidIncidentIOURL = (url: string): boolean => {
}
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareIncidentIORequest = (
config: Partial<IncidentIOChannel>,
): AlertmanagertypesPostableChannelDTO => {
const incidentio: AlertmanagertypesIncidentIOReceiverConfigDTO = {
url: config.url || '',
token: config.token || '',
send_resolved: config.send_resolved || false,
};
if (config.title) {
incidentio.title = config.title;
}
if (config.description) {
incidentio.description = config.description;
}
const metadata = Object.fromEntries(
Object.entries(config.metadata || {}).filter(([key]) => key.trim() !== ''),
);
if (Object.keys(metadata).length > 0) {
incidentio.metadata = metadata;
}
return {
name: config.name || '',
incidentio_configs: [incidentio],
};
};
/** Drops the rows a user added and left blank, keyed on the column the API requires. */
export const filledRows = <T>(
rows: T[] | null | undefined,
required: keyof T,
): T[] =>
(rows ?? []).filter((row) => String(row[required] ?? '').trim() !== '');

View File

@@ -0,0 +1,53 @@
import { TFunction } from 'i18next';
import type { ZodType } from 'zod';
import { emailSchema } from './schemas/email';
import { googleChatSchema } from './schemas/googlechat';
import { incidentIOSchema } from './schemas/incidentio';
import { jiraSchema } from './schemas/jira';
import { jsmOpsSchema } from './schemas/jsmops';
import { msTeamsSchema } from './schemas/msteams';
import { opsgenieSchema } from './schemas/opsgenie';
import { pagerdutySchema } from './schemas/pagerduty';
import { channelNameSchema } from './schemas/shared';
import { slackSchema } from './schemas/slack';
import { webhookSchema } from './schemas/webhook';
import { ChannelFormValues, ChannelKind } from './types';
/** The spec schema for each kind, mirroring `Validate()` in `pkg/types/alertmanagertypes`. */
export const CHANNEL_SCHEMAS: Record<ChannelKind, ZodType> = {
[ChannelKind.slack]: slackSchema,
[ChannelKind.webhook]: webhookSchema,
[ChannelKind.pagerduty]: pagerdutySchema,
[ChannelKind.opsgenie]: opsgenieSchema,
[ChannelKind.jsmops]: jsmOpsSchema,
[ChannelKind.email]: emailSchema,
[ChannelKind.msteams]: msTeamsSchema,
[ChannelKind.googlechat]: googleChatSchema,
[ChannelKind.jira]: jiraSchema,
[ChannelKind.incidentio]: incidentIOSchema,
};
/**
* Client-side validation for the fields the API rejects outright, so a save
* round trip is not spent on an obviously incomplete form. Returns the message
* to show, or null when the form can be submitted.
*/
export function validateChannel(
kind: ChannelKind,
values: ChannelFormValues,
t: TFunction,
): string | null {
const nameResult = channelNameSchema.safeParse(values);
if (!nameResult.success) {
return t(nameResult.error.issues[0].message);
}
const schema = CHANNEL_SCHEMAS[kind];
if (!schema) {
return t('selected_channel_invalid');
}
const result = schema.safeParse(values);
return result.success ? null : t(result.error.issues[0].message);
}

View File

@@ -1,12 +1,8 @@
import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -21,14 +17,11 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const {
data,
data: channels,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||

View File

@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockChannels: ChannelOption[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,16 +85,17 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
}));
// Mock alert format categories

View File

@@ -3,7 +3,7 @@ import type { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: Channels[] = [
const mockChannels: ChannelOption[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,

View File

@@ -1,5 +1,5 @@
import type { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import {
NotificationSettingsAction,
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,863 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import editEmail from 'api/channels/editEmail';
import editMsTeamsApi from 'api/channels/editMsTeams';
import editOpsgenie from 'api/channels/editOpsgenie';
import editPagerApi from 'api/channels/editPager';
import editSlackApi from 'api/channels/editSlack';
import editWebhookApi from 'api/channels/editWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsgenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const [formInstance] = Form.useForm();
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>({
...initialValue,
});
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
const onTypeChangeHandler = useCallback((value: string) => {
setType(value as ChannelType);
}, []);
useEffect(() => {
formInstance.setFieldsValue({
...initialValue,
});
}, [formInstance, initialValue]);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onSlackEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareSlackRequest, t, notifications, selectedConfig]);
const prepareWebhookRequest = useCallback(() => {
const { name, username, password } = selectedConfig;
return {
api_url: selectedConfig?.api_url || '',
name: name || '',
send_resolved: selectedConfig?.send_resolved || false,
username,
password,
id,
};
}, [id, selectedConfig]);
const onWebhookEditHandler = useCallback(async () => {
setSavingState(true);
const { username, password } = selectedConfig;
const showError = (msg: string): void => {
notifications.error({
message: 'Error',
description: msg,
});
};
if (selectedConfig?.api_url === '') {
showError(t('webhook_url_required'));
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
if (username && (!password || password === '')) {
showError(t('username_no_password'));
setSavingState(false);
return { status: 'failed', statusMessage: t('username_no_password') };
}
try {
await editWebhookApi(prepareWebhookRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareWebhookRequest, t, notifications, selectedConfig]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig.to || '',
html: selectedConfig.html || '',
headers: selectedConfig.headers || {},
id,
}),
[id, selectedConfig],
);
const onEmailEditHandler = useCallback(async () => {
setSavingState(true);
const request = prepareEmailRequest();
try {
await editEmail(request);
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, t, notifications]);
const preparePagerRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig.routing_key,
client: selectedConfig.client,
client_url: selectedConfig.client_url,
description: selectedConfig.description,
severity: selectedConfig.severity,
component: selectedConfig.component,
class: selectedConfig.class,
group: selectedConfig.group,
details: selectedConfig.details,
detailsArray: JSON.parse(selectedConfig.details || '{}'),
id,
}),
[id, selectedConfig],
);
const onPagerEditHandler = useCallback(async () => {
setSavingState(true);
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setSavingState(false);
return { status: 'failed', statusMessage: validationError };
}
try {
await editPagerApi(preparePagerRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [preparePagerRequest, notifications, selectedConfig, t]);
const prepareOpsgenieRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
api_key: selectedConfig.api_key || '',
message: selectedConfig.message || '',
description: selectedConfig.description || '',
priority: selectedConfig.priority || '',
id,
}),
[id, selectedConfig],
);
const onOpsgenieEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_key === '') {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('api_key_required') };
}
try {
await editOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onMsTeamsEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.webhook_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJiraConfig = useCallback((): string => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
return t('jira_site_invalid');
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
return t('jira_reopen_duration_invalid');
}
return '';
}, [selectedConfig, t]);
const onJiraEditHandler = useCallback(async () => {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJiraRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJsmOpsConfig = useCallback((): string => {
if (!selectedConfig.api_key) {
return t('api_key_required');
}
return '';
}, [selectedConfig, t]);
const onJsmOpsEditHandler = useCallback(async () => {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJsmOpsRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateIncidentIOConfig = useCallback((): string => {
if (!selectedConfig.url || !selectedConfig.token) {
return t('incidentio_required_fields');
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
return t('incidentio_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onIncidentIOEditHandler = useCallback(async () => {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareIncidentIORequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
if (value === ChannelType.Slack) {
result = await onSlackEditHandler();
} else if (value === ChannelType.Webhook) {
result = await onWebhookEditHandler();
} else if (value === ChannelType.Pagerduty) {
result = await onPagerEditHandler();
} else if (value === ChannelType.MsTeams) {
result = await onMsTeamsEditHandler();
} else if (value === ChannelType.Opsgenie) {
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
} else if (value === ChannelType.Jira) {
result = await onJiraEditHandler();
} else if (value === ChannelType.JsmOps) {
result = await onJsmOpsEditHandler();
} else if (value === ChannelType.IncidentIO) {
result = await onIncidentIOEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: result?.status,
statusMessage: result?.statusMessage,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackEditHandler,
onWebhookEditHandler,
onPagerEditHandler,
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
onJiraEditHandler,
onJsmOpsEditHandler,
onIncidentIOEditHandler,
],
);
const performChannelTest = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
if (request) {
await testMsTeamsApi(request);
}
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
if (request) {
await testOpsgenie(request);
}
break;
case ChannelType.Email:
request = prepareEmailRequest();
if (request) {
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
case ChannelType.Jira: {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
}
case ChannelType.JsmOps: {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
}
case ChannelType.IncidentIO: {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test success',
});
} catch (error) {
notifyError(error);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareOpsgenieRequest,
prepareEmailRequest,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
testingState,
savingState,
title: t('page_title_edit'),
initialValue,
editing: true,
}}
/>
);
}
interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -0,0 +1,17 @@
.form {
display: flex;
flex-direction: column;
}
.fields {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.actions {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}

View File

@@ -0,0 +1,12 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
}
.label {
display: flex;
align-items: center;
gap: var(--spacing-1);
}

View File

@@ -0,0 +1,40 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styles from './FormField.module.scss';
interface FormFieldProps {
label?: ReactNode;
/** Shown under the control, and replaced by `error` while one is set. */
help?: ReactNode;
error?: string;
htmlFor?: string;
children: ReactNode;
}
/** Label, control and the one message below it, the layout every channel field uses. */
function FormField({
label,
help,
error,
htmlFor,
children,
}: FormFieldProps): JSX.Element {
return (
<div className={styles.field}>
{!!label && (
<label className={styles.label} htmlFor={htmlFor}>
<Typography.Text size="sm">{label}</Typography.Text>
</label>
)}
{children}
{!!(error || help) && (
<Typography.Text size="sm" color={error ? 'danger' : 'muted'}>
{error ?? help}
</Typography.Text>
)}
</div>
);
}
export default FormField;

View File

@@ -1,49 +1,48 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
function EmailForm(): JSX.Element {
const { t } = useTranslation('channels');
const handleInputChange =
(field: string) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
setSelectedConfig((value) => ({
...value,
[field]: event.target.value,
}));
};
const {
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="to"
<FormField
help={t('help_email_to')}
label={t('field_email_to')}
required
error={errors.to?.message}
>
<Input
onChange={handleInputChange('to')}
{...register('to')}
placeholder={t('placeholder_email_to')}
data-testid="email-to-textbox"
/>
</Form.Item>
</FormField>
{/* <Form.Item name="html" label={t('field_email_html')} required>
<TextArea
{/* <FormField label={t('field_email_html')} required
error={errors.html?.message}
>
<Controller
control={control}
name="html"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
onChange={handleInputChange('html')}
placeholder={t('placeholder_email_html')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item> */}
</FormField> */}
</>
);
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
}
export default EmailForm;

View File

@@ -1,82 +1,57 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
function GoogleChat(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidGoogleChatWebhookURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_google_chat_url')}
variables={{}}
<FormField label={t('field_webhook_url')} error={errors.webhookUrl?.message}>
<Input {...register('webhookUrl')} data-testid="webhook-url-textbox" />
</FormField>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={4}
data-testid="title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
)}
/>
</Form.Item>
</FormField>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
<FormField label={t('field_slack_description')} error={errors.text?.message}>
<Controller
control={control}
name="text"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={4}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</FormField>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -1,21 +1,41 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
interface MetadataRow {
key: string;
value: string;
}
function IncidentIOSettings({
setSelectedConfig,
initialMetadata,
}: IncidentIOProps): JSX.Element {
function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
getValues,
setValue,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
/** Writes straight into the form, for controls that are not plain inputs. */
const setSelectedConfig = (
patch: (
current: Partial<ChannelSpecFormValues>,
) => Partial<ChannelSpecFormValues>,
): void => {
Object.entries(patch(getValues())).forEach(([field, value]) => {
setValue(field as keyof ChannelSpecFormValues, value as never, {
shouldDirty: true,
});
});
};
const [metadataRows, setMetadataRows] = useState<MetadataRow[]>(() =>
Object.entries(initialMetadata || {}).map(([key, value]) => ({
key,
@@ -23,7 +43,7 @@ function IncidentIOSettings({
})),
);
const update = (patch: Partial<IncidentIOChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
@@ -55,56 +75,65 @@ function IncidentIOSettings({
</Typography.Link>
</Typography.Text>
<Form.Item
name="url"
<FormField
label={t('field_incidentio_url')}
help={t('help_incidentio_url')}
required
error={errors.url?.message}
>
<Input
onChange={(event): void => update({ url: event.target.value })}
data-testid="incidentio-url-textbox"
/>
</Form.Item>
<Input {...register('url')} data-testid="incidentio-url-textbox" />
</FormField>
<Form.Item
name="token"
<FormField
label={t('field_incidentio_token')}
help={t('help_incidentio_token')}
required
error={errors.token?.message}
>
<Input
{...register('token')}
type="password"
onChange={(event): void => update({ token: event.target.value })}
data-testid="incidentio-token-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="title"
<FormField
label={t('field_incidentio_title')}
help={t('help_incidentio_title')}
error={errors.title?.message}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ title: event.target.value })}
data-testid="incidentio-title-textarea"
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={2}
data-testid="incidentio-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="description"
<FormField
label={t('field_incidentio_description')}
help={t('help_incidentio_description')}
error={errors.description?.message}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="incidentio-description-textarea"
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={6}
data-testid="incidentio-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
<FormField
label={t('field_incidentio_metadata')}
help={t('help_incidentio_metadata')}
>
@@ -155,13 +184,12 @@ function IncidentIOSettings({
>
{t('button_incidentio_add_metadata')}
</Button>
</Form.Item>
</FormField>
</>
);
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
initialMetadata?: Record<string, string>;
}

View File

@@ -1,120 +1,94 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Collapse, Input as AntdInput, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { JiraChannel } from '../../CreateAlertChannels/config';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from '../../CreateAlertChannels/utils';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
function JiraSettings(): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JiraChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const advanced = (
<>
<Form.Item
name="priority"
<FormField
label={t('field_jira_priority')}
help={t('help_jira_priority')}
error={errors.priority?.message}
>
<Input
{...register('priority')}
placeholder={t('placeholder_jira_priority')}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jira-priority-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="labels"
<FormField
label={t('field_jira_labels')}
help={t('help_jira_labels')}
error={errors.labels?.message}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jira_labels')}
onChange={(value): void => update({ labels: value as string[] })}
data-testid="jira-labels-select"
/>
</Form.Item>
</FormField>
<Form.Item
name="resolve_transition"
<FormField
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
error={errors.resolveTransition?.message}
>
<Input
{...register('resolveTransition')}
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="reopen_transition"
<FormField
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
error={errors.reopenTransition?.message}
>
<Input
{...register('reopenTransition')}
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="wont_fix_resolution"
<FormField
label={t('field_jira_wont_fix_resolution')}
help={t('help_jira_wont_fix_resolution')}
error={errors.wontFixResolution?.message}
>
<Input
{...register('wontFixResolution')}
placeholder={t('placeholder_jira_wont_fix_resolution')}
onChange={(event): void =>
update({ wont_fix_resolution: event.target.value })
}
data-testid="jira-wont-fix-resolution-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="reopen_duration"
<FormField
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
{
validator: (_, value: string): Promise<void> =>
isValidJiraReopenDuration(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_reopen_duration')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
help={t('help_jira_reopen_duration')}
error={errors.reopenDuration?.message}
>
<Input
{...register('reopenDuration')}
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
</FormField>
</>
);
@@ -136,104 +110,87 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Typography.Link>
</Typography.Text>
<Form.Item
name="site"
label={t('field_jira_site')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidJiraSiteURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_site_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_site')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<FormField label={t('field_jira_site')} error={errors.site?.message}>
<Input
{...register('site')}
placeholder="https://your-domain.atlassian.net"
onChange={(event): void => update({ site: event.target.value })}
data-testid="jira-site-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="username"
<FormField
label={t('field_jira_email')}
help={t('help_jira_email')}
required
error={errors.email?.message}
>
<Input
onChange={(event): void => update({ username: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Input {...register('email')} data-testid="jira-email-textbox" />
</FormField>
<Form.Item
name="password"
<FormField
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
error={errors.apiToken?.message}
>
<Input
{...register('apiToken')}
type="password"
onChange={(event): void => update({ password: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
</FormField>
<Form.Item name="project" label={t('field_jira_project')} required>
<FormField label={t('field_jira_project')} error={errors.project?.message}>
<Input
{...register('project')}
placeholder="e.g. OPS"
onChange={(event): void => update({ project: event.target.value })}
data-testid="jira-project-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="issue_type"
<FormField
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
error={errors.issueType?.message}
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
<Input {...register('issueType')} data-testid="jira-issue-type-textbox" />
</FormField>
<Form.Item
name="summary"
<FormField
label={t('field_jira_summary')}
help={t('help_jira_summary')}
error={errors.summary?.message}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ summary: event.target.value })}
data-testid="jira-summary-textarea"
<Controller
control={control}
name="summary"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={2}
data-testid="jira-summary-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="description"
<FormField
label={t('field_jira_description')}
help={t('help_jira_description')}
error={errors.description?.message}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jira-description-textarea"
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={6}
data-testid="jira-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Collapse
ghost
@@ -249,8 +206,4 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
);
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
}
export default JiraSettings;

View File

@@ -1,43 +1,62 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Collapse, Input as AntdInput, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
function JsmOpsSettings(): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JsmOpsChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const advanced = (
<>
<Form.Item
name="priority"
<FormField
label={t('field_jsmops_priority')}
help={t('help_jsmops_priority')}
error={errors.priority?.message}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jsmops-priority-textarea"
<Controller
control={control}
name="priority"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={2}
data-testid="jsmops-priority-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="tags"
<FormField
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
error={errors.tags?.message}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
data-testid="jsmops-tags-select"
{/* the API takes one comma-separated string, the control edits chips */}
<Controller
control={control}
name="tags"
render={({ field }): JSX.Element => (
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
value={field.value ? String(field.value).split(',') : []}
onChange={(value): void => field.onChange((value as string[]).join(','))}
data-testid="jsmops-tags-select"
/>
)}
/>
</Form.Item>
</FormField>
</>
);
@@ -59,42 +78,55 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
</Typography.Link>
</Typography.Text>
<Form.Item
name="api_key"
<FormField
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
error={errors.apiKey?.message}
>
<Input
{...register('apiKey')}
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="message"
<FormField
label={t('field_jsmops_message')}
help={t('help_jsmops_message')}
error={errors.message?.message}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ message: event.target.value })}
data-testid="jsmops-message-textarea"
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={2}
data-testid="jsmops-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="description"
<FormField
label={t('field_jsmops_description')}
help={t('help_jsmops_description')}
error={errors.description?.message}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jsmops-description-textarea"
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={6}
data-testid="jsmops-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Collapse
ghost
@@ -110,8 +142,4 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
);
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
}
export default JsmOpsSettings;

View File

@@ -1,74 +1,58 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
function MsTeams(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_ms_teams_url')}
variables={{}}
<FormField label={t('field_webhook_url')} error={errors.webhookUrl?.message}>
<Input {...register('webhookUrl')} data-testid="webhook-url-textbox" />
</FormField>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
rows={4}
// value={`[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n{{\" \"}}(\n{{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}=\"{{ $label.Value -}}\"\n {{- end }}\n{{- end -}}\n)\n{{- end }}`}
data-testid="title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
)}
/>
</Form.Item>
</FormField>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
// value={`[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n{{\" \"}}(\n{{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}=\"{{ $label.Value -}}\"\n {{- end }}\n{{- end -}}\n)\n{{- end }}`}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
<FormField label={t('field_slack_description')} error={errors.text?.message}>
<Controller
control={control}
name="text"
render={({ field }): JSX.Element => (
<AntdInput.TextArea
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</FormField>
</>
);
}
interface MsTeamsProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<MsTeamsChannel>>
>;
}
export default MsTeams;

View File

@@ -1,95 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
const { TextArea } = Input;
const { TextArea } = AntdInput;
function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
function OpsgenieForm(): JSX.Element {
const { t } = useTranslation('channels');
const handleInputChange =
(field: string) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
setSelectedConfig((value) => ({
...value,
[field]: event.target.value,
}));
};
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="api_key"
<FormField
label={t('field_opsgenie_api_key')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_opsgenie_api_key')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
required
error={errors.apiKey?.message}
>
<Input
onChange={handleInputChange('api_key')}
data-testid="opsgenie-api-key-textbox"
/>
</Form.Item>
<Input {...register('apiKey')} data-testid="opsgenie-api-key-textbox" />
</FormField>
<Form.Item
name="message"
<FormField
help={t('help_opsgenie_message')}
label={t('field_opsgenie_message')}
required
error={errors.message?.message}
>
<TextArea
rows={4}
onChange={handleInputChange('message')}
placeholder={t('placeholder_opsgenie_message')}
data-testid="opsgenie-message-textarea"
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
placeholder={t('placeholder_opsgenie_message')}
data-testid="opsgenie-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="description"
<FormField
help={t('help_opsgenie_description')}
label={t('field_opsgenie_description')}
required
error={errors.description?.message}
>
<TextArea
rows={4}
onChange={handleInputChange('description')}
placeholder={t('placeholder_opsgenie_description')}
data-testid="opsgenie-description-textarea"
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
placeholder={t('placeholder_opsgenie_description')}
data-testid="opsgenie-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="priority"
<FormField
help={t('help_opsgenie_priority')}
label={t('field_opsgenie_priority')}
required
error={errors.priority?.message}
>
<TextArea
rows={4}
onChange={handleInputChange('priority')}
placeholder={t('placeholder_opsgenie_priority')}
data-testid="opsgenie-priority-textarea"
<Controller
control={control}
name="priority"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
placeholder={t('placeholder_opsgenie_priority')}
data-testid="opsgenie-priority-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
</>
);
}
interface OpsgenieFormProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<OpsgenieChannel>>
>;
}
export default OpsgenieForm;

View File

@@ -1,176 +1,103 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { PagerChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import PagerDetails from './PagerDetails';
const { TextArea } = Input;
const { TextArea } = AntdInput;
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
function PagerForm(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="routing_key"
<FormField
label={t('field_pager_routing_key')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_pager_routing_key')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
error={errors.routingKey?.message}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
routing_key: event.target.value,
}));
}}
{...register('routingKey')}
data-testid="pager-routing-key-textbox"
/>
</Form.Item>
</FormField>
<Form.Item
name="description"
<FormField
help={t('help_pager_description')}
label={t('field_pager_description')}
required
error={errors.description?.message}
>
<TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
description: event.target.value,
}))
}
placeholder={t('placeholder_pager_description')}
data-testid="pager-description-textarea"
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
placeholder={t('placeholder_pager_description')}
data-testid="pager-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</Form.Item>
</FormField>
<Form.Item
name="severity"
<FormField
help={t('help_pager_severity')}
label={t('field_pager_severity')}
error={errors.severity?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
severity: event.target.value,
}))
}
data-testid="pager-severity-textbox"
/>
</Form.Item>
<Input {...register('severity')} data-testid="pager-severity-textbox" />
</FormField>
<Form.Item
name="details"
help={t('help_pager_details')}
label={t('field_pager_details')}
>
<TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
details: event.target.value,
}))
}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
<PagerDetails />
<Form.Item
name="component"
<FormField
help={t('help_pager_component')}
label={t('field_pager_component')}
error={errors.component?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
component: event.target.value,
}))
}
/>
</Form.Item>
<Input {...register('component')} />
</FormField>
<Form.Item
name="group"
<FormField
help={t('help_pager_group')}
label={t('field_pager_group')}
error={errors.group?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
group: event.target.value,
}))
}
data-testid="pager-group-textarea"
/>
</Form.Item>
<Input {...register('group')} data-testid="pager-group-textarea" />
</FormField>
<Form.Item
name="class"
<FormField
help={t('help_pager_class')}
label={t('field_pager_class')}
error={errors.class?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
class: event.target.value,
}))
}
data-testid="pager-class-textarea"
/>
</Form.Item>
<Form.Item
name="client"
<Input {...register('class')} data-testid="pager-class-textarea" />
</FormField>
<FormField
help={t('help_pager_client')}
label={t('field_pager_client')}
error={errors.client?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
client: event.target.value,
}))
}
data-testid="pager-client-textarea"
/>
</Form.Item>
<Input {...register('client')} data-testid="pager-client-textarea" />
</FormField>
<Form.Item
name="client_url"
<FormField
help={t('help_pager_client_url')}
label={t('field_pager_client_url')}
error={errors.clientUrl?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
client_url: event.target.value,
}))
}
data-testid="pager-client-url-textarea"
/>
</Form.Item>
<Input {...register('clientUrl')} data-testid="pager-client-url-textarea" />
</FormField>
</>
);
}
interface PagerFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
}
export default PagerForm;

View File

@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Input } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
const { TextArea } = Input;
const serialize = (details?: Record<string, string>): string =>
details && Object.keys(details).length > 0 ? JSON.stringify(details) : '';
/**
* The API models these details as a map, so the raw JSON the box edits is held
* here: a half-typed object is not valid JSON and would not survive a round trip
* through the form's parsed value.
*/
function PagerDetails(): JSX.Element {
const { t } = useTranslation('channels');
const { getValues, setValue } = useFormContext<ChannelSpecFormValues>();
const [text, setText] = useState<string>(() =>
serialize(getValues('details') ?? undefined),
);
const [error, setError] = useState<string | null>(null);
const onChange = (value: string): void => {
setText(value);
if (value.trim() === '') {
setError(null);
setValue('details', {}, { shouldDirty: true });
return;
}
try {
setValue('details', JSON.parse(value), { shouldDirty: true });
setError(null);
} catch {
setError(t('pager_details_invalid_json'));
}
};
return (
<FormField
error={error ?? undefined}
help={t('help_pager_details')}
label={t('field_pager_details')}
>
<TextArea
rows={4}
value={text}
onChange={(event): void => onChange(event.target.value)}
data-testid="pager-additional-details-textarea"
/>
</FormField>
);
}
export default PagerDetails;

View File

@@ -1,90 +1,112 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { SlackChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import SlackActions from './SlackActions';
import SlackFields from './SlackFields';
const { TextArea } = Input;
const { TextArea } = AntdInput;
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
function Slack(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_slack_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
api_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<FormField label={t('field_webhook_url')} error={errors.apiUrl?.message}>
<Input {...register('apiUrl')} data-testid="webhook-url-textbox" />
</FormField>
<Form.Item
name="channel"
<FormField
help={t('slack_channel_help')}
label={t('field_slack_recipient')}
error={errors.channel?.message}
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
data-testid="title-textarea"
rows={4}
// value={`[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n{{\" \"}}(\n{{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}=\"{{ $label.Value -}}\"\n {{- end }}\n{{- end -}}\n)\n{{- end }}`}
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_slack_title_link')}
error={errors.titleLink?.message}
>
<Input {...register('titleLink')} data-testid="title-link-textbox" />
</FormField>
<FormField label={t('field_slack_description')} error={errors.text?.message}>
<Controller
control={control}
name="text"
render={({ field }): JSX.Element => (
<TextArea
placeholder={t('placeholder_slack_description')}
data-testid="description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}
error={errors.color?.message}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
channel: event.target.value,
}))
}
data-testid="slack-channel-textbox"
{...register('color')}
placeholder={t('placeholder_slack_color')}
data-testid="slack-color-textbox"
/>
</Form.Item>
</FormField>
<Form.Item name="title" label={t('field_slack_title')}>
<TextArea
data-testid="title-textarea"
rows={4}
// value={`[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n{{\" \"}}(\n{{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}=\"{{ $label.Value -}}\"\n {{- end }}\n{{- end -}}\n)\n{{- end }}`}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
/>
</Form.Item>
<FormField
label={t('field_slack_pretext')}
help={t('help_slack_pretext')}
error={errors.pretext?.message}
>
<Input {...register('pretext')} data-testid="slack-pretext-textbox" />
</FormField>
<Form.Item name="text" label={t('field_slack_description')}>
<TextArea
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
placeholder={t('placeholder_slack_description')}
data-testid="description-textarea"
/>
</Form.Item>
<FormField
label={t('field_slack_fallback')}
help={t('help_slack_fallback')}
error={errors.fallback?.message}
>
<Input {...register('fallback')} data-testid="slack-fallback-textbox" />
</FormField>
<FormField label={t('field_slack_footer')} error={errors.footer?.message}>
<Input {...register('footer')} data-testid="slack-footer-textbox" />
</FormField>
<SlackFields />
<SlackActions />
</>
);
}
interface SlackProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
}
export default Slack;

View File

@@ -0,0 +1,12 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.field {
flex: 1 1 160px;
min-width: 0;
}

View File

@@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import styles from './SlackActions.module.scss';
// Buttons Slack renders under the attachment. `type` and `text` are required by
// the API; a `button` carrying a url is the link-out case, the rest drive a
// Slack app's own callbacks.
function SlackActions(): JSX.Element {
const { t } = useTranslation('channels');
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
return (
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<Input
{...register(`actions.${index}.text`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_text')}
testId={`slack-action-text-${index}`}
/>
<Input
{...register(`actions.${index}.url`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_url')}
testId={`slack-action-url-${index}`}
/>
<Input
{...register(`actions.${index}.type`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_type')}
testId={`slack-action-type-${index}`}
/>
<Input
{...register(`actions.${index}.name`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_name')}
testId={`slack-action-name-${index}`}
/>
<Input
{...register(`actions.${index}.value`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_value')}
testId={`slack-action-value-${index}`}
/>
<Input
{...register(`actions.${index}.style`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_style')}
testId={`slack-action-style-${index}`}
/>
<Input
{...register(`actions.${index}.confirm.text`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_action_confirm')}
testId={`slack-action-confirm-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<Button
variant="dashed"
color="secondary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => append({ type: 'button', text: '', url: '' })}
testId="slack-action-add"
>
{t('add_slack_action')}
</Button>
</FormField>
);
}
export default SlackActions;

View File

@@ -0,0 +1,12 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.field {
flex: 1 1 180px;
min-width: 0;
}

View File

@@ -0,0 +1,78 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import styles from './SlackFields.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 } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<Input
{...register(`fields.${index}.title`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
<Input
{...register(`fields.${index}.value`)}
containerClassName={styles.field}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<Button
variant="dashed"
color="secondary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => append({ title: '', value: '', short: false })}
testId="slack-field-add"
>
{t('add_slack_field')}
</Button>
</FormField>
);
}
export default SlackFields;

View File

@@ -1,77 +1,53 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { WebhookChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
function WebhookSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<Form.Item
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_webhook_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
api_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<Form.Item
name="username"
<FormField label={t('field_webhook_url')} error={errors.url?.message}>
<Input {...register('url')} data-testid="webhook-url-textbox" />
</FormField>
<FormField
label={t('field_webhook_username')}
help={t('help_webhook_username')}
error={errors.username?.message}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
username: event.target.value,
}));
}}
data-testid="webhook-username-textbox"
/>
</Form.Item>
<Form.Item
name="password"
<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"
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
password: event.target.value,
}));
}}
data-testid="webhook-password-textbox"
/>
</Form.Item>
</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>
</>
);
}
interface WebhookProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<WebhookChannel>>>;
}
export default WebhookSettings;

View File

@@ -1,5 +1,8 @@
import { Form } from 'antd';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { useForm } from 'react-hook-form';
import {
ChannelFormValues,
ChannelKind,
} from 'container/CreateAlertChannels/types';
import { act, render, renderHook, screen } from 'tests/test-utils';
import FormAlertChannels from '..';
@@ -10,31 +13,29 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
describe('FormAlertChannels', () => {
it('keeps the send resolved switch in sync when the value arrives after mount', () => {
const {
result: {
current: [formInstance],
},
} = renderHook(() => Form.useForm());
const { result } = renderHook(() =>
useForm<ChannelFormValues>({ defaultValues: { name: 'Dummy-Channel' } }),
);
render(
<FormAlertChannels
formInstance={formInstance}
type={ChannelType.Slack}
setSelectedConfig={jest.fn()}
form={result.current}
type={ChannelKind.slack}
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
testingState={false}
savingState={false}
title="Edit"
initialValue={{ name: 'Dummy-Channel', type: ChannelType.Slack }}
editing
/>,
);
const sendResolvedSwitch = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolvedSwitch).not.toBeChecked();
act(() => {
formInstance.setFieldsValue({ send_resolved: true });
result.current.setValue('sendResolved', true);
});
expect(sendResolvedSwitch).toBeChecked();

View File

@@ -1,23 +1,17 @@
import { Dispatch, ReactElement, SetStateAction } from 'react';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Form, FormInstance, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
ChannelFormValues,
ChannelKind,
} from 'container/CreateAlertChannels/types';
import ROUTES from 'constants/routes';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import history from 'lib/history';
import EmailSettings from './Settings/Email';
@@ -30,50 +24,68 @@ import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import WebhookSettings from './Settings/Webhook';
import { Button } from './styles';
import { Button } from '@signozhq/ui/button';
import FormField from './FormField';
import styles from './FormAlertChannels.module.scss';
/** The kinds the form can create, in the order the picker lists them. */
const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.slack, label: 'Slack' },
{ value: ChannelKind.webhook, label: 'Webhook' },
{ value: ChannelKind.pagerduty, label: 'Pagerduty' },
{ value: ChannelKind.opsgenie, label: 'Opsgenie' },
{ value: ChannelKind.email, label: 'Email' },
{ value: ChannelKind.msteams, label: 'Microsoft Teams' },
{ value: ChannelKind.googlechat, label: 'Google Chat' },
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
];
function FormAlertChannels({
formInstance,
form,
type,
setSelectedConfig,
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
savingState,
testingState,
title,
initialValue,
editing = false,
readOnly = false,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = form;
const renderSettings = (): ReactElement | null => {
switch (type) {
case ChannelType.Slack:
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Webhook:
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Pagerduty:
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.JsmOps:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.IncidentIO:
return (
<IncidentIOSettings
setSelectedConfig={setSelectedConfig}
initialMetadata={initialValue?.metadata as Record<string, string>}
/>
);
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.slack:
return <SlackSettings />;
case ChannelKind.webhook:
return <WebhookSettings />;
case ChannelKind.pagerduty:
return <PagerSettings />;
case ChannelKind.msteams:
return <MsTeamsSettings />;
case ChannelKind.googlechat:
return <GoogleChatSettings />;
case ChannelKind.jira:
return <JiraSettings />;
case ChannelKind.jsmops:
return <JsmOpsSettings />;
case ChannelKind.incidentio:
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:
return null;
}
@@ -85,165 +97,123 @@ function FormAlertChannels({
{title}
</Typography.Title>
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
<Input
data-testid="channel-name-textbox"
disabled={editing}
onChange={(event): void => {
setSelectedConfig((state) => ({
...state,
name: event.target.value,
}));
}}
/>
</Form.Item>
<Form.Item
label={t('field_send_resolved')}
labelAlign="left"
name="send_resolved"
getValueProps={(value): { value: boolean } => ({ value: !!value })}
>
<Switch
color="primary"
textPlacement="right"
testId="field-send-resolved-checkbox"
onChange={(value): void => {
setSelectedConfig((state) => ({
...state,
send_resolved: value,
}));
}}
/>
</Form.Item>
<Form.Item label={t('field_channel_type')} labelAlign="left" name="type">
<Select
disabled={editing}
onChange={onTypeChangeHandler}
value={type}
data-testid="channel-type-select"
>
<Select.Option value="slack" key="slack" data-testid="select-option">
Slack
</Select.Option>
<Select.Option value="webhook" key="webhook" data-testid="select-option">
Webhook
</Select.Option>
<Select.Option
value="pagerduty"
key="pagerduty"
data-testid="select-option"
<FormProvider {...form}>
<form className={styles.form}>
{/* the @signozhq inputs take no disabled context and the settings
still render antd textareas; the fieldset turns off both. */}
<fieldset className={styles.fields} disabled={readOnly}>
<FormField
label={t('field_channel_name')}
help={editing ? t('help_channel_name_immutable') : undefined}
error={errors.name?.message}
htmlFor="channel-name"
>
Pagerduty
</Select.Option>
<Select.Option
value="opsgenie"
key="opsgenie"
data-testid="select-option"
<Input
id="channel-name"
data-testid="channel-name-textbox"
disabled={editing}
{...register('name')}
/>
</FormField>
<FormField label={t('field_send_resolved')}>
<Controller
control={control}
name="sendResolved"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="field-send-resolved-checkbox"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
value={type}
disabled={editing}
onChange={(value): void => onTypeChangeHandler(value as ChannelKind)}
withPortal={false}
testId="channel-type-select"
/>
</FormField>
{renderSettings()}
</fieldset>
<div className={styles.actions}>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="sm"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="sm"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
variant="outlined"
color="secondary"
size="sm"
testId="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
>
Opsgenie
</Select.Option>
<Select.Option value="email" key="email" data-testid="select-option">
Email
</Select.Option>
<Select.Option value="msteams" key="msteams" data-testid="select-option">
Microsoft Teams
</Select.Option>
<Select.Option
value="googlechat"
key="googlechat"
data-testid="select-option"
>
Google Chat
</Select.Option>
<Select.Option value="jira" key="jira" data-testid="select-option">
Jira
</Select.Option>
<Select.Option value="jsmops" key="jsmops" data-testid="select-option">
Jira Service Management Ops
</Select.Option>
<Select.Option
value="incidentio"
key="incidentio"
data-testid="select-option"
>
incident.io
</Select.Option>
</Select>
</Form.Item>
<Form.Item>{renderSettings()}</Form.Item>
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
>
{t('button_return')}
</Button>
</Form.Item>
</Form>
{t('button_return')}
</Button>
</div>
</form>
</FormProvider>
</>
);
}
interface FormAlertChannelsProps {
formInstance: FormInstance;
type: ChannelType;
setSelectedConfig: Dispatch<
SetStateAction<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>
>;
onTypeChangeHandler: (value: ChannelType) => void;
onSaveHandler: (props: ChannelType) => void;
onTestHandler: (props: ChannelType) => void;
form: UseFormReturn<ChannelFormValues>;
type: ChannelKind;
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
testingState: boolean;
savingState: boolean;
title: string;
initialValue: Store;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
testChecks?: BrandedPermission[];
}
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
saveChecks: [],
testChecks: [],
};
export default FormAlertChannels;

View File

@@ -1,19 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
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 { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
@@ -47,18 +43,16 @@ function BasicInfo({
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const { isLoading, data, error, isError, refetch } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
useState(false);
@@ -81,7 +75,7 @@ function BasicInfo({
});
};
const noChannels = data?.data?.length === 0;
const noChannels = channels.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
@@ -96,7 +90,7 @@ function BasicInfo({
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: data?.data?.length,
numberOfChannels: channels.length,
});
hasLoggedEvent.current = true;
}
@@ -230,7 +224,7 @@ function BasicInfo({
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={data?.data || []}
channels={channels}
isLoading={isLoading}
hasError={isError}
error={error as APIError}

View File

@@ -2,10 +2,9 @@ import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
@@ -16,7 +15,7 @@ export interface ChannelSelectProps {
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: Channels[];
channels: ChannelOption[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;
@@ -53,11 +52,8 @@ function ChannelSelect({
});
}
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const renderOptions = (): ReactNode[] => {
const children: ReactNode[] = [];

View File

@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(12);
expect(result).toHaveLength(13);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -337,6 +337,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'saved-view',
@@ -423,7 +424,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(12);
expect(result).toHaveLength(13);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -431,6 +432,7 @@ describe('createEmptyRolePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'saved-view',

View File

@@ -1,5 +1,6 @@
import {
Bot,
Cable,
ChartLine,
DraftingCompass,
FileKey,
@@ -104,6 +105,15 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type saved view ID, separate multiple with comma or space',
docsAnchor: 'saved-view',
},
'notification-channel': {
label: 'Notification Channels',
description:
'Destinations for alert notifications, such as Slack, PagerDuty, webhooks, etc.',
icon: Cable,
selectorPlaceholder:
'Type notification channel ID, separate multiple with comma or space',
docsAnchor: 'notification-channel',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -1,6 +1,6 @@
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
import { IAppContext, IUser } from 'providers/App/types';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -28,21 +28,13 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: Channels = {
export const MOCK_CHANNEL_1: ChannelOption = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: Channels = {
export const MOCK_CHANNEL_2: ChannelOption = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(

View File

@@ -77,12 +77,14 @@ jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
isLoading: false,
}),
}));
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: (): any =>
Promise.resolve({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
}),
useChannelOptions: (): any => ({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
isLoading: false,
isError: false,
refetch: jest.fn(),
}),
}));
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';

View File

@@ -1,4 +1,4 @@
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
export interface RoutingPolicy {
id: string;
@@ -62,7 +62,7 @@ export interface RoutingPolicyDetailsProps {
routingPolicy: RoutingPolicy | null;
closeModal: () => void;
mode: PolicyDetailsModalMode;
channels: Channels[];
channels: ChannelOption[];
isErrorChannels: boolean;
isLoadingChannels: boolean;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
@@ -86,7 +86,7 @@ export interface UseRoutingPoliciesReturn {
isErrorRoutingPolicies: boolean;
refetchRoutingPolicies: () => void;
// Channels
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,19 +1,17 @@
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import getAllChannels from 'api/channels/getAll';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import useUrlQuery from 'hooks/useUrlQuery';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import {
PolicyDetailsModalMode,
@@ -83,14 +81,11 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
// Channels list
const {
data,
data: channels,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refetchChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
const refreshChannels = (): void => {
refetchChannels();

View File

@@ -0,0 +1,68 @@
import { useQuery, UseQueryResult } from 'react-query';
import { listNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
const PAGE_SIZE = 100;
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
export interface ChannelOption {
id: string;
/** The display name, which is what rules and routing policies reference. */
name: string;
}
export type UseChannelOptionsResult = Omit<
UseQueryResult<ChannelOption[], Error>,
'data'
> & {
data: ChannelOption[];
};
function fetchPage(
offset: number,
): ReturnType<typeof listNotificationChannels> {
return listNotificationChannels({
limit: PAGE_SIZE,
offset,
sort: AlertmanagertypesChannelListSortDTO.name,
order: AlertmanagertypesChannelListOrderDTO.asc,
});
}
/**
* Every channel, for the pickers that let a rule or a policy name one. The list
* API is paginated and silently clamps a larger limit, so the first page reports
* the total and any remainder is fetched alongside rather than truncating. One
* request covers an org with a page's worth of channels or fewer.
*/
async function fetchAllChannels(): Promise<ChannelOption[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
);
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
...first.data.channels,
...rest.flatMap((page) => page.data.channels),
];
return channels.map((channel) => ({
id: channel.id,
name: channel.displayName,
}));
}
export function useChannelOptions(): UseChannelOptionsResult {
const query = useQuery<ChannelOption[], Error>(
CHANNEL_OPTIONS_QUERY_KEY,
fetchAllChannels,
);
return { ...query, data: query.data ?? [] };
}

View File

@@ -0,0 +1,39 @@
import {
NotificationChannelCreatePermission,
NotificationChannelListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export interface NotificationChannelCollectionPermissions {
canList: boolean;
canCreate: boolean;
/** A test send is gated on `create` against the wildcard. */
canTest: boolean;
isLoading: boolean;
/**
* The check itself failed. Callers should fall open (behave as before authz
* and let the API decide) rather than treat an outage as a denial.
*/
hasError: boolean;
}
// Module-level so the useQueries identity stays stable across renders.
const CHECKS = [
NotificationChannelListPermission,
NotificationChannelCreatePermission,
];
/** Collection-level notification channel permissions (wildcard selector). */
export function useNotificationChannelCollectionPermissions(): NotificationChannelCollectionPermissions {
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
const canCreate = isGranted(NotificationChannelCreatePermission);
return {
canList: isGranted(NotificationChannelListPermission),
canCreate,
canTest: canCreate,
isLoading,
hasError: !!error,
};
}

View File

@@ -0,0 +1,75 @@
import { useMemo } from 'react';
import {
buildNotificationChannelDeletePermission,
buildNotificationChannelReadPermission,
buildNotificationChannelUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export interface NotificationChannelPermissions {
canRead: boolean;
canUpdate: boolean;
canDelete: boolean;
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
canEdit: boolean;
isLoading: boolean;
/**
* The check itself failed. Callers should fall open — behave as before authz
* and let the API decide — rather than read an outage as a denial.
*/
hasError: boolean;
readPermission: BrandedPermission;
updatePermission: BrandedPermission;
deletePermission: BrandedPermission;
/** `[read, update]`, so a denial names both. */
editChecks: BrandedPermission[];
}
/**
* Resource-level notification channel permissions. Pass `enabled: false` while
* the id is unknown, so no check fires against an empty selector.
*/
export function useNotificationChannelPermissions(
channelId: string,
options?: { enabled?: boolean },
): NotificationChannelPermissions {
const enabled = options?.enabled ?? true;
const { readPermission, updatePermission, deletePermission } = useMemo(
() => ({
readPermission: buildNotificationChannelReadPermission(channelId),
updatePermission: buildNotificationChannelUpdatePermission(channelId),
deletePermission: buildNotificationChannelDeletePermission(channelId),
}),
[channelId],
);
const checks = useMemo(
() => [readPermission, updatePermission, deletePermission],
[readPermission, updatePermission, deletePermission],
);
const { isGranted, isLoading, error } = useAuthZ(checks, { enabled });
const canRead = isGranted(readPermission);
const canUpdate = isGranted(updatePermission);
const editChecks = useMemo(
() => [readPermission, updatePermission],
[readPermission, updatePermission],
);
return {
canRead,
canUpdate,
canDelete: isGranted(deletePermission),
canEdit: canRead && canUpdate,
isLoading,
hasError: !!error,
readPermission,
updatePermission,
deletePermission,
editChecks,
};
}

View File

@@ -18,6 +18,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'notification-channel',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'quick-filter',
type: 'metaresource',

Some files were not shown because too many files have changed in this diff Show More