mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-23 03:40:40 +01:00
Compare commits
7 Commits
main
...
feat/notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76678c55b5 | ||
|
|
f0ddabf832 | ||
|
|
c3ff44e7ad | ||
|
|
7f72db9bdc | ||
|
|
2c194b135f | ||
|
|
47b81c5923 | ||
|
|
27bdafa20f |
23
cmd/authz.go
23
cmd/authz.go
@@ -93,17 +93,18 @@ 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.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.ResourceMetaResourceNotificationChannel).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -151,5 +151,34 @@
|
||||
"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"
|
||||
}
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"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_webhook_bearer_token": "Bearer token (optional)",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it."
|
||||
}
|
||||
|
||||
@@ -1462,10 +1462,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: {
|
||||
@@ -1474,7 +1473,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
|
||||
@@ -1558,6 +1557,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,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;
|
||||
}
|
||||
}
|
||||
115
frontend/src/container/CreateAlertChannels/channelConfig.test.ts
Normal file
115
frontend/src/container/CreateAlertChannels/channelConfig.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { toChannelConfig, toPostableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import { ChannelFormValues, ChannelType } from './config';
|
||||
|
||||
const slackValues: ChannelFormValues = {
|
||||
name: 'prod alerts',
|
||||
api_url: 'https://hooks.slack.com/services/T/B/X',
|
||||
channel: '#alerts',
|
||||
title: 'title template',
|
||||
title_link: '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' }],
|
||||
send_resolved: true,
|
||||
};
|
||||
|
||||
describe('toChannelConfig', () => {
|
||||
it('maps the slack form onto the v2 spec, new fields included', () => {
|
||||
expect(toChannelConfig(ChannelType.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 untouched fields so the api applies its own defaults', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelType.Webhook, {
|
||||
api_url: 'https://example.com/hook',
|
||||
}),
|
||||
).toStrictEqual({
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook', sendResolved: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames the fields v2 models differently', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelType.Jira, {
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issue_type: 'Task',
|
||||
username: 'someone@acme.io',
|
||||
password: 'token',
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'jira',
|
||||
spec: { email: 'someone@acme.io', apiToken: 'token', issueType: 'Task' },
|
||||
});
|
||||
|
||||
expect(
|
||||
toChannelConfig(ChannelType.JsmOps, {
|
||||
api_key: 'key',
|
||||
tags: ['prod', 'db'],
|
||||
}),
|
||||
).toMatchObject({ kind: 'jsmops', spec: { tags: 'prod,db' } });
|
||||
});
|
||||
|
||||
it('parses the raw json the pagerduty form holds for details', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelType.Pagerduty, {
|
||||
routing_key: 'key',
|
||||
details: '{"firing":"{{ .Alerts.Firing | toJson }}"}',
|
||||
}),
|
||||
).toMatchObject({
|
||||
spec: { details: { firing: '{{ .Alerts.Firing | toJson }}' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPostableChannel', () => {
|
||||
it('lets the api generate the immutable name from the display name', () => {
|
||||
expect(toPostableChannel(ChannelType.Slack, slackValues)).toMatchObject({
|
||||
generateName: true,
|
||||
displayName: 'prod alerts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(ChannelType.Slack, slackValues),
|
||||
} as AlertmanagertypesGettableNotificationChannelDTO;
|
||||
|
||||
const { type, values } = toChannelFormState(channel);
|
||||
|
||||
expect(type).toBe(ChannelType.Slack);
|
||||
expect(toChannelConfig(type, values)).toStrictEqual(channel.config);
|
||||
expect(values.name).toBe('prod alerts');
|
||||
});
|
||||
});
|
||||
237
frontend/src/container/CreateAlertChannels/channelConfig.ts
Normal file
237
frontend/src/container/CreateAlertChannels/channelConfig.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
AlertmanagertypesChannelConfigDTO,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind as EmailKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind as GoogleChatKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind as IncidentIOKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind as JiraKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind as JsmOpsKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind as MsTeamsKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind as OpsgenieKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind as PagerdutyKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind as SlackKind,
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind as WebhookKind,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelFormValues, ChannelType } from './config';
|
||||
|
||||
/**
|
||||
* The v2 API rejects a key it does not model and applies its own defaults for an
|
||||
* absent one, so an untouched field must be dropped rather than sent empty.
|
||||
*/
|
||||
function omitEmpty<T extends Record<string, unknown>>(spec: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(spec).filter(([, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** The pagerduty and opsgenie forms hold `details` as raw JSON text. */
|
||||
function parseDetails(details?: string): Record<string, string> {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function dropBlankKeys(
|
||||
pairs?: Record<string, string>,
|
||||
): Record<string, string> | undefined {
|
||||
if (!pairs) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(pairs).filter(([key]) => key.trim() !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
export function toChannelConfig(
|
||||
type: ChannelType,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesChannelConfigDTO {
|
||||
const sendResolved = values.send_resolved ?? false;
|
||||
|
||||
switch (type) {
|
||||
case ChannelType.Slack:
|
||||
return {
|
||||
kind: SlackKind.slack,
|
||||
spec: omitEmpty({
|
||||
apiUrl: values.api_url ?? '',
|
||||
channel: values.channel,
|
||||
title: values.title,
|
||||
titleLink: values.title_link,
|
||||
text: values.text,
|
||||
pretext: values.pretext,
|
||||
fallback: values.fallback,
|
||||
footer: values.footer,
|
||||
color: values.color,
|
||||
fields: values.fields,
|
||||
actions: values.actions,
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.Webhook:
|
||||
return {
|
||||
kind: WebhookKind.webhook,
|
||||
spec: omitEmpty({
|
||||
url: values.api_url ?? '',
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
bearerToken: values.bearer_token,
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.Email:
|
||||
return {
|
||||
kind: EmailKind.email,
|
||||
spec: omitEmpty({
|
||||
to: values.to ?? '',
|
||||
html: values.html,
|
||||
headers: dropBlankKeys(values.headers),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.Pagerduty:
|
||||
return {
|
||||
kind: PagerdutyKind.pagerduty,
|
||||
spec: omitEmpty({
|
||||
routingKey: values.routing_key ?? '',
|
||||
client: values.client,
|
||||
clientUrl: values.client_url,
|
||||
description: values.description,
|
||||
severity: values.severity,
|
||||
component: values.component,
|
||||
group: values.group,
|
||||
class: values.class,
|
||||
url: values.pagerduty_url,
|
||||
details: parseDetails(values.details),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.Opsgenie:
|
||||
return {
|
||||
kind: OpsgenieKind.opsgenie,
|
||||
spec: omitEmpty({
|
||||
apiKey: values.api_key ?? '',
|
||||
apiUrl: values.opsgenie_api_url,
|
||||
message: values.message,
|
||||
description: values.description,
|
||||
source: values.source,
|
||||
priority: values.priority,
|
||||
details: parseDetails(values.details),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.MsTeams:
|
||||
return {
|
||||
kind: MsTeamsKind.msteams,
|
||||
spec: omitEmpty({
|
||||
webhookUrl: values.webhook_url ?? '',
|
||||
title: values.title,
|
||||
text: values.text,
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.GoogleChat:
|
||||
return {
|
||||
kind: GoogleChatKind.googlechat,
|
||||
spec: omitEmpty({
|
||||
webhookUrl: values.webhook_url ?? '',
|
||||
title: values.title,
|
||||
text: values.text,
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.Jira:
|
||||
return {
|
||||
kind: JiraKind.jira,
|
||||
spec: omitEmpty({
|
||||
site: values.site ?? '',
|
||||
project: values.project ?? '',
|
||||
issueType: values.issue_type ?? '',
|
||||
// basic auth: the atlassian account email and its api token
|
||||
email: values.username ?? '',
|
||||
apiToken: values.password ?? '',
|
||||
summary: values.summary,
|
||||
description: values.description,
|
||||
priority: values.priority,
|
||||
labels: values.labels,
|
||||
resolveTransition: values.resolve_transition,
|
||||
reopenTransition: values.reopen_transition,
|
||||
wontFixResolution: values.wont_fix_resolution,
|
||||
reopenDuration: values.reopen_duration,
|
||||
customFields: dropBlankKeys(values.custom_fields),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.JsmOps:
|
||||
return {
|
||||
kind: JsmOpsKind.jsmops,
|
||||
spec: omitEmpty({
|
||||
apiKey: values.api_key ?? '',
|
||||
message: values.message,
|
||||
description: values.description,
|
||||
priority: values.priority,
|
||||
// the backend takes a comma-separated string and splits it back
|
||||
tags: values.tags?.join(','),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
case ChannelType.IncidentIO:
|
||||
return {
|
||||
kind: IncidentIOKind.incidentio,
|
||||
spec: omitEmpty({
|
||||
url: values.url ?? '',
|
||||
token: values.token ?? '',
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
metadata: dropBlankKeys(values.metadata),
|
||||
sendResolved,
|
||||
}),
|
||||
};
|
||||
default:
|
||||
throw new Error(`unsupported channel type: ${String(type)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toPostableChannel(
|
||||
type: ChannelType,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesPostableNotificationChannelDTO {
|
||||
return {
|
||||
// the api derives the immutable dns1123 name from the display name
|
||||
generateName: true,
|
||||
displayName: values.name ?? '',
|
||||
config: toChannelConfig(type, values),
|
||||
};
|
||||
}
|
||||
|
||||
export function toUpdatableChannel(
|
||||
type: ChannelType,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
return { config: toChannelConfig(type, values) };
|
||||
}
|
||||
|
||||
export function toTestableChannel(
|
||||
type: ChannelType,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesTestableNotificationChannelDTO {
|
||||
return { config: toChannelConfig(type, values) };
|
||||
}
|
||||
155
frontend/src/container/CreateAlertChannels/channelFormValues.ts
Normal file
155
frontend/src/container/CreateAlertChannels/channelFormValues.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
AlertmanagertypesChannelConfigDTO,
|
||||
AlertmanagertypesGettableNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelFormValues, ChannelType } from './config';
|
||||
|
||||
/** jira custom field values are free-form json server-side, the form edits text. */
|
||||
function toStringMap(
|
||||
pairs?: Record<string, unknown>,
|
||||
): Record<string, string> | undefined {
|
||||
if (!pairs) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(pairs).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'string' ? value : JSON.stringify(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyDetails(details?: Record<string, string>): string {
|
||||
return details && Object.keys(details).length > 0
|
||||
? JSON.stringify(details)
|
||||
: '';
|
||||
}
|
||||
|
||||
function toValues(
|
||||
config: AlertmanagertypesChannelConfigDTO,
|
||||
): ChannelFormValues {
|
||||
switch (config.kind) {
|
||||
case 'slack':
|
||||
return {
|
||||
api_url: config.spec.apiUrl,
|
||||
channel: config.spec.channel,
|
||||
title: config.spec.title,
|
||||
title_link: config.spec.titleLink,
|
||||
text: config.spec.text,
|
||||
pretext: config.spec.pretext,
|
||||
fallback: config.spec.fallback,
|
||||
footer: config.spec.footer,
|
||||
color: config.spec.color,
|
||||
fields: config.spec.fields,
|
||||
actions: config.spec.actions,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'webhook':
|
||||
return {
|
||||
api_url: config.spec.url,
|
||||
username: config.spec.username,
|
||||
password: config.spec.password,
|
||||
bearer_token: config.spec.bearerToken,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'email':
|
||||
return {
|
||||
to: config.spec.to,
|
||||
html: config.spec.html,
|
||||
headers: config.spec.headers,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'pagerduty':
|
||||
return {
|
||||
routing_key: config.spec.routingKey,
|
||||
client: config.spec.client,
|
||||
client_url: config.spec.clientUrl,
|
||||
description: config.spec.description,
|
||||
severity: config.spec.severity,
|
||||
component: config.spec.component,
|
||||
group: config.spec.group,
|
||||
class: config.spec.class,
|
||||
pagerduty_url: config.spec.url,
|
||||
details: stringifyDetails(config.spec.details),
|
||||
detailsArray: config.spec.details,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'opsgenie':
|
||||
return {
|
||||
api_key: config.spec.apiKey,
|
||||
opsgenie_api_url: config.spec.apiUrl,
|
||||
message: config.spec.message,
|
||||
description: config.spec.description,
|
||||
source: config.spec.source,
|
||||
priority: config.spec.priority,
|
||||
details: stringifyDetails(config.spec.details),
|
||||
detailsArray: config.spec.details,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'msteams':
|
||||
case 'googlechat':
|
||||
return {
|
||||
webhook_url: config.spec.webhookUrl,
|
||||
title: config.spec.title,
|
||||
text: config.spec.text,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'jira':
|
||||
return {
|
||||
site: config.spec.site,
|
||||
project: config.spec.project,
|
||||
issue_type: config.spec.issueType,
|
||||
username: config.spec.email,
|
||||
password: config.spec.apiToken,
|
||||
summary: config.spec.summary,
|
||||
description: config.spec.description,
|
||||
priority: config.spec.priority,
|
||||
labels: config.spec.labels,
|
||||
resolve_transition: config.spec.resolveTransition,
|
||||
reopen_transition: config.spec.reopenTransition,
|
||||
wont_fix_resolution: config.spec.wontFixResolution,
|
||||
reopen_duration: config.spec.reopenDuration,
|
||||
custom_fields: toStringMap(config.spec.customFields),
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'jsmops':
|
||||
return {
|
||||
api_key: config.spec.apiKey,
|
||||
message: config.spec.message,
|
||||
description: config.spec.description,
|
||||
priority: config.spec.priority,
|
||||
tags: config.spec.tags ? config.spec.tags.split(',') : undefined,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
case 'incidentio':
|
||||
return {
|
||||
url: config.spec.url,
|
||||
token: config.spec.token,
|
||||
title: config.spec.title,
|
||||
description: config.spec.description,
|
||||
metadata: config.spec.metadata,
|
||||
send_resolved: config.spec.sendResolved ?? false,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelFormState {
|
||||
type: ChannelType;
|
||||
values: ChannelFormValues;
|
||||
}
|
||||
|
||||
export function toChannelFormState(
|
||||
channel: AlertmanagertypesGettableNotificationChannelDTO,
|
||||
): ChannelFormState {
|
||||
return {
|
||||
type: channel.config.kind as string as ChannelType,
|
||||
values: {
|
||||
...toValues(channel.config),
|
||||
// the api keeps name immutable and exposes the editable label separately
|
||||
name: channel.displayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
AlertmanagertypesChannelSlackActionDTO,
|
||||
AlertmanagertypesChannelSlackFieldDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface Channel {
|
||||
send_resolved?: boolean;
|
||||
name: string;
|
||||
@@ -8,14 +13,26 @@ export interface SlackChannel extends Channel {
|
||||
api_url?: string;
|
||||
channel?: string;
|
||||
title?: string;
|
||||
// link the attachment title points at
|
||||
title_link?: string;
|
||||
text?: string;
|
||||
// text shown above the attachment block
|
||||
pretext?: string;
|
||||
// plain-text shown where the attachment cannot render, e.g. notifications
|
||||
fallback?: string;
|
||||
footer?: string;
|
||||
// attachment bar colour: good, warning, danger or a #rrggbb value
|
||||
color?: string;
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
}
|
||||
|
||||
export interface WebhookChannel extends Channel {
|
||||
api_url?: string;
|
||||
// basic auth
|
||||
// basic auth, optional — a channel may send with bearer auth or none at all
|
||||
username?: string;
|
||||
password?: string;
|
||||
bearer_token?: string;
|
||||
}
|
||||
|
||||
// PagerChannel configures alert manager to send
|
||||
@@ -39,6 +56,8 @@ export interface PagerChannel extends Channel {
|
||||
|
||||
details?: string;
|
||||
detailsArray?: Record<string, string>;
|
||||
// pagerduty events api endpoint, defaulted server-side when empty
|
||||
pagerduty_url?: string;
|
||||
}
|
||||
|
||||
// OpsgenieChannel configures alert manager to send
|
||||
@@ -62,6 +81,9 @@ export interface OpsgenieChannel extends Channel {
|
||||
|
||||
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
|
||||
priority?: string;
|
||||
|
||||
// opsgenie api endpoint, defaulted server-side when empty
|
||||
opsgenie_api_url?: string;
|
||||
}
|
||||
|
||||
export interface EmailChannel extends Channel {
|
||||
@@ -161,6 +183,8 @@ export interface JiraChannel extends Channel {
|
||||
wont_fix_resolution?: string;
|
||||
// duration string, e.g. 72h or 3d
|
||||
reopen_duration?: string;
|
||||
// jira custom field ids mapped to their templated values
|
||||
custom_fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
// IncidentIOChannel configures the incident.io alert channel, backed by an
|
||||
@@ -192,3 +216,20 @@ export interface JsmOpsChannel extends Channel {
|
||||
// tags, joined to a comma-separated string for the backend
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The create and edit forms hold every kind's fields in one object, so a type
|
||||
* switch keeps whatever the shared fields (title, text, description) already had.
|
||||
*/
|
||||
export type ChannelFormValues = Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,19 +1,4 @@
|
||||
import {
|
||||
AlertmanagertypesIncidentIOReceiverConfigDTO,
|
||||
AlertmanagertypesJiraReceiverConfigDTO,
|
||||
AlertmanagertypesJSMOpsReceiverConfigDTO,
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigSecretURLDTO,
|
||||
ModelDurationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
} from './config';
|
||||
import { ChannelType } from './config';
|
||||
|
||||
export const isChannelType = (type: string): type is ChannelType =>
|
||||
Object.values(ChannelType).includes(type as ChannelType);
|
||||
@@ -32,22 +17,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,87 +62,6 @@ 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 || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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(',');
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jsmops_configs: [jsmops],
|
||||
};
|
||||
};
|
||||
|
||||
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
@@ -190,33 +78,3 @@ export const isValidIncidentIOURL = (url: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 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],
|
||||
};
|
||||
};
|
||||
|
||||
111
frontend/src/container/CreateAlertChannels/validation.ts
Normal file
111
frontend/src/container/CreateAlertChannels/validation.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelType,
|
||||
PagerChannel,
|
||||
ValidatePagerChannel,
|
||||
} from './config';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidIncidentIOURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
} from './utils';
|
||||
|
||||
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
|
||||
|
||||
const requireWebhookUrl: Validator = (values, t) =>
|
||||
values.webhook_url ? null : t('webhook_url_required');
|
||||
|
||||
const requireApiKey: Validator = (values, t) =>
|
||||
values.api_key ? null : t('api_key_required');
|
||||
|
||||
const validateSlack: Validator = (values, t) =>
|
||||
values.api_url ? null : t('webhook_url_required');
|
||||
|
||||
const validateWebhook: Validator = (values, t) => {
|
||||
if (!values.api_url) {
|
||||
return t('webhook_url_required');
|
||||
}
|
||||
// the api allows bearer-only and no-auth webhooks, but a username without its
|
||||
// password is still an incomplete basic auth pair
|
||||
return values.username && !values.password ? t('username_no_password') : null;
|
||||
};
|
||||
|
||||
const validatePagerduty: Validator = (values) => {
|
||||
const error = ValidatePagerChannel(values as PagerChannel);
|
||||
return error === '' ? null : error;
|
||||
};
|
||||
|
||||
const validateEmail: Validator = (values, t) =>
|
||||
values.to ? null : t('to_required');
|
||||
|
||||
const validateGoogleChat: Validator = (values, t) => {
|
||||
if (!values.webhook_url) {
|
||||
return t('webhook_url_required');
|
||||
}
|
||||
return isValidGoogleChatWebhookURL(values.webhook_url)
|
||||
? null
|
||||
: t('google_chat_webhook_url_invalid');
|
||||
};
|
||||
|
||||
const validateJira: Validator = (values, t) => {
|
||||
if (
|
||||
!values.site ||
|
||||
!values.username ||
|
||||
!values.password ||
|
||||
!values.project ||
|
||||
!values.issue_type
|
||||
) {
|
||||
return t('jira_required_fields');
|
||||
}
|
||||
if (!isValidJiraSiteURL(values.site)) {
|
||||
return t('jira_site_invalid');
|
||||
}
|
||||
if (
|
||||
values.reopen_duration &&
|
||||
!isValidJiraReopenDuration(values.reopen_duration)
|
||||
) {
|
||||
return t('jira_reopen_duration_invalid');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateIncidentIO: Validator = (values, t) => {
|
||||
if (!values.url || !values.token) {
|
||||
return t('incidentio_required_fields');
|
||||
}
|
||||
return isValidIncidentIOURL(values.url) ? null : t('incidentio_url_invalid');
|
||||
};
|
||||
|
||||
const VALIDATORS: Record<ChannelType, Validator> = {
|
||||
[ChannelType.Slack]: validateSlack,
|
||||
[ChannelType.Webhook]: validateWebhook,
|
||||
[ChannelType.Pagerduty]: validatePagerduty,
|
||||
[ChannelType.Opsgenie]: requireApiKey,
|
||||
[ChannelType.JsmOps]: requireApiKey,
|
||||
[ChannelType.Email]: validateEmail,
|
||||
[ChannelType.MsTeams]: requireWebhookUrl,
|
||||
[ChannelType.GoogleChat]: validateGoogleChat,
|
||||
[ChannelType.Jira]: validateJira,
|
||||
[ChannelType.IncidentIO]: validateIncidentIO,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(
|
||||
type: ChannelType,
|
||||
values: ChannelFormValues,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
if (!values.name) {
|
||||
return t('channel_name_required');
|
||||
}
|
||||
|
||||
const validate = VALIDATORS[type];
|
||||
return validate ? validate(values, t) : t('selected_channel_invalid');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -25,10 +21,8 @@ function AlertCondition(): JSX.Element {
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refreshChannels,
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
} = useChannelOptions();
|
||||
const channels = data || [];
|
||||
|
||||
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;
|
||||
@@ -3,11 +3,22 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import {
|
||||
AlertmanagertypesChannelSlackActionDTO,
|
||||
AlertmanagertypesChannelSlackFieldDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SlackChannel } from '../../CreateAlertChannels/config';
|
||||
import SlackActions from './SlackActions';
|
||||
import SlackFields from './SlackFields';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
function Slack({
|
||||
setSelectedConfig,
|
||||
initialFields,
|
||||
initialActions,
|
||||
}: SlackProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
return (
|
||||
@@ -67,6 +78,18 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="title_link" label={t('field_slack_title_link')}>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
title_link: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="title-link-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="text" label={t('field_slack_description')}>
|
||||
<TextArea
|
||||
onChange={(event): void =>
|
||||
@@ -79,12 +102,85 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
data-testid="description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="color"
|
||||
label={t('field_slack_color')}
|
||||
help={t('help_slack_color')}
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
color: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('placeholder_slack_color')}
|
||||
data-testid="slack-color-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="pretext"
|
||||
label={t('field_slack_pretext')}
|
||||
help={t('help_slack_pretext')}
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
pretext: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="slack-pretext-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="fallback"
|
||||
label={t('field_slack_fallback')}
|
||||
help={t('help_slack_fallback')}
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
fallback: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="slack-fallback-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="footer" label={t('field_slack_footer')}>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
footer: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="slack-footer-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<SlackFields
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialFields={initialFields}
|
||||
/>
|
||||
|
||||
<SlackActions
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialActions={initialActions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlackProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
|
||||
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
}
|
||||
|
||||
export default Slack;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SlackChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
interface SlackActionsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
|
||||
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
}
|
||||
|
||||
const emptyAction: AlertmanagertypesChannelSlackActionDTO = {
|
||||
type: 'button',
|
||||
text: '',
|
||||
url: '',
|
||||
};
|
||||
|
||||
// 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({
|
||||
setSelectedConfig,
|
||||
initialActions,
|
||||
}: SlackActionsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const [rows, setRows] = useState<AlertmanagertypesChannelSlackActionDTO[]>(
|
||||
() => initialActions ?? [],
|
||||
);
|
||||
|
||||
const sync = (next: AlertmanagertypesChannelSlackActionDTO[]): void => {
|
||||
setRows(next);
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
actions: next.filter((row) => row.text.trim() !== ''),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRow = (
|
||||
index: number,
|
||||
patch: Partial<AlertmanagertypesChannelSlackActionDTO>,
|
||||
): void =>
|
||||
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
|
||||
return (
|
||||
<Form.Item label={t('field_slack_actions')} help={t('help_slack_actions')}>
|
||||
{rows.map((row, index) => (
|
||||
// the rows have no stable id, and reordering is not offered
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className="slack-actions-row">
|
||||
<Input
|
||||
value={row.text}
|
||||
placeholder={t('placeholder_slack_action_text')}
|
||||
onChange={(event): void => updateRow(index, { text: event.target.value })}
|
||||
data-testid={`slack-action-text-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.url}
|
||||
placeholder={t('placeholder_slack_action_url')}
|
||||
onChange={(event): void => updateRow(index, { url: event.target.value })}
|
||||
data-testid={`slack-action-url-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.type}
|
||||
placeholder={t('placeholder_slack_action_type')}
|
||||
onChange={(event): void => updateRow(index, { type: event.target.value })}
|
||||
data-testid={`slack-action-type-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.name ?? ''}
|
||||
placeholder={t('placeholder_slack_action_name')}
|
||||
onChange={(event): void => updateRow(index, { name: event.target.value })}
|
||||
data-testid={`slack-action-name-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value ?? ''}
|
||||
placeholder={t('placeholder_slack_action_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
data-testid={`slack-action-value-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.style ?? ''}
|
||||
placeholder={t('placeholder_slack_action_style')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { style: event.target.value })
|
||||
}
|
||||
data-testid={`slack-action-style-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.confirm?.text ?? ''}
|
||||
placeholder={t('placeholder_slack_action_confirm')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, {
|
||||
confirm: event.target.value ? { text: event.target.value } : undefined,
|
||||
})
|
||||
}
|
||||
data-testid={`slack-action-confirm-${index}`}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Minus size={14} />}
|
||||
aria-label={t('remove_slack_action')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
data-testid={`slack-action-remove-${index}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => sync([...rows, { ...emptyAction }])}
|
||||
data-testid="slack-action-add"
|
||||
>
|
||||
{t('add_slack_action')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackActions;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button, Checkbox, Form, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SlackChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
interface SlackFieldsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
|
||||
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
}
|
||||
|
||||
// Slack renders these as the attachment's table of short or full-width entries.
|
||||
function SlackFields({
|
||||
setSelectedConfig,
|
||||
initialFields,
|
||||
}: SlackFieldsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const [rows, setRows] = useState<AlertmanagertypesChannelSlackFieldDTO[]>(
|
||||
() => initialFields ?? [],
|
||||
);
|
||||
|
||||
const sync = (next: AlertmanagertypesChannelSlackFieldDTO[]): void => {
|
||||
setRows(next);
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
fields: next.filter((row) => row.title.trim() !== ''),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRow = (
|
||||
index: number,
|
||||
patch: Partial<AlertmanagertypesChannelSlackFieldDTO>,
|
||||
): void =>
|
||||
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
|
||||
return (
|
||||
<Form.Item label={t('field_slack_fields')} help={t('help_slack_fields')}>
|
||||
{rows.map((row, index) => (
|
||||
// the rows have no stable id, and reordering is not offered
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className="slack-fields-row">
|
||||
<Input
|
||||
value={row.title}
|
||||
placeholder={t('placeholder_slack_field_title')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { title: event.target.value })
|
||||
}
|
||||
data-testid={`slack-field-title-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t('placeholder_slack_field_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
data-testid={`slack-field-value-${index}`}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={!!row.short}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { short: event.target.checked })
|
||||
}
|
||||
data-testid={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Minus size={14} />}
|
||||
aria-label={t('remove_slack_field')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
data-testid={`slack-field-remove-${index}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
sync([...rows, { title: '', value: '', short: false }])
|
||||
}
|
||||
data-testid="slack-field-add"
|
||||
>
|
||||
{t('add_slack_field')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackFields;
|
||||
@@ -66,6 +66,22 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bearer_token"
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
bearer_token: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,13 @@ function FormAlertChannels({
|
||||
const renderSettings = (): ReactElement | null => {
|
||||
switch (type) {
|
||||
case ChannelType.Slack:
|
||||
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
|
||||
return (
|
||||
<SlackSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialFields={initialValue?.fields as SlackChannel['fields']}
|
||||
initialActions={initialValue?.actions as SlackChannel['actions']}
|
||||
/>
|
||||
);
|
||||
case ChannelType.Webhook:
|
||||
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Pagerduty:
|
||||
|
||||
@@ -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,10 @@ 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, 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 +69,7 @@ function BasicInfo({
|
||||
});
|
||||
};
|
||||
|
||||
const noChannels = data?.data?.length === 0;
|
||||
const noChannels = data?.length === 0;
|
||||
const handleCreateNewChannels = useCallback(() => {
|
||||
logEvent('Alert: Create notification channel button clicked', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
@@ -96,7 +84,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: data?.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
@@ -232,7 +220,7 @@ function BasicInfo({
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={data?.data || []}
|
||||
channels={data || []}
|
||||
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[] = [];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
Cable,
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type quick filter ID, separate multiple with comma or space',
|
||||
docsAnchor: 'quick-filter',
|
||||
},
|
||||
'notification-channel': {
|
||||
label: 'Notification Channels',
|
||||
description: 'Channels alerts are delivered to, such as Slack or PagerDuty.',
|
||||
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;
|
||||
|
||||
@@ -2,17 +2,16 @@ import { useMemo, useState } from 'react';
|
||||
import { useQuery, 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 {
|
||||
@@ -87,10 +86,8 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refetchChannels,
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
} = useChannelOptions();
|
||||
const channels = data || [];
|
||||
|
||||
const refreshChannels = (): void => {
|
||||
refetchChannels();
|
||||
|
||||
56
frontend/src/hooks/notificationChannels/useChannelOptions.ts
Normal file
56
frontend/src/hooks/notificationChannels/useChannelOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { listNotificationChannels } from 'api/generated/services/channels';
|
||||
import {
|
||||
AlertmanagertypesChannelListOrderDTO,
|
||||
AlertmanagertypesChannelListSortDTO,
|
||||
AlertmanagertypesListedNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** The list API's own ceiling; a bigger limit is clamped to it server-side. */
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel, for the pickers that let a rule or a policy name one. The list
|
||||
* API pages at 200, so this walks the pages rather than silently truncating.
|
||||
*/
|
||||
async function fetchAllChannels(): Promise<ChannelOption[]> {
|
||||
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [];
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const page = await listNotificationChannels({
|
||||
limit: MAX_PAGE_SIZE,
|
||||
offset: channels.length,
|
||||
sort: AlertmanagertypesChannelListSortDTO.name,
|
||||
order: AlertmanagertypesChannelListOrderDTO.asc,
|
||||
});
|
||||
|
||||
total = page.data.total;
|
||||
channels.push(...page.data.channels);
|
||||
|
||||
if (page.data.channels.length === 0) {
|
||||
break;
|
||||
}
|
||||
} while (channels.length < total);
|
||||
|
||||
return channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.displayName,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useChannelOptions(): UseQueryResult<ChannelOption[], Error> {
|
||||
return useQuery<ChannelOption[], Error>(
|
||||
CHANNEL_OPTIONS_QUERY_KEY,
|
||||
fetchAllChannels,
|
||||
);
|
||||
}
|
||||
@@ -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,69 @@
|
||||
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;
|
||||
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 } = 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,
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level. Wildcard selector required for correct response key matching.
|
||||
export const NotificationChannelListPermission = buildPermission(
|
||||
'list',
|
||||
'notification-channel:*',
|
||||
);
|
||||
// The test endpoint is gated on `create` against the wildcard, since a test send
|
||||
// persists nothing and the channel need not exist.
|
||||
export const NotificationChannelCreatePermission = buildPermission(
|
||||
'create',
|
||||
'notification-channel:*',
|
||||
);
|
||||
|
||||
// Resource-level. Requires a specific channel id.
|
||||
export const buildNotificationChannelReadPermission = (
|
||||
id: string,
|
||||
): BrandedPermission => buildPermission('read', `notification-channel:${id}`);
|
||||
export const buildNotificationChannelUpdatePermission = (
|
||||
id: string,
|
||||
): BrandedPermission => buildPermission('update', `notification-channel:${id}`);
|
||||
export const buildNotificationChannelDeletePermission = (
|
||||
id: string,
|
||||
): BrandedPermission => buildPermission('delete', `notification-channel:${id}`);
|
||||
@@ -51,3 +51,38 @@ export const opsGeniePriorityDefaultValue =
|
||||
|
||||
export const pagerDutySeverityTextDefaultValue =
|
||||
'{{ (index .Alerts 0).Labels.severity }}';
|
||||
|
||||
export const notificationChannelsV2 = [
|
||||
{
|
||||
id: '3',
|
||||
name: 'dummy-channel',
|
||||
displayName: 'Dummy-Channel',
|
||||
kind: 'slack',
|
||||
createdAt: '2023-08-09T04:45:19.239344617Z',
|
||||
updatedAt: '2024-06-27T11:37:14.841184399Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'oncall-pagerduty',
|
||||
displayName: 'Oncall PagerDuty',
|
||||
kind: 'pagerduty',
|
||||
createdAt: '2024-02-03T04:45:19.239344617Z',
|
||||
updatedAt: '2024-06-28T11:37:14.841184399Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const notificationChannelV2 = {
|
||||
id: '3',
|
||||
name: 'dummy-channel',
|
||||
displayName: 'Dummy-Channel',
|
||||
createdAt: '2023-08-09T04:45:19.239344617Z',
|
||||
updatedAt: '2024-06-27T11:37:14.841184399Z',
|
||||
config: {
|
||||
kind: 'slack',
|
||||
spec: {
|
||||
apiUrl: 'https://hooks.slack.com/services/dummy',
|
||||
channel: '#dummy_channel',
|
||||
sendResolved: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,11 @@ import { rest } from 'msw';
|
||||
|
||||
import commonEnTranslation from '../../public/locales/en/common.json';
|
||||
import enTranslation from '../../public/locales/en/translation.json';
|
||||
import { allAlertChannels } from './__mockdata__/alerts';
|
||||
import {
|
||||
allAlertChannels,
|
||||
notificationChannelsV2,
|
||||
notificationChannelV2,
|
||||
} from './__mockdata__/alerts';
|
||||
import { alertRulesFixture } from './__mockdata__/alert_rules';
|
||||
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
|
||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||
@@ -205,6 +209,32 @@ export const handlers = [
|
||||
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/notification_channels', (req, res, ctx) => {
|
||||
const query = req.url.searchParams.get('query')?.toLowerCase() ?? '';
|
||||
const kind = req.url.searchParams.get('kind');
|
||||
const channels = notificationChannelsV2.filter(
|
||||
(channel) =>
|
||||
(!query || channel.displayName.toLowerCase().includes(query)) &&
|
||||
(!kind || channel.kind === kind),
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { channels, total: channels.length },
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get('http://localhost/api/v2/notification_channels/:id', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: notificationChannelV2, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.delete(
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
(_, res, ctx) => res(ctx.status(204)),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.container {
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 56px;
|
||||
margin: var(--spacing-2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-8) var(--spacing-5);
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: var(--radius-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: var(--spacing-5);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Cable } from '@signozhq/icons';
|
||||
import { Pagination } from '@signozhq/ui/pagination';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { NotificationChannelListPermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
|
||||
import ChannelFormDrawer from './components/ChannelFormDrawer/ChannelFormDrawer';
|
||||
import ChannelListItem from './components/ChannelListItem/ChannelListItem';
|
||||
import ChannelsToolbar from './components/ChannelsToolbar/ChannelsToolbar';
|
||||
import DeleteChannelDialog from './components/DeleteChannelDialog/DeleteChannelDialog';
|
||||
import { PAGE_SIZE } from './constants';
|
||||
import { useChannelDelete } from './hooks/useChannelDelete';
|
||||
import { useChannelFormView } from './hooks/useChannelFormView';
|
||||
import { useChannelList } from './hooks/useChannelList';
|
||||
import styles from './NotificationChannels.module.scss';
|
||||
|
||||
function NotificationChannels(): JSX.Element {
|
||||
const list = useChannelList();
|
||||
const deletion = useChannelDelete();
|
||||
const formView = useChannelFormView();
|
||||
|
||||
useEffect(() => {
|
||||
void logEvent('Alert Channel: Channel list page visited', {
|
||||
number: list.total,
|
||||
});
|
||||
// the count is only interesting once, on the first resolved page
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const openEdit = (
|
||||
channel: AlertmanagertypesListedNotificationChannelDTO,
|
||||
): void => {
|
||||
formView.openEdit(channel.id);
|
||||
};
|
||||
|
||||
const isEmpty = !list.isLoading && list.channels.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Typography.Title className={styles.title}>
|
||||
Notification Channels
|
||||
</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle} color="muted">
|
||||
Configure where SigNoz delivers your alerts.
|
||||
</Typography.Text>
|
||||
|
||||
<ChannelsToolbar
|
||||
search={list.search}
|
||||
onSearchChange={list.setSearch}
|
||||
kind={list.kind}
|
||||
onKindChange={list.setKind}
|
||||
sort={list.sort}
|
||||
onSortChange={list.setSort}
|
||||
onCreate={formView.openCreate}
|
||||
/>
|
||||
|
||||
{list.isError && (
|
||||
<Typography.Text className={styles.error} color="danger">
|
||||
{list.error?.message ?? 'Could not load notification channels.'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{list.isLoading && (
|
||||
<div className={styles.list} data-testid="channels-loading">
|
||||
<Skeleton className={styles.skeletonRow} />
|
||||
<Skeleton className={styles.skeletonRow} />
|
||||
<Skeleton className={styles.skeletonRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && !list.isError && (
|
||||
<div className={styles.empty} data-testid="channels-empty">
|
||||
<Cable size={20} />
|
||||
<Typography.Text>
|
||||
{list.hasFilters
|
||||
? 'No channels match these filters.'
|
||||
: 'No notification channels yet. Create one to start receiving alerts.'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!list.isLoading && list.channels.length > 0 && (
|
||||
<>
|
||||
<div className={styles.list} data-testid="channels-list">
|
||||
{list.channels.map((channel) => (
|
||||
<ChannelListItem
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
onEdit={openEdit}
|
||||
onDelete={deletion.request}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.total > PAGE_SIZE && (
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
align="end"
|
||||
total={list.total}
|
||||
pageSize={PAGE_SIZE}
|
||||
current={list.page}
|
||||
onPageChange={list.setPage}
|
||||
showTotal
|
||||
testId="channels-pagination"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{formView.isDrawerOpen && (
|
||||
<ChannelFormDrawer
|
||||
open={formView.isDrawerOpen}
|
||||
channelId={formView.drawerChannelId}
|
||||
onClose={formView.closeDrawer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteChannelDialog
|
||||
open={!!deletion.channel}
|
||||
channelName={deletion.channel?.displayName ?? ''}
|
||||
isDeleting={deletion.isDeleting}
|
||||
onConfirm={deletion.confirm}
|
||||
onCancel={deletion.cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthZContent(NotificationChannels, {
|
||||
checks: [NotificationChannelListPermission],
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import NotificationChannels from '../NotificationChannels';
|
||||
|
||||
describe('Notification channels list - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('hides the list when list is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('channels-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables creating when create is denied', async () => {
|
||||
server.use(setupAuthzDeny(NotificationChannelCreatePermission));
|
||||
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await screen.findByText('Dummy-Channel');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('channels-create')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the row actions live for an admin', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await screen.findByText('Dummy-Channel');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('channel-edit-3')).not.toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import NotificationChannels from '../NotificationChannels';
|
||||
|
||||
describe('Notification channels list', () => {
|
||||
beforeEach(() => {
|
||||
// the list keeps search, filter and page in the url, so one test's filter
|
||||
// would otherwise still be applied in the next
|
||||
window.history.replaceState({}, '', '/');
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('lists every channel with its kind', async () => {
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await expect(screen.findByText('Dummy-Channel')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
|
||||
expect(screen.getByText('Slack')).toBeInTheDocument();
|
||||
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters server-side as the user types', async () => {
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await screen.findByText('Dummy-Channel');
|
||||
|
||||
await userEvent.type(screen.getByTestId('channels-search'), 'oncall');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Dummy-Channel')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks for confirmation before deleting', async () => {
|
||||
render(<NotificationChannels />);
|
||||
|
||||
await screen.findByText('Dummy-Channel');
|
||||
// the row actions stay disabled until the permission check resolves
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('channel-delete-3')).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('channel-delete-3'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('channel-delete-confirm'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Delete notification channel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
.loadError {
|
||||
margin: var(--spacing-5) 0;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import Spinner from 'components/Spinner';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
|
||||
import { useChannelFormState } from './useChannelFormState';
|
||||
import styles from './ChannelForm.module.scss';
|
||||
|
||||
interface ChannelFormProps {
|
||||
/** Absent when creating. */
|
||||
channelId?: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
formInstance,
|
||||
type,
|
||||
values,
|
||||
setValues,
|
||||
onTypeChange,
|
||||
onSave,
|
||||
onTest,
|
||||
isSaving,
|
||||
isTesting,
|
||||
isLoading,
|
||||
loadError,
|
||||
} = useChannelFormState({ channelId, onDone });
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip={t('loading_channels_message')} />;
|
||||
}
|
||||
|
||||
// A channel written through the v1 API can carry a configuration this API
|
||||
// does not model, several notifier configs on one channel for instance.
|
||||
if (loadError) {
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
title={loadError}
|
||||
className={styles.loadError}
|
||||
testId="channel-load-error"
|
||||
>
|
||||
This channel carries a configuration this form cannot represent, because it
|
||||
was written through the v1 API. Recreate it here, or keep editing it through
|
||||
that API.
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormAlertChannels
|
||||
formInstance={formInstance}
|
||||
type={type}
|
||||
setSelectedConfig={setValues}
|
||||
onTypeChangeHandler={onTypeChange}
|
||||
onTestHandler={onTest}
|
||||
onSaveHandler={onSave}
|
||||
savingState={isSaving}
|
||||
testingState={isTesting}
|
||||
title={channelId ? t('page_title_edit') : t('page_title_create')}
|
||||
initialValue={{ type, ...values }}
|
||||
editing={!!channelId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelForm;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Form, FormInstance } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateGetNotificationChannel,
|
||||
invalidateListNotificationChannels,
|
||||
useCreateNotificationChannel,
|
||||
useGetNotificationChannel,
|
||||
useTestNotificationChannel,
|
||||
useUpdateNotificationChannel,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import {
|
||||
toPostableChannel,
|
||||
toTestableChannel,
|
||||
toUpdatableChannel,
|
||||
} from 'container/CreateAlertChannels/channelConfig';
|
||||
import { toChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelType,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import { ChannelInitialConfig } from 'container/CreateAlertChannels/defaults';
|
||||
import { validateChannel } from 'container/CreateAlertChannels/validation';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
export interface ChannelFormState {
|
||||
formInstance: FormInstance;
|
||||
type: ChannelType;
|
||||
values: ChannelFormValues;
|
||||
setValues: React.Dispatch<React.SetStateAction<ChannelFormValues>>;
|
||||
onTypeChange: (value: string) => void;
|
||||
onSave: (type: ChannelType) => Promise<void>;
|
||||
onTest: (type: ChannelType) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isTesting: boolean;
|
||||
isLoading: boolean;
|
||||
/** A channel the v2 API cannot model, or one that no longer exists. */
|
||||
loadError: string | null;
|
||||
}
|
||||
|
||||
interface UseChannelFormStateArgs {
|
||||
channelId?: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function useChannelFormState({
|
||||
channelId,
|
||||
onDone,
|
||||
}: UseChannelFormStateArgs): ChannelFormState {
|
||||
const { t } = useTranslation('channels');
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
const isEditing = !!channelId;
|
||||
|
||||
const [type, setType] = useState<ChannelType>(ChannelType.Slack);
|
||||
const [values, setValues] = useState<ChannelFormValues>(() => ({
|
||||
send_resolved: true,
|
||||
...ChannelInitialConfig[ChannelType.Slack],
|
||||
}));
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error: loadFailure,
|
||||
} = useGetNotificationChannel(
|
||||
{ id: channelId ?? '' },
|
||||
{ query: { enabled: isEditing } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data) {
|
||||
return;
|
||||
}
|
||||
const loaded = toChannelFormState(data.data);
|
||||
setType(loaded.type);
|
||||
setValues(loaded.values);
|
||||
}, [data]);
|
||||
|
||||
const { mutateAsync: createChannel, isLoading: isCreating } =
|
||||
useCreateNotificationChannel();
|
||||
const { mutateAsync: updateChannel, isLoading: isUpdating } =
|
||||
useUpdateNotificationChannel();
|
||||
const { mutateAsync: testChannel, isLoading: isTesting } =
|
||||
useTestNotificationChannel();
|
||||
|
||||
const onTypeChange = 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];
|
||||
setValues((current) => ({ ...current, ...defaults }));
|
||||
formInstance.setFieldsValue(defaults);
|
||||
},
|
||||
[type, formInstance],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
async (channelType: ChannelType): Promise<void> => {
|
||||
const validationError = validateChannel(channelType, values, t);
|
||||
if (validationError) {
|
||||
notifications.error({ message: 'Error', description: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
await updateChannel({
|
||||
pathParams: { id: channelId as string },
|
||||
data: toUpdatableChannel(channelType, values),
|
||||
});
|
||||
await invalidateGetNotificationChannel(queryClient, {
|
||||
id: channelId as string,
|
||||
});
|
||||
} else {
|
||||
await createChannel({ data: toPostableChannel(channelType, values) });
|
||||
}
|
||||
|
||||
await invalidateListNotificationChannels(queryClient);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: isEditing
|
||||
? t('channel_edit_done')
|
||||
: t('channel_creation_done'),
|
||||
});
|
||||
void logEvent('Alert Channel: Save channel', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: values.send_resolved,
|
||||
name: values.name,
|
||||
new: isEditing ? 'false' : 'true',
|
||||
status: 'success',
|
||||
});
|
||||
onDone();
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
void logEvent('Alert Channel: Save channel', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: values.send_resolved,
|
||||
name: values.name,
|
||||
new: isEditing ? 'false' : 'true',
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
values,
|
||||
t,
|
||||
notifications,
|
||||
isEditing,
|
||||
updateChannel,
|
||||
channelId,
|
||||
queryClient,
|
||||
createChannel,
|
||||
onDone,
|
||||
showErrorModal,
|
||||
],
|
||||
);
|
||||
|
||||
const onTest = useCallback(
|
||||
async (channelType: ChannelType): Promise<void> => {
|
||||
const validationError = validateChannel(channelType, values, t);
|
||||
if (validationError) {
|
||||
notifications.error({ message: 'Error', description: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await testChannel({ data: toTestableChannel(channelType, values) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_test_done'),
|
||||
});
|
||||
void logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
name: values.name,
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
void logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
name: values.name,
|
||||
status: 'Test failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
[values, t, notifications, testChannel, showErrorModal],
|
||||
);
|
||||
|
||||
return {
|
||||
formInstance,
|
||||
type,
|
||||
values,
|
||||
setValues,
|
||||
onTypeChange,
|
||||
onSave,
|
||||
onTest,
|
||||
isSaving: isCreating || isUpdating,
|
||||
isTesting,
|
||||
isLoading: isEditing && isLoading,
|
||||
loadError: loadFailure
|
||||
? toAPIError(
|
||||
loadFailure as ErrorType<RenderErrorResponseDTO>,
|
||||
).getErrorMessage()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.body {
|
||||
padding: var(--spacing-1) 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import ChannelForm from '../ChannelForm/ChannelForm';
|
||||
import styles from './ChannelFormDrawer.module.scss';
|
||||
|
||||
interface ChannelFormDrawerProps {
|
||||
open: boolean;
|
||||
/** Absent when creating. */
|
||||
channelId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The same form the standalone route renders, hosted over the list so the
|
||||
// filters and page the user was on survive a create or an edit.
|
||||
function ChannelFormDrawer({
|
||||
open,
|
||||
channelId,
|
||||
onClose,
|
||||
}: ChannelFormDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={channelId ? 'Edit notification channel' : 'New notification channel'}
|
||||
width="wide"
|
||||
testId="channel-form-drawer"
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<ChannelForm channelId={channelId} onDone={onClose} />
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelFormDrawer;
|
||||
@@ -0,0 +1,51 @@
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.kind {
|
||||
flex: 0 0 auto;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--periscope-font-size-normal);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
flex: 0 0 auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
|
||||
.item:hover &,
|
||||
.item:focus-within & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotificationChannelPermissions } from 'hooks/notificationChannels/useNotificationChannelPermissions';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
|
||||
import { CHANNEL_KIND_META } from '../../constants';
|
||||
import { getRelativeTime } from '../../utils';
|
||||
import styles from './ChannelListItem.module.scss';
|
||||
|
||||
interface ChannelListItemProps {
|
||||
channel: AlertmanagertypesListedNotificationChannelDTO;
|
||||
onEdit: (channel: AlertmanagertypesListedNotificationChannelDTO) => void;
|
||||
onDelete: (channel: AlertmanagertypesListedNotificationChannelDTO) => void;
|
||||
}
|
||||
|
||||
function ChannelListItem({
|
||||
channel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ChannelListItemProps): JSX.Element {
|
||||
const { editChecks, deletePermission } = useNotificationChannelPermissions(
|
||||
channel.id,
|
||||
);
|
||||
const kindMeta = CHANNEL_KIND_META[channel.kind];
|
||||
|
||||
return (
|
||||
<div className={styles.item} data-testid={`channel-row-${channel.id}`}>
|
||||
<Badge color={kindMeta.color} variant="outline" className={styles.kind}>
|
||||
{kindMeta.label}
|
||||
</Badge>
|
||||
|
||||
<div className={styles.identity}>
|
||||
<Typography.Text className={styles.name} truncate={1}>
|
||||
{channel.displayName}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.meta} color="muted">
|
||||
{`Updated ${getRelativeTime(channel.updatedAt)} · created ${getRelativeTime(
|
||||
channel.createdAt,
|
||||
)}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<AuthZTooltip checks={editChecks}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={`Edit ${channel.displayName}`}
|
||||
onClick={(): void => onEdit(channel)}
|
||||
testId={`channel-edit-${channel.id}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={[deletePermission]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
aria-label={`Delete ${channel.displayName}`}
|
||||
onClick={(): void => onDelete(channel)}
|
||||
testId={`channel-delete-${channel.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelListItem;
|
||||
@@ -0,0 +1,17 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-5);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
flex: 0 0 auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { AlertmanagertypesChannelListSortDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
|
||||
import { KIND_FILTER_OPTIONS, SORT_OPTIONS } from '../../constants';
|
||||
import styles from './ChannelsToolbar.module.scss';
|
||||
|
||||
const CREATE_CHECKS = [NotificationChannelCreatePermission];
|
||||
|
||||
interface ChannelsToolbarProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
kind: string;
|
||||
onKindChange: (value: string) => void;
|
||||
sort: AlertmanagertypesChannelListSortDTO;
|
||||
onSortChange: (value: AlertmanagertypesChannelListSortDTO) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function ChannelsToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
kind,
|
||||
onKindChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
onCreate,
|
||||
}: ChannelsToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<Input
|
||||
className={styles.search}
|
||||
placeholder="Search channels..."
|
||||
value={search}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
onSearchChange(event.target.value)
|
||||
}
|
||||
prefix={<Search size={12} />}
|
||||
suffix={
|
||||
search ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label="Clear search"
|
||||
onClick={(): void => onSearchChange('')}
|
||||
testId="channels-search-clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
data-testid="channels-search"
|
||||
/>
|
||||
|
||||
<SelectSimple
|
||||
className={styles.filter}
|
||||
items={KIND_FILTER_OPTIONS}
|
||||
value={kind}
|
||||
onChange={(value): void => onKindChange(value as string)}
|
||||
testId="channels-kind-filter"
|
||||
/>
|
||||
|
||||
<SelectSimple
|
||||
className={styles.filter}
|
||||
items={SORT_OPTIONS}
|
||||
value={sort}
|
||||
onChange={(value): void =>
|
||||
onSortChange(value as AlertmanagertypesChannelListSortDTO)
|
||||
}
|
||||
testId="channels-sort"
|
||||
/>
|
||||
|
||||
<AuthZButton
|
||||
checks={CREATE_CHECKS}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onCreate}
|
||||
testId="channels-create"
|
||||
>
|
||||
New channel
|
||||
</AuthZButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsToolbar;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
interface DeleteChannelDialogProps {
|
||||
open: boolean;
|
||||
channelName: string;
|
||||
isDeleting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Alert rules and routing policies reference a channel by name, so a delete can
|
||||
// silence them. The destructive step stays behind an explicit confirm.
|
||||
function DeleteChannelDialog({
|
||||
open,
|
||||
channelName,
|
||||
isDeleting,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteChannelDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Delete notification channel"
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="channel-delete-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={onConfirm}
|
||||
prefix={<Trash2 size={12} />}
|
||||
testId="channel-delete-confirm"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete <strong>{channelName}</strong>? Alerts routed
|
||||
to it will stop being delivered, and this cannot be undone.
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteChannelDialog;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BadgeColor } from '@signozhq/ui/badge';
|
||||
import {
|
||||
AlertmanagertypesChannelKindDTO,
|
||||
AlertmanagertypesChannelListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const SEARCH_KEY = 'search';
|
||||
export const KIND_KEY = 'kind';
|
||||
export const SORT_KEY = 'sort';
|
||||
export const PAGE_KEY = 'page';
|
||||
/** Opens the create/edit form as a drawer over the list instead of its own page. */
|
||||
export const FORM_VIEW_KEY = 'formView';
|
||||
export const FORM_VIEW_DRAWER = 'drawer';
|
||||
/** Holds the channel the drawer is editing, or `new` while it is creating. */
|
||||
export const DRAWER_CHANNEL_KEY = 'channel';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
interface ChannelKindMeta {
|
||||
label: string;
|
||||
color: BadgeColor;
|
||||
}
|
||||
|
||||
export const CHANNEL_KIND_META: Record<
|
||||
AlertmanagertypesChannelKindDTO,
|
||||
ChannelKindMeta
|
||||
> = {
|
||||
[AlertmanagertypesChannelKindDTO.slack]: { label: 'Slack', color: 'sakura' },
|
||||
[AlertmanagertypesChannelKindDTO.email]: { label: 'Email', color: 'robin' },
|
||||
[AlertmanagertypesChannelKindDTO.webhook]: {
|
||||
label: 'Webhook',
|
||||
color: 'aqua',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.pagerduty]: {
|
||||
label: 'PagerDuty',
|
||||
color: 'forest',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.opsgenie]: {
|
||||
label: 'Opsgenie',
|
||||
color: 'sienna',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.msteams]: {
|
||||
label: 'MS Teams',
|
||||
color: 'robin',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.googlechat]: {
|
||||
label: 'Google Chat',
|
||||
color: 'forest',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.jira]: { label: 'Jira', color: 'robin' },
|
||||
[AlertmanagertypesChannelKindDTO.jsmops]: {
|
||||
label: 'JSM Ops',
|
||||
color: 'amber',
|
||||
},
|
||||
[AlertmanagertypesChannelKindDTO.incidentio]: {
|
||||
label: 'incident.io',
|
||||
color: 'cherry',
|
||||
},
|
||||
};
|
||||
|
||||
export const KIND_FILTER_ALL = 'all';
|
||||
|
||||
export const KIND_FILTER_OPTIONS = [
|
||||
{ value: KIND_FILTER_ALL, label: 'All types' },
|
||||
...Object.entries(CHANNEL_KIND_META).map(([value, { label }]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
];
|
||||
|
||||
/** Paired with the API's `sort` + `order`, which the list exposes as one control. */
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'updated_at', label: 'Last updated' },
|
||||
{ value: 'created_at', label: 'Recently created' },
|
||||
{ value: 'name', label: 'Name (A–Z)' },
|
||||
];
|
||||
|
||||
export const SORT_ORDER_BY_SORT: Record<string, 'asc' | 'desc'> = {
|
||||
[AlertmanagertypesChannelListSortDTO.updated_at]: 'desc',
|
||||
[AlertmanagertypesChannelListSortDTO.created_at]: 'desc',
|
||||
[AlertmanagertypesChannelListSortDTO.name]: 'asc',
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
invalidateListNotificationChannels,
|
||||
useDeleteNotificationChannel,
|
||||
} from 'api/generated/services/channels';
|
||||
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface ChannelDeleteState {
|
||||
channel: AlertmanagertypesListedNotificationChannelDTO | null;
|
||||
isDeleting: boolean;
|
||||
request: (channel: AlertmanagertypesListedNotificationChannelDTO) => void;
|
||||
cancel: () => void;
|
||||
confirm: () => void;
|
||||
}
|
||||
|
||||
export function useChannelDelete(onDeleted?: () => void): ChannelDeleteState {
|
||||
const [channel, setChannel] =
|
||||
useState<AlertmanagertypesListedNotificationChannelDTO | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { mutate, isLoading } = useDeleteNotificationChannel();
|
||||
|
||||
const confirm = (): void => {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(
|
||||
{ pathParams: { id: channel.id } },
|
||||
{
|
||||
onSuccess: async (): Promise<void> => {
|
||||
setChannel(null);
|
||||
await invalidateListNotificationChannels(queryClient);
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error): void => {
|
||||
setChannel(null);
|
||||
showErrorModal(error as unknown as APIError);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
channel,
|
||||
isDeleting: isLoading,
|
||||
request: setChannel,
|
||||
cancel: (): void => setChannel(null),
|
||||
confirm,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
DRAWER_CHANNEL_KEY,
|
||||
FORM_VIEW_DRAWER,
|
||||
FORM_VIEW_KEY,
|
||||
} from '../constants';
|
||||
|
||||
/** Sentinel for the drawer's create mode, which has no channel id yet. */
|
||||
const DRAWER_CREATE = 'new';
|
||||
|
||||
export interface ChannelFormView {
|
||||
/** True while `?formView=drawer` keeps the form over the list. */
|
||||
isDrawer: boolean;
|
||||
isDrawerOpen: boolean;
|
||||
/** Absent while the drawer is creating rather than editing. */
|
||||
drawerChannelId?: string;
|
||||
openCreate: () => void;
|
||||
openEdit: (channelId: string) => void;
|
||||
closeDrawer: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The create and edit form renders either on its own route or in a drawer over
|
||||
* the list, chosen by `?formView=drawer` so the two can be compared without a
|
||||
* rebuild.
|
||||
*/
|
||||
export function useChannelFormView(): ChannelFormView {
|
||||
const [formView] = useQueryState(FORM_VIEW_KEY, parseAsString.withDefault(''));
|
||||
const [drawerChannel, setDrawerChannel] = useQueryState(
|
||||
DRAWER_CHANNEL_KEY,
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
|
||||
const isDrawer = formView === FORM_VIEW_DRAWER;
|
||||
|
||||
const openCreate = (): void => {
|
||||
if (isDrawer) {
|
||||
void setDrawerChannel(DRAWER_CREATE);
|
||||
return;
|
||||
}
|
||||
history.push(ROUTES.CHANNELS_NEW);
|
||||
};
|
||||
|
||||
const openEdit = (channelId: string): void => {
|
||||
if (isDrawer) {
|
||||
void setDrawerChannel(channelId);
|
||||
return;
|
||||
}
|
||||
history.push(generatePath(ROUTES.CHANNELS_EDIT, { channelId }));
|
||||
};
|
||||
|
||||
return {
|
||||
isDrawer,
|
||||
isDrawerOpen: isDrawer && !!drawerChannel,
|
||||
drawerChannelId:
|
||||
drawerChannel && drawerChannel !== DRAWER_CREATE ? drawerChannel : undefined,
|
||||
openCreate,
|
||||
openEdit,
|
||||
closeDrawer: (): void => {
|
||||
void setDrawerChannel(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useListNotificationChannels } from 'api/generated/services/channels';
|
||||
import {
|
||||
AlertmanagertypesChannelKindDTO,
|
||||
AlertmanagertypesChannelListOrderDTO,
|
||||
AlertmanagertypesChannelListSortDTO,
|
||||
AlertmanagertypesListedNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
|
||||
import {
|
||||
KIND_FILTER_ALL,
|
||||
KIND_KEY,
|
||||
PAGE_KEY,
|
||||
PAGE_SIZE,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_KEY,
|
||||
SORT_KEY,
|
||||
SORT_ORDER_BY_SORT,
|
||||
} from '../constants';
|
||||
|
||||
export interface ChannelListState {
|
||||
channels: AlertmanagertypesListedNotificationChannelDTO[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
kind: string;
|
||||
setKind: (value: string) => void;
|
||||
sort: AlertmanagertypesChannelListSortDTO;
|
||||
setSort: (value: AlertmanagertypesChannelListSortDTO) => void;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
/** True while the user is typing, before the debounced query has caught up. */
|
||||
isSearching: boolean;
|
||||
hasFilters: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search, kind filter, sort and paging all run server-side and live in the URL,
|
||||
* so a filtered list survives a reload and can be shared.
|
||||
*/
|
||||
export function useChannelList(enabled = true): ChannelListState {
|
||||
const [search, setSearchParam] = useQueryState(
|
||||
SEARCH_KEY,
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const [kind, setKindParam] = useQueryState(
|
||||
KIND_KEY,
|
||||
parseAsString.withDefault(KIND_FILTER_ALL),
|
||||
);
|
||||
const [sort, setSortParam] = useQueryState(
|
||||
SORT_KEY,
|
||||
parseAsStringEnum<AlertmanagertypesChannelListSortDTO>(
|
||||
Object.values(AlertmanagertypesChannelListSortDTO),
|
||||
).withDefault(AlertmanagertypesChannelListSortDTO.updated_at),
|
||||
);
|
||||
const [page, setPageParam] = useQueryState(
|
||||
PAGE_KEY,
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
|
||||
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
|
||||
const isSearching = search !== debouncedSearch;
|
||||
|
||||
const { data, isLoading, isError, error, refetch } =
|
||||
useListNotificationChannels(
|
||||
{
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
sort,
|
||||
order: SORT_ORDER_BY_SORT[sort] as AlertmanagertypesChannelListOrderDTO,
|
||||
...(debouncedSearch ? { query: debouncedSearch } : {}),
|
||||
...(kind !== KIND_FILTER_ALL
|
||||
? { kind: kind as AlertmanagertypesChannelKindDTO }
|
||||
: {}),
|
||||
},
|
||||
{ query: { enabled: enabled && !isSearching, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const setSearch = (value: string): void => {
|
||||
void setSearchParam(value || null);
|
||||
void setPageParam(null);
|
||||
};
|
||||
|
||||
const setKind = (value: string): void => {
|
||||
void setKindParam(value === KIND_FILTER_ALL ? null : value);
|
||||
void setPageParam(null);
|
||||
};
|
||||
|
||||
const setSort = (value: AlertmanagertypesChannelListSortDTO): void => {
|
||||
void setSortParam(
|
||||
value === AlertmanagertypesChannelListSortDTO.updated_at ? null : value,
|
||||
);
|
||||
void setPageParam(null);
|
||||
};
|
||||
|
||||
return {
|
||||
channels: data?.data?.channels ?? [],
|
||||
total: data?.data?.total ?? 0,
|
||||
isLoading,
|
||||
isError,
|
||||
error: (error as Error) ?? null,
|
||||
refetch,
|
||||
search,
|
||||
setSearch,
|
||||
kind,
|
||||
setKind,
|
||||
sort,
|
||||
setSort,
|
||||
page,
|
||||
setPage: (next: number): void => {
|
||||
void setPageParam(next === 1 ? null : next);
|
||||
},
|
||||
isSearching,
|
||||
hasFilters: Boolean(search) || kind !== KIND_FILTER_ALL,
|
||||
};
|
||||
}
|
||||
11
frontend/src/pages/AlertList/NotificationChannels/utils.ts
Normal file
11
frontend/src/pages/AlertList/NotificationChannels/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const getRelativeTime = (
|
||||
timestamp: string | number | Date | null | undefined,
|
||||
): string => {
|
||||
const parsed = timestamp != null ? dayjs(timestamp) : null;
|
||||
return parsed?.isValid() ? parsed.fromNow() : '-';
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { Tabs, TabsProps } from 'antd';
|
||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AllAlertChannels from 'container/AllAlertChannels';
|
||||
import AllAlertRules from 'container/ListAlertRules';
|
||||
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
||||
import RoutingPolicies from 'container/RoutingPolicies';
|
||||
@@ -16,6 +15,8 @@ import AlertDetails from 'pages/AlertDetails';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import ChannelsNew from 'pages/ChannelsNew';
|
||||
|
||||
import NotificationChannels from './NotificationChannels/NotificationChannels';
|
||||
|
||||
import { AlertListSubTabs, AlertListTabs } from './types';
|
||||
|
||||
import './AlertList.styles.scss';
|
||||
@@ -104,7 +105,7 @@ function AllAlertList(): JSX.Element {
|
||||
<div className="alert-rules-container">
|
||||
{isChannelsNew && <ChannelsNew />}
|
||||
{isChannelsEdit && <ChannelsEdit />}
|
||||
{!isChannelDetails && <AllAlertChannels />}
|
||||
{!isChannelDetails && <NotificationChannels />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,230 +1,43 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import get from 'api/channels/get';
|
||||
import { useGetNotificationChannel } from 'api/generated/services/channels';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import EditAlertChannels from 'container/EditAlertChannels';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
buildNotificationChannelReadPermission,
|
||||
buildNotificationChannelUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
|
||||
|
||||
import './ChannelsEdit.styles.scss';
|
||||
|
||||
function ChannelsEdit(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const channelId = matchPath<{ channelId: string }>(pathname, {
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
})?.params?.channelId;
|
||||
|
||||
const { isFetching, isError, data, error } = useQuery<
|
||||
SuccessResponseV2<Channels>,
|
||||
APIError
|
||||
>(['getChannel', channelId], {
|
||||
queryFn: () =>
|
||||
get({
|
||||
id: channelId || '',
|
||||
}),
|
||||
enabled: !!channelId,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Typography>
|
||||
{error?.getErrorMessage() || t('something_went_wrong')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching || !data?.data) {
|
||||
return <Spinner tip="Loading Channels..." />;
|
||||
}
|
||||
|
||||
const { data: ChannelData } = data.data;
|
||||
|
||||
const value = JSON.parse(ChannelData);
|
||||
|
||||
const prepChannelConfig = (): {
|
||||
type: string;
|
||||
channel: Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>;
|
||||
} => {
|
||||
let channel: Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
> = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
if (value && 'slack_configs' in value) {
|
||||
const slackConfig = value.slack_configs[0];
|
||||
channel = slackConfig;
|
||||
return {
|
||||
type: ChannelType.Slack,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'msteamsv2_configs' in value) {
|
||||
const msteamsConfig = value.msteamsv2_configs[0];
|
||||
channel = msteamsConfig;
|
||||
return {
|
||||
type: ChannelType.MsTeams,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'googlechat_configs' in value) {
|
||||
const [googleChatConfig] = value.googlechat_configs;
|
||||
channel = googleChatConfig;
|
||||
return {
|
||||
type: ChannelType.GoogleChat,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'jira_configs' in value) {
|
||||
const [jiraConfig] = value.jira_configs;
|
||||
channel = jiraConfig;
|
||||
if (jiraConfig.http_config?.basic_auth) {
|
||||
channel.username = jiraConfig.http_config.basic_auth.username;
|
||||
channel.password = jiraConfig.http_config.basic_auth.password;
|
||||
}
|
||||
return {
|
||||
type: ChannelType.Jira,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'pagerduty_configs' in value) {
|
||||
const pagerConfig = value.pagerduty_configs[0];
|
||||
channel = pagerConfig;
|
||||
channel.details = JSON.stringify(pagerConfig.details);
|
||||
channel.detailsArray = { ...pagerConfig.details };
|
||||
return {
|
||||
type: ChannelType.Pagerduty,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'incidentio_configs' in value) {
|
||||
const [incidentIOConfig] = value.incidentio_configs;
|
||||
channel = incidentIOConfig;
|
||||
return {
|
||||
type: ChannelType.IncidentIO,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'jsmops_configs' in value) {
|
||||
const [jsmopsConfig] = value.jsmops_configs;
|
||||
channel = jsmopsConfig;
|
||||
// backend stores tags as a comma-separated string; the form uses chips
|
||||
channel.tags = jsmopsConfig.tags
|
||||
? String(jsmopsConfig.tags)
|
||||
.split(',')
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
type: ChannelType.JsmOps,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'opsgenie_configs' in value) {
|
||||
const opsgenieConfig = value.opsgenie_configs[0];
|
||||
channel = opsgenieConfig;
|
||||
return {
|
||||
type: ChannelType.Opsgenie,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'email_configs' in value) {
|
||||
const emailConfig = value.email_configs[0];
|
||||
channel = emailConfig;
|
||||
return {
|
||||
type: ChannelType.Email,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'webhook_configs' in value) {
|
||||
const webhookConfig = value.webhook_configs[0];
|
||||
channel = webhookConfig;
|
||||
channel.api_url = webhookConfig.url;
|
||||
|
||||
if ('http_config' in webhookConfig) {
|
||||
const httpConfig = webhookConfig.http_config;
|
||||
if ('basic_auth' in httpConfig) {
|
||||
channel.username = webhookConfig.http_config?.basic_auth?.username;
|
||||
channel.password = webhookConfig.http_config?.basic_auth?.password;
|
||||
} else if ('authorization' in httpConfig) {
|
||||
channel.password = webhookConfig.http_config?.authorization?.credentials;
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: ChannelType.Webhook,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: ChannelType.Slack,
|
||||
channel,
|
||||
};
|
||||
};
|
||||
|
||||
const target = prepChannelConfig();
|
||||
// The form runs the same query, so this resolves from the cache rather than
|
||||
// fetching the channel twice.
|
||||
const { data } = useGetNotificationChannel(
|
||||
{ id: channelId ?? '' },
|
||||
{ query: { enabled: !!channelId } },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{ title: value.name || 'Edit Channel', isLast: true },
|
||||
{ title: data?.data?.displayName || 'Edit Channel', isLast: true },
|
||||
]}
|
||||
/>
|
||||
<div className="edit-alert-channels-container">
|
||||
<EditAlertChannels
|
||||
{...{
|
||||
channelId: channelId || '',
|
||||
initialValue: {
|
||||
...target.channel,
|
||||
type: target.type,
|
||||
name: value.name,
|
||||
},
|
||||
<ChannelForm
|
||||
channelId={channelId}
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -232,4 +45,15 @@ function ChannelsEdit(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsEdit;
|
||||
// Editing needs `read` as well as `update`, per the authz guide.
|
||||
export default withAuthZPage(ChannelsEdit, {
|
||||
checks: (_props, router) => {
|
||||
const channelId =
|
||||
router.matchPath<{ channelId: string }>(ROUTES.CHANNELS_EDIT)?.channelId ??
|
||||
'';
|
||||
return [
|
||||
buildNotificationChannelReadPermission(channelId),
|
||||
buildNotificationChannelUpdatePermission(channelId),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import history from 'lib/history';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
function ChannelsNew(): JSX.Element {
|
||||
@@ -14,10 +17,16 @@ function ChannelsNew(): JSX.Element {
|
||||
]}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<CreateAlertChannels preType={ChannelType.Slack} />
|
||||
<ChannelForm
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsNew;
|
||||
export default withAuthZPage(ChannelsNew, {
|
||||
checks: [NotificationChannelCreatePermission],
|
||||
});
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { EmailChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = EmailChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { MsTeamsChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = MsTeamsChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { OpsgenieChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = OpsgenieChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { PagerChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = PagerChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SlackChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = SlackChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { WebhookChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export type Props = WebhookChannel;
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Channels } from './getAll';
|
||||
|
||||
export interface Props {
|
||||
id: Channels['id'];
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
status: string;
|
||||
data: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { EmailChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export interface Props extends EmailChannel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { MsTeamsChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export interface Props extends MsTeamsChannel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { OpsgenieChannel } from 'container/CreateAlertChannels/config';
|
||||
|
||||
export interface Props extends OpsgenieChannel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user