mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-20 00:50:41 +01:00
Compare commits
40 Commits
fix/dynami
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3cc69e93c | ||
|
|
2e7c5b35ca | ||
|
|
35a34fe51f | ||
|
|
580c612cbf | ||
|
|
2a8907bbf1 | ||
|
|
d66cb80bdd | ||
|
|
e2d63ea6df | ||
|
|
e47ed83ac6 | ||
|
|
80cc9a2e5c | ||
|
|
482445cc09 | ||
|
|
ada28611c4 | ||
|
|
636f8dda8e | ||
|
|
46e7e03955 | ||
|
|
82de936e8d | ||
|
|
321ff56535 | ||
|
|
f0178377c3 | ||
|
|
762028149f | ||
|
|
9812f2d04b | ||
|
|
752d60be63 | ||
|
|
ce531c9165 | ||
|
|
9423d8f5fe | ||
|
|
aa6ed6d23a | ||
|
|
f7e0bd25a3 | ||
|
|
b35facf1e7 | ||
|
|
2ececf7830 | ||
|
|
9d559aed56 | ||
|
|
aea795b428 | ||
|
|
130cbd578f | ||
|
|
6223274fdd | ||
|
|
9095ec2ba2 | ||
|
|
57e87141db | ||
|
|
97ba9e0a13 | ||
|
|
11c8ac90f8 | ||
|
|
22ff223a8c | ||
|
|
1b6b7d7a30 | ||
|
|
48f05cb2c6 | ||
|
|
53c5d0e1ea | ||
|
|
939c261baa | ||
|
|
346d645d16 | ||
|
|
7de58fd803 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
|
||||
25
cmd/authz.go
25
cmd/authz.go
@@ -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{}
|
||||
|
||||
@@ -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',
|
||||
[
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
.alert-channels-container {
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
148
frontend/src/container/CreateAlertChannels/channelConfig.test.ts
Normal file
148
frontend/src/container/CreateAlertChannels/channelConfig.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
185
frontend/src/container/CreateAlertChannels/channelConfig.ts
Normal file
185
frontend/src/container/CreateAlertChannels/channelConfig.ts
Normal 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) };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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]: {},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const emailSchema = z.object({
|
||||
to: requiredString('to_required'),
|
||||
});
|
||||
@@ -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' },
|
||||
),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
21
frontend/src/container/CreateAlertChannels/schemas/jira.ts
Normal file
21
frontend/src/container/CreateAlertChannels/schemas/jira.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const jsmOpsSchema = z.object({
|
||||
apiKey: requiredString('api_key_required'),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const msTeamsSchema = z.object({
|
||||
webhookUrl: requiredString('webhook_url_required'),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const opsgenieSchema = z.object({
|
||||
apiKey: requiredString('api_key_required'),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const pagerdutySchema = z.object({
|
||||
routingKey: requiredString('routing_key_required'),
|
||||
});
|
||||
15
frontend/src/container/CreateAlertChannels/schemas/shared.ts
Normal file
15
frontend/src/container/CreateAlertChannels/schemas/shared.ts
Normal 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'),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const slackSchema = z.object({
|
||||
apiUrl: requiredString('webhook_url_required'),
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
43
frontend/src/container/CreateAlertChannels/types.ts
Normal file
43
frontend/src/container/CreateAlertChannels/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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() !== '');
|
||||
|
||||
53
frontend/src/container/CreateAlertChannels/validation.ts
Normal file
53
frontend/src/container/CreateAlertChannels/validation.ts
Normal 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);
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
40
frontend/src/container/FormAlertChannels/FormField.tsx
Normal file
40
frontend/src/container/FormAlertChannels/FormField.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
68
frontend/src/hooks/notificationChannels/useChannelOptions.ts
Normal file
68
frontend/src/hooks/notificationChannels/useChannelOptions.ts
Normal 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 ?? [] };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user