Compare commits
36 Commits
ci/e2e-pul
...
feat/notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21bfef8ff1 | ||
|
|
1a48744147 | ||
|
|
23cab24911 | ||
|
|
018b8f3e4f | ||
|
|
94e809e51a | ||
|
|
8638f56b2d | ||
|
|
c22950adfd | ||
|
|
108b072880 | ||
|
|
709cdc8603 | ||
|
|
3a80a50179 | ||
|
|
af1d36749c | ||
|
|
9d03fa1999 | ||
|
|
6f5efca9b0 | ||
|
|
edad6a2abf | ||
|
|
40ad264918 | ||
|
|
fe4e2c3e98 | ||
|
|
b780eb11b7 | ||
|
|
2457ba1dc9 | ||
|
|
83db520dde | ||
|
|
a829cfab58 | ||
|
|
eb06fca901 | ||
|
|
393b14bb62 | ||
|
|
02ad3d0a05 | ||
|
|
9741734aa9 | ||
|
|
23a1dca192 | ||
|
|
e9284bac49 | ||
|
|
410622bf9f | ||
|
|
337fefa9bc | ||
|
|
782f17864e | ||
|
|
6bcb0c8ba3 | ||
|
|
531504c523 | ||
|
|
b770c249a0 | ||
|
|
6e6d7ebe8e | ||
|
|
aef60bad5d | ||
|
|
7d77577d75 | ||
|
|
f1bf33630f |
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{}
|
||||
|
||||
@@ -8046,6 +8046,31 @@ components:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesIndexMaterializedPathsResult:
|
||||
properties:
|
||||
indexed:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
skipped:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- indexed
|
||||
- skipped
|
||||
type: object
|
||||
PromotetypesPostableIndexMaterializedPaths:
|
||||
properties:
|
||||
dryRun:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
@@ -14072,6 +14097,67 @@ paths:
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths/materialized:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint indexes the JSON sub-columns of materialized paths
|
||||
whose columns are indexed and returns the indexed and skipped paths.
|
||||
operationId: IndexMaterializedPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Index materialized paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -18914,9 +19000,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: Run Infra Monitoring Setup Checks
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -18988,9 +19074,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Clusters for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19072,9 +19158,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List DaemonSets for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19150,9 +19236,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Deployments for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19218,9 +19304,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19300,9 +19386,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19381,9 +19467,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Kubernetes Containers for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19452,9 +19538,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Namespaces for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19527,9 +19613,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19603,9 +19689,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Pods for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19674,9 +19760,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List Volumes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
@@ -19752,9 +19838,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- metrics:read
|
||||
summary: List StatefulSets for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
|
||||
@@ -11,10 +11,13 @@
|
||||
"loading_channels_message": "Loading Channels..",
|
||||
"page_title_create": "New Notification Channel",
|
||||
"page_title_edit": "Edit Notification Channel",
|
||||
"page_title_view": "View Notification Channel",
|
||||
"button_save_channel": "Save",
|
||||
"button_test_channel": "Test",
|
||||
"button_return": "Back",
|
||||
"field_channel_name": "Name",
|
||||
"field_channel_id": "Channel ID",
|
||||
"copy_channel_id": "Copy channel ID",
|
||||
"field_send_resolved": "Send resolved alerts",
|
||||
"field_channel_type": "Type",
|
||||
"field_webhook_url": "Webhook URL",
|
||||
@@ -136,5 +139,7 @@
|
||||
"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",
|
||||
"channel_read_only_title": "You can view this channel but not change it",
|
||||
"channel_read_only_help": "Changing a channel needs update permission on it. Ask an admin to grant it, or open the channel read-only."
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
"loading_channels_message": "Loading Channels..",
|
||||
"page_title_create": "New Notification Channel",
|
||||
"page_title_edit": "Edit Notification Channel",
|
||||
"page_title_view": "View Notification Channel",
|
||||
"button_save_channel": "Save",
|
||||
"button_test_channel": "Test",
|
||||
"button_return": "Back",
|
||||
"field_channel_name": "Name",
|
||||
"field_channel_id": "Channel ID",
|
||||
"copy_channel_id": "Copy channel ID",
|
||||
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
|
||||
"field_send_resolved": "Send resolved alerts",
|
||||
"field_channel_type": "Type",
|
||||
"field_webhook_url": "Webhook URL",
|
||||
@@ -98,6 +102,33 @@
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
"field_slack_title_link": "Title link",
|
||||
"field_slack_color": "Color",
|
||||
"help_slack_color": "good, warning, danger, or a hex value like #439FE0. Templates are allowed.",
|
||||
"placeholder_slack_color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
|
||||
"field_slack_pretext": "Pretext",
|
||||
"help_slack_pretext": "Shown above the attachment block",
|
||||
"field_slack_fallback": "Fallback text",
|
||||
"help_slack_fallback": "Plain text shown where the attachment cannot render, such as push notifications",
|
||||
"field_slack_footer": "Footer",
|
||||
"field_slack_fields": "Fields",
|
||||
"help_slack_fields": "Extra entries rendered as a table inside the attachment",
|
||||
"placeholder_slack_field_title": "Title",
|
||||
"placeholder_slack_field_value": "Value",
|
||||
"field_slack_field_short": "Short",
|
||||
"add_slack_field": "Add field",
|
||||
"remove_slack_field": "Remove field",
|
||||
"field_slack_actions": "Actions",
|
||||
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
|
||||
"placeholder_slack_action_text": "Button text",
|
||||
"placeholder_slack_action_url": "https://runbook.example.com",
|
||||
"placeholder_slack_action_type": "button",
|
||||
"placeholder_slack_action_name": "Name (Slack app callbacks)",
|
||||
"placeholder_slack_action_value": "Value (Slack app callbacks)",
|
||||
"placeholder_slack_action_style": "Style: default, primary or danger",
|
||||
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
|
||||
"add_slack_action": "Add action",
|
||||
"remove_slack_action": "Remove action",
|
||||
"field_opsgenie_api_key": "API Key",
|
||||
"field_opsgenie_description": "Description",
|
||||
"placeholder_opsgenie_description": "Description",
|
||||
@@ -109,6 +140,7 @@
|
||||
"placeholder_email_html": "Email body template",
|
||||
"field_webhook_username": "User Name (optional)",
|
||||
"field_webhook_password": "Password (optional)",
|
||||
"field_webhook_bearer_token": "Bearer token (optional)",
|
||||
"field_pager_routing_key": "Routing Key",
|
||||
"field_pager_description": "Description",
|
||||
"field_pager_severity": "Severity",
|
||||
@@ -133,6 +165,7 @@
|
||||
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
|
||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||
"help_webhook_password": "Specify a password or bearer token",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
|
||||
"help_pager_description": "Shows up as description in pagerduty",
|
||||
"help_opsgenie_message": "Shows up as message in opsgenie",
|
||||
"help_opsgenie_priority": "Priority of the incident",
|
||||
@@ -148,8 +181,12 @@
|
||||
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
|
||||
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
|
||||
"webhook_url_required": "Webhook URL is mandatory",
|
||||
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
|
||||
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
|
||||
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
|
||||
"api_key_required": "API Key is mandatory",
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory"
|
||||
}
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"channel_read_only_title": "You can view this channel but not change it",
|
||||
"channel_read_only_help": "Changing a channel needs update permission on it. Ask an admin to grant it, or open the channel read-only."
|
||||
}
|
||||
|
||||
@@ -1454,10 +1454,9 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
|
||||
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
|
||||
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
|
||||
// route to match last, so a VIEWER is now correctly redirected.
|
||||
it('lets a VIEWER reach /alerts/channels/new, which authz then gates', () => {
|
||||
// CHANNELS_NEW runs on fine-grained authz, so the router no longer decides
|
||||
// on the role: the page's own guard denies when `create` is not granted.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.CHANNELS_NEW,
|
||||
appContext: {
|
||||
@@ -1466,7 +1465,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
|
||||
@@ -1550,6 +1549,11 @@ describe('PrivateRoute', () => {
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
CHANNELS_NEW: { path: ROUTES.CHANNELS_NEW, deniedRoles: DENIED_ROLES },
|
||||
CHANNELS_EDIT: {
|
||||
path: ROUTES.CHANNELS_EDIT.replace(':channelId', 'channel-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
|
||||
DASHBOARD: {
|
||||
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
IndexMaterializedPaths200,
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPostableIndexMaterializedPathsDTO,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -230,3 +232,87 @@ export const usePromotePaths = <
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const indexMaterializedPaths = (
|
||||
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<IndexMaterializedPaths200>({
|
||||
url: `/api/v1/promoted_paths/materialized`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPostableIndexMaterializedPathsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIndexMaterializedPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['indexMaterializedPaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return indexMaterializedPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type IndexMaterializedPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>
|
||||
>;
|
||||
export type IndexMaterializedPathsMutationBody =
|
||||
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
|
||||
| undefined;
|
||||
export type IndexMaterializedPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const useIndexMaterializedPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getIndexMaterializedPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -9878,6 +9878,28 @@ export interface PromotetypesPromotePathDTO {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface PromotetypesIndexMaterializedPathsResultDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
indexed: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
skipped: PromotetypesPromotePathDTO[] | null;
|
||||
}
|
||||
|
||||
export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13250,6 +13272,14 @@ export type ListPromotedPaths200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type IndexMaterializedPaths200 = {
|
||||
data: PromotetypesIndexMaterializedPathsResultDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -61,7 +61,11 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
],
|
||||
values: [
|
||||
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
|
||||
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
|
||||
{
|
||||
timestamp: 2000,
|
||||
value: 12,
|
||||
partial: true,
|
||||
} as unknown as TimeSeriesValue,
|
||||
],
|
||||
} as unknown as TimeSeries,
|
||||
],
|
||||
@@ -104,7 +108,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
labels: { 'service.name': 'adservice' },
|
||||
values: [
|
||||
{ timestamp: 1000, value: '10' },
|
||||
{ timestamp: 2000, value: '12' },
|
||||
{ timestamp: 2000, value: '12', partial: true },
|
||||
],
|
||||
metaData: expect.objectContaining({
|
||||
alias: '__result_0',
|
||||
|
||||
@@ -125,6 +125,7 @@ function convertTimeSeriesData(
|
||||
values: series.values.map((value: any) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: String(value.value),
|
||||
...(value.partial ? { partial: true } : {}),
|
||||
})),
|
||||
metaData: {
|
||||
alias,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
<path fill="#0E9AA7" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
|
||||
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
|
||||
</svg>
|
||||
<svg role="img" viewBox="-2.625 3 28 28" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 451 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="图层_1" x="0" y="0" version="1.1" viewBox="0 0 519.27 214.14"><style>.st2{fill:#a9cc54}</style><path d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1L493.54 5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01" style="fill:#b6de64"/><defs><path id="SVGID_1_" d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1V5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><g style="clip-path:url(#SVGID_2_)"><path d="M-23.16 55.58H428.5v51.1H-23.16zM-23.16 157.59h530.98v72.35H-23.16z" class="st2"/></g><path d="M493.54 184.23c-43.94 0-79.56-33.62-79.56-77.56s35.62-76.56 79.56-76.56" style="fill:none;stroke:#ddf4a4;stroke-width:49;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#37814A" d="M2.303 0A2.298 2.298 0 0 0 0 2.303v19.394A2.298 2.298 0 0 0 2.303 24h19.394A2.298 2.298 0 0 0 24 21.697V2.303A2.298 2.298 0 0 0 21.697 0zm8.177 3.072c4.098 0 7.028 1.438 7.68 1.764l-1.194 2.55c-2.442-1.057-4.993-1.41-5.672-1.41-1.574 0-2.17.922-2.17 1.763v8.494c0 .869.596 1.791 2.17 1.791.679 0 3.23-.38 5.672-1.41l1.194 2.496c-.435.271-3.637 1.818-7.68 1.818-1.112 0-4.64-.244-4.64-4.64V7.713c0-4.397 3.528-4.64 4.64-4.64z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 529 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="lucide/github">
|
||||
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#C0C1C3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#7A8291" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 587 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#A68400" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 398 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#430098" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#7C4DD6" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 463 B |
@@ -1 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LiveKit</title><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#fff"></path></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#7A8291"></path></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 316 B After Width: | Height: | Size: 251 B |
@@ -1 +1 @@
|
||||
<svg width='234' height='42' viewBox='0 0 234 42' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M18 30H6V18H18V30Z' fill='#CFCECD'/><path d='M18 12H6V30H18V12ZM24 36H0V6H24V36Z' fill='#656363'/><path d='M48 30H36V18H48V30Z' fill='#CFCECD'/><path d='M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z' fill='#656363'/><path d='M84 24V30H66V24H84Z' fill='#CFCECD'/><path d='M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z' fill='#656363'/><path d='M108 36H96V18H108V36Z' fill='#CFCECD'/><path d='M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z' fill='#656363'/><path d='M144 30H126V18H144V30Z' fill='#CFCECD'/><path d='M144 12H126V30H144V36H120V6H144V12Z' fill='#211E1E'/><path d='M168 30H156V18H168V30Z' fill='#CFCECD'/><path d='M168 12H156V30H168V12ZM174 36H150V6H174V36Z' fill='#211E1E'/><path d='M198 30H186V18H198V30Z' fill='#CFCECD'/><path d='M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z' fill='#211E1E'/><path d='M234 24V30H216V24H234Z' fill='#CFCECD'/><path d='M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z' fill='#211E1E'/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 137 B |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 376 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" viewBox="0 0 800 600"><path d="M364.7 246.1h16.9L363.2 300h-16.9l-18.5-53.9h17.1l10.2 33h.2zM436 300h-39.4v-53.9h39.2v13.6h-23.6v7.1h20.9V279h-20.9v7.4H436zm41.6-54.8c11.3 0 19.6 5.8 22.1 15.1l-14.7 6.4c-.7-4.2-2.9-7.4-7.6-7.4-5.3 0-9.3 4.4-9.3 14s3.8 14.2 9.3 14.2c4.5 0 6.9-3.4 7.6-7.6l14.7 6.4c-2.5 9.3-10.9 15.1-22.1 15.1-16.2 0-26.1-10.7-26.1-28s9.9-28.2 26.1-28.2m50.3 54.8v-40.3h-16.2v-13.6h48.5v13.6H544V300zm97.7-26.9c0 17.2-10 28-26.1 28-16 0-26.1-10.7-26.1-28s10.2-28 26.1-28c16.1.1 26.1 10.8 26.1 28m-35.4 0c0 8.7 3.3 14.2 9.4 14.2s9.4-5.3 9.4-14.2c0-8.7-3.3-14.2-9.4-14.2s-9.4 5.3-9.4 14.2m84.4 26.9c-1.5-1.8-2-3.4-2.5-6.4l-1.1-6c-.7-3.8-2.5-4.9-5.8-4.9h-6.4V300h-16v-53.9h25.6c12.2 0 20.7 4.7 20.7 16.5 0 7.8-5.3 12.3-11.1 13.4v.2c4.7.7 7.4 3.6 8.5 8.5l2 8.5c.7 2.9 1.6 4.7 3.4 6.7.1.1-17.3.1-17.3.1m-8.2-40.7h-7.8V270h7.8c3.8 0 5.8-1.6 5.8-5.4 0-3.5-1.8-5.3-5.8-5.3m-321.5 81.2c3.3.4 5.6 2.4 5.6 6 0 4.4-2.7 7.3-8.7 7.3h-9.3v-26.1h8.9c5.1 0 8.2 2.4 8.2 6.9 0 3.1-2 5.3-4.7 5.9m-9.1-1.1h5.4c3.3 0 5.1-1.5 5.1-4.5s-1.8-4.5-5.1-4.5h-5.4zm0 11.6h5.8c3.6 0 5.4-1.6 5.4-4.5s-2-4.5-5.4-4.5h-5.8zm28-10.7 7.1-12.7h3.4l-8.9 15.8v10.2h-3.3v-10.2l-9.1-15.8h3.6zm26.7-12.7h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.2 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm36.2-4.3H419l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-10-2.9h8.9l-4.4-12.5zm24.1 10.1v-23.2h-8.7v-2.9h20.9v2.9h-8.7v23.2zm28.3-7.2h-10.9l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-9.8-2.9h8.9l-4.4-12.5zm20.7-16h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.3 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm44.1-10.1c0 8.4-4.5 13.4-11.3 13.4S508 349 508 340.7c0-8.4 4.5-13.4 11.3-13.4s11.3 5 11.3 13.4m-19 0c0 6.4 2.7 10.7 7.8 10.7s7.8-4.4 7.8-10.7c0-6.4-2.7-10.7-7.8-10.7s-7.8 4.3-7.8 10.7m45-.2v13.1H554l-.5-3.8c-.9 2.7-3.8 4.4-7.3 4.4-6.5 0-11.1-4.9-11.1-13.4 0-8.7 4.9-13.6 11.4-13.6 4.7 0 8 2 9.4 6l-2.9 1.6c-.9-3.1-3.4-4.9-6.7-4.9-5.1 0-8 4-8 10.7 0 6.4 2.5 10.7 8 10.7 3.4 0 6.7-1.8 6.7-6.5V343h-6.4v-2.7h9.8z"/><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
|
||||
<svg role="img" viewBox="89.7 190 220 220" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,6 @@
|
||||
.breadcrumb {
|
||||
padding-left: 16px;
|
||||
/* the bar sat flush against the tab strip above it */
|
||||
padding: var(--spacing-4) 16px var(--spacing-2);
|
||||
|
||||
ol {
|
||||
align-items: center;
|
||||
|
||||
@@ -1,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,13 +0,0 @@
|
||||
.create-alert-channels-container {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 3px;
|
||||
|
||||
padding: 16px;
|
||||
|
||||
.form-alert-channels-title {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
148
frontend/src/container/CreateAlertChannels/channelConfig.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { toChannelConfig, toPostableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
|
||||
const slackValues: ChannelFormValues = {
|
||||
name: 'prod alerts',
|
||||
apiUrl: 'https://hooks.slack.com/services/T/B/X',
|
||||
channel: '#alerts',
|
||||
title: 'title template',
|
||||
titleLink: 'https://signoz.io',
|
||||
text: 'body template',
|
||||
pretext: 'pretext',
|
||||
fallback: 'fallback',
|
||||
footer: 'footer',
|
||||
color: 'danger',
|
||||
fields: [{ title: 'env', value: 'prod', short: true }],
|
||||
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
|
||||
sendResolved: true,
|
||||
};
|
||||
|
||||
describe('toChannelConfig', () => {
|
||||
it('sends every field the slack spec models, new ones included', () => {
|
||||
expect(toChannelConfig(ChannelKind.slack, slackValues)).toStrictEqual({
|
||||
kind: 'slack',
|
||||
spec: {
|
||||
apiUrl: 'https://hooks.slack.com/services/T/B/X',
|
||||
channel: '#alerts',
|
||||
title: 'title template',
|
||||
titleLink: 'https://signoz.io',
|
||||
text: 'body template',
|
||||
pretext: 'pretext',
|
||||
fallback: 'fallback',
|
||||
footer: 'footer',
|
||||
color: 'danger',
|
||||
fields: [{ title: 'env', value: 'prod', short: true }],
|
||||
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
|
||||
sendResolved: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops what a user left blank but keeps a false they chose', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.webhook, {
|
||||
url: 'https://example.com/hook',
|
||||
username: '',
|
||||
password: undefined,
|
||||
bearerToken: '',
|
||||
sendResolved: false,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook', sendResolved: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an attachment list the user emptied instead of sending []', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.slack, {
|
||||
...slackValues,
|
||||
fields: [],
|
||||
actions: [],
|
||||
}).spec,
|
||||
).not.toHaveProperty('fields');
|
||||
});
|
||||
|
||||
it.each(Object.values(ChannelKind))('stamps the config as %s', (kind) => {
|
||||
expect(toChannelConfig(kind, slackValues).kind).toBe(kind);
|
||||
});
|
||||
|
||||
it('sends only the selected kind s fields, not another kind s leftovers', () => {
|
||||
const config = toChannelConfig(ChannelKind.msteams, {
|
||||
webhookUrl: 'https://teams.example.com/hook',
|
||||
title: 'shared title',
|
||||
// left over from a kind the user switched away from
|
||||
routingKey: 'pagerduty-key',
|
||||
apiUrl: 'https://hooks.slack.com/services/T/B/X',
|
||||
});
|
||||
|
||||
expect(config.spec).toStrictEqual({
|
||||
webhookUrl: 'https://teams.example.com/hook',
|
||||
title: 'shared title',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPostableChannel', () => {
|
||||
it('lets the api generate the immutable name from the display name', () => {
|
||||
expect(toPostableChannel(ChannelKind.slack, slackValues)).toMatchObject({
|
||||
generateName: true,
|
||||
displayName: 'prod alerts',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the display name out of the spec', () => {
|
||||
expect(
|
||||
toPostableChannel(ChannelKind.slack, slackValues).config.spec,
|
||||
).not.toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toChannelFormState', () => {
|
||||
it('round-trips a channel back into the form it was built from', () => {
|
||||
const channel = {
|
||||
id: '1',
|
||||
name: 'prod-alerts',
|
||||
displayName: 'prod alerts',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
updatedAt: '2026-09-01T00:00:00Z',
|
||||
config: toChannelConfig(ChannelKind.slack, slackValues),
|
||||
} as AlertmanagertypesGettableNotificationChannelDTO;
|
||||
|
||||
const { kind, values } = toChannelFormState(channel);
|
||||
|
||||
expect(kind).toBe(ChannelKind.slack);
|
||||
expect(toChannelConfig(kind, values)).toStrictEqual(channel.config);
|
||||
expect(values.name).toBe('prod alerts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('jira reopen duration', () => {
|
||||
it.each([
|
||||
['72h', '3d'],
|
||||
['1440m', '1d'],
|
||||
['7d', '1w'],
|
||||
['90m', '1h30m'],
|
||||
['3d', '3d'],
|
||||
])('sends %s as %s, the only spelling the api accepts back', (typed, sent) => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, {
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
email: 'a@b.c',
|
||||
apiToken: 'tok',
|
||||
reopenDuration: typed,
|
||||
}).spec,
|
||||
).toMatchObject({ reopenDuration: sent });
|
||||
});
|
||||
|
||||
it('leaves a duration it cannot parse for the validator to reject', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, { reopenDuration: 'tomorrow' }).spec,
|
||||
).toMatchObject({ reopenDuration: 'tomorrow' });
|
||||
});
|
||||
});
|
||||
171
frontend/src/container/CreateAlertChannels/channelConfig.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
AlertmanagertypesChannelConfigDTO,
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelMSTeamsConfigDTO,
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO,
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO,
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
AlertmanagertypesChannelWebhookConfigDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { pick } from 'lodash-es';
|
||||
import { omitBlank } from 'utils/omitBlank';
|
||||
|
||||
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
|
||||
import { canonicalJiraReopenDuration } from './utils';
|
||||
|
||||
/**
|
||||
* The fields each kind sends. `satisfies` ties every entry to that kind's
|
||||
* generated spec, so a key the API does not model fails to compile.
|
||||
* An untouched field is dropped rather than sent empty, because the API
|
||||
* applies its own default only for an absent key.
|
||||
*/
|
||||
const SPEC_FIELDS = {
|
||||
[ChannelKind.slack]: [
|
||||
'apiUrl',
|
||||
'channel',
|
||||
'title',
|
||||
'titleLink',
|
||||
'text',
|
||||
'pretext',
|
||||
'fallback',
|
||||
'footer',
|
||||
'color',
|
||||
'fields',
|
||||
'actions',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelSlackConfigDTO)[],
|
||||
[ChannelKind.webhook]: [
|
||||
'url',
|
||||
'username',
|
||||
'password',
|
||||
'bearerToken',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelWebhookConfigDTO)[],
|
||||
[ChannelKind.email]: [
|
||||
'to',
|
||||
'html',
|
||||
'headers',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelEmailConfigDTO)[],
|
||||
[ChannelKind.pagerduty]: [
|
||||
'routingKey',
|
||||
'client',
|
||||
'clientUrl',
|
||||
'description',
|
||||
'severity',
|
||||
'component',
|
||||
'group',
|
||||
'class',
|
||||
'url',
|
||||
'details',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelPagerdutyConfigDTO)[],
|
||||
[ChannelKind.opsgenie]: [
|
||||
'apiKey',
|
||||
'apiUrl',
|
||||
'message',
|
||||
'description',
|
||||
'source',
|
||||
'priority',
|
||||
'details',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelOpsgenieConfigDTO)[],
|
||||
[ChannelKind.msteams]: [
|
||||
'webhookUrl',
|
||||
'title',
|
||||
'text',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelMSTeamsConfigDTO)[],
|
||||
[ChannelKind.googlechat]: [
|
||||
'webhookUrl',
|
||||
'title',
|
||||
'text',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
|
||||
[ChannelKind.jira]: [
|
||||
'site',
|
||||
'project',
|
||||
'issueType',
|
||||
'email',
|
||||
'apiToken',
|
||||
'summary',
|
||||
'description',
|
||||
'priority',
|
||||
'labels',
|
||||
'resolveTransition',
|
||||
'reopenTransition',
|
||||
'wontFixResolution',
|
||||
'reopenDuration',
|
||||
'customFields',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelJiraConfigDTO)[],
|
||||
[ChannelKind.jsmops]: [
|
||||
'apiKey',
|
||||
'message',
|
||||
'description',
|
||||
'priority',
|
||||
'tags',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelJSMOpsConfigDTO)[],
|
||||
[ChannelKind.incidentio]: [
|
||||
'url',
|
||||
'token',
|
||||
'title',
|
||||
'description',
|
||||
'metadata',
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelIncidentIOConfigDTO)[],
|
||||
} satisfies Record<ChannelKind, readonly (keyof ChannelSpecFormValues)[]>;
|
||||
|
||||
export function toChannelConfig(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesChannelConfigDTO {
|
||||
const spec: Record<string, unknown> = omitBlank(
|
||||
pick(values, SPEC_FIELDS[kind]),
|
||||
);
|
||||
|
||||
// the API answers a read with the duration prometheus would print, and
|
||||
// rejects anything spelled differently
|
||||
if (kind === ChannelKind.jira && typeof spec.reopenDuration === 'string') {
|
||||
spec.reopenDuration = canonicalJiraReopenDuration(spec.reopenDuration);
|
||||
}
|
||||
|
||||
// The generated config type is a union whose every member fixes `kind` to a
|
||||
// single literal, so a plain ChannelKind never matches one. Asserted once
|
||||
// here instead of ten times.
|
||||
return { kind, spec } as unknown as AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export function toPostableChannel(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesPostableNotificationChannelDTO {
|
||||
return {
|
||||
// the API derives the immutable dns1123 name from the display name
|
||||
generateName: true,
|
||||
displayName: values.name ?? '',
|
||||
config: toChannelConfig(kind, values),
|
||||
};
|
||||
}
|
||||
|
||||
export function toUpdatableChannel(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
return { config: toChannelConfig(kind, values) };
|
||||
}
|
||||
|
||||
export function toTestableChannel(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesTestableNotificationChannelDTO {
|
||||
return { config: toChannelConfig(kind, values) };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
|
||||
export interface ChannelFormState {
|
||||
kind: ChannelKind;
|
||||
values: ChannelFormValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form edits the spec the API returns, so loading a channel lifts its
|
||||
* display name alongside that spec rather than remapping field by field.
|
||||
*/
|
||||
export function toChannelFormState(
|
||||
channel: AlertmanagertypesGettableNotificationChannelDTO,
|
||||
): ChannelFormState {
|
||||
return {
|
||||
kind: channel.config.kind as string as ChannelKind,
|
||||
values: {
|
||||
...channel.config.spec,
|
||||
// the API keeps `name` immutable and exposes the editable label separately
|
||||
name: channel.displayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import { ChannelKind } from './types';
|
||||
|
||||
/**
|
||||
* Loading a channel and saving it again without touching anything must send the
|
||||
* API exactly what it returned. A field the form model drops silently loses a
|
||||
* user's configuration on the next save, because the update replaces the whole
|
||||
* config.
|
||||
*/
|
||||
const SPECS: Record<string, Record<string, unknown>> = {
|
||||
slack: {
|
||||
apiUrl: 'https://hooks.slack.com/services/T/B/X',
|
||||
channel: '#ops',
|
||||
title: 'title',
|
||||
titleLink: 'https://signoz.io',
|
||||
text: 'body',
|
||||
pretext: 'pre',
|
||||
fallback: 'fall',
|
||||
footer: 'foot',
|
||||
color: 'danger',
|
||||
fields: [{ title: 'env', value: 'prod', short: true }],
|
||||
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
|
||||
sendResolved: true,
|
||||
},
|
||||
webhook: {
|
||||
url: 'https://example.com/hook',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
bearerToken: 't',
|
||||
sendResolved: true,
|
||||
},
|
||||
email: {
|
||||
to: 'oncall@signoz.io',
|
||||
html: '<p>a</p>',
|
||||
headers: { 'X-Team': 'ops' },
|
||||
sendResolved: true,
|
||||
},
|
||||
pagerduty: {
|
||||
routingKey: 'k',
|
||||
client: 'c',
|
||||
clientUrl: 'https://c',
|
||||
description: 'd',
|
||||
severity: 'critical',
|
||||
component: 'comp',
|
||||
group: 'grp',
|
||||
class: 'cls',
|
||||
url: 'https://events.pagerduty.com',
|
||||
details: { firing: '{{ .Alerts.Firing }}' },
|
||||
sendResolved: true,
|
||||
},
|
||||
opsgenie: {
|
||||
apiKey: 'k',
|
||||
apiUrl: 'https://api.opsgenie.com',
|
||||
message: 'm',
|
||||
description: 'd',
|
||||
source: 's',
|
||||
priority: 'P1',
|
||||
details: { env: 'prod' },
|
||||
sendResolved: true,
|
||||
},
|
||||
msteams: {
|
||||
webhookUrl: 'https://teams',
|
||||
title: 't',
|
||||
text: 'b',
|
||||
sendResolved: true,
|
||||
},
|
||||
googlechat: {
|
||||
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
|
||||
title: 't',
|
||||
text: 'b',
|
||||
sendResolved: true,
|
||||
},
|
||||
jira: {
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
email: 'a@b.c',
|
||||
apiToken: 'tok',
|
||||
summary: 's',
|
||||
description: 'd',
|
||||
priority: 'High',
|
||||
labels: ['one', 'two'],
|
||||
resolveTransition: 'Done',
|
||||
reopenTransition: 'Reopen',
|
||||
wontFixResolution: 'WontFix',
|
||||
// the API only ever returns the canonical spelling
|
||||
reopenDuration: '3d',
|
||||
customFields: { customfield_1: 'v' },
|
||||
sendResolved: true,
|
||||
},
|
||||
jsmops: {
|
||||
apiKey: 'k',
|
||||
message: 'm',
|
||||
description: 'd',
|
||||
priority: 'P2',
|
||||
tags: 'prod,db',
|
||||
sendResolved: true,
|
||||
},
|
||||
incidentio: {
|
||||
url: 'https://api.incident.io/v2/alert_events/http/abc',
|
||||
token: 'tok',
|
||||
title: 't',
|
||||
description: 'd',
|
||||
metadata: { team: 'ops' },
|
||||
sendResolved: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('load then save round trip', () => {
|
||||
it.each(Object.keys(SPECS))('keeps every %s field', (kind) => {
|
||||
const channel = {
|
||||
id: '1',
|
||||
name: `${kind}-channel`,
|
||||
displayName: `${kind} channel`,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
config: { kind, spec: SPECS[kind] },
|
||||
} as unknown as AlertmanagertypesGettableNotificationChannelDTO;
|
||||
|
||||
const { kind: loadedKind, values } = toChannelFormState(channel);
|
||||
const saved = toUpdatableChannel(loadedKind, values);
|
||||
|
||||
expect(loadedKind).toBe(kind as ChannelKind);
|
||||
expect(saved.config).toStrictEqual(channel.config);
|
||||
});
|
||||
|
||||
it('never sends the display name inside the spec', () => {
|
||||
const config = toChannelConfig(ChannelKind.slack, {
|
||||
name: 'prod alerts',
|
||||
apiUrl: 'https://hooks.slack.com/x',
|
||||
});
|
||||
|
||||
expect(config.spec).not.toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO,
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO,
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelKind, ChannelSpecFormValues } from './types';
|
||||
|
||||
// shared by slack and ms teams, both render the same title / description boxes
|
||||
export const SlackInitialConfig: Partial<SlackChannel> = {
|
||||
text: `{{ range .Alerts -}}
|
||||
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
|
||||
{
|
||||
text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||
|
||||
*Summary:* {{ .Annotations.summary }}
|
||||
@@ -26,7 +26,7 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
|
||||
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
{{" "}}(
|
||||
{{- with .CommonLabels.Remove .GroupLabels.Names }}
|
||||
@@ -37,27 +37,29 @@ export const SlackInitialConfig: Partial<SlackChannel> = {
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
|
||||
// which the backend applies when title / text are left empty
|
||||
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
text: `{{ range .Alerts -}}
|
||||
export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChatConfigDTO> =
|
||||
{
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
text: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
|
||||
// summary / description are left empty. The description is markdown here and is
|
||||
// wrapped in the ADF status panel + deep-links server-side.
|
||||
export const JiraInitialConfig: Partial<JiraChannel> = {
|
||||
issue_type: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
|
||||
{
|
||||
issueType: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}
|
||||
@@ -65,10 +67,11 @@ export const JiraInitialConfig: Partial<JiraChannel> = {
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfigDTO> =
|
||||
{
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
{{" "}}(
|
||||
{{- with .CommonLabels.Remove .GroupLabels.Names }}
|
||||
@@ -79,20 +82,21 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
severity: '{{ (index .Alerts 0).Labels.severity }}',
|
||||
client: 'SigNoz Alert Manager',
|
||||
client_url: 'https://enter-signoz-host-n-port-here/alerts',
|
||||
details: JSON.stringify({
|
||||
firing: `{{ .Alerts.Firing | toJson }}`,
|
||||
resolved: `{{ .Alerts.Resolved | toJson }}`,
|
||||
num_firing: '{{ .Alerts.Firing | len }}',
|
||||
num_resolved: '{{ .Alerts.Resolved | len }}',
|
||||
}),
|
||||
};
|
||||
severity: '{{ (index .Alerts 0).Labels.severity }}',
|
||||
client: 'SigNoz Alert Manager',
|
||||
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
|
||||
details: {
|
||||
firing: `{{ .Alerts.Firing | toJson }}`,
|
||||
resolved: `{{ .Alerts.Resolved | toJson }}`,
|
||||
num_firing: '{{ .Alerts.Firing | len }}',
|
||||
num_resolved: '{{ .Alerts.Resolved | len }}',
|
||||
},
|
||||
};
|
||||
|
||||
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: `{{ if gt (len .Alerts.Firing) 0 -}}
|
||||
export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConfigDTO> =
|
||||
{
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: `{{ if gt (len .Alerts.Firing) 0 -}}
|
||||
Alerts Firing:
|
||||
{{ range .Alerts.Firing }}
|
||||
- Message: {{ .Annotations.description }}
|
||||
@@ -114,19 +118,20 @@ export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||
{{ end }} Source: {{ .GeneratorURL }}
|
||||
{{ end }}
|
||||
{{- end }}`,
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
};
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
};
|
||||
|
||||
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
|
||||
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
|
||||
// description are left empty. send_resolved is seeded on so JSM alerts close on
|
||||
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
|
||||
// Opsgenie template mapping severity to P1-P5.
|
||||
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
|
||||
send_resolved: true,
|
||||
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
@@ -140,19 +145,20 @@ export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`,
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
tags: ['signoz-alert'],
|
||||
};
|
||||
priority:
|
||||
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
tags: 'signoz-alert',
|
||||
};
|
||||
|
||||
// mirrors DefaultIncidentIOTitleTemplate / DefaultIncidentIODescriptionTemplate
|
||||
// in pkg/types/alertmanagertypes/incidentio.go, applied by the backend when
|
||||
// title / description are left empty. send_resolved is seeded on so incident.io
|
||||
// alerts resolve with the rule (the backend cannot default it).
|
||||
export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
|
||||
send_resolved: true,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIOConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
@@ -166,11 +172,12 @@ export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
send_resolved: true,
|
||||
html: `<!--
|
||||
export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
html: `<!--
|
||||
Credits: https://github.com/mailgun/transactional-email-templates
|
||||
-->
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
@@ -562,33 +569,20 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
};
|
||||
};
|
||||
|
||||
// prefilled values of every channel type, keyed by type so the form can apply
|
||||
// exactly one set of defaults and swap it when the type changes
|
||||
export const ChannelInitialConfig: Record<
|
||||
ChannelType,
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>
|
||||
> = {
|
||||
[ChannelType.Slack]: SlackInitialConfig,
|
||||
[ChannelType.MsTeams]: SlackInitialConfig,
|
||||
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
|
||||
[ChannelType.Jira]: JiraInitialConfig,
|
||||
[ChannelType.JsmOps]: JsmOpsInitialConfig,
|
||||
[ChannelType.IncidentIO]: IncidentIOInitialConfig,
|
||||
[ChannelType.Pagerduty]: PagerInitialConfig,
|
||||
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
|
||||
[ChannelType.Email]: EmailInitialConfig,
|
||||
[ChannelType.Webhook]: {},
|
||||
};
|
||||
export const ChannelInitialConfig: Record<ChannelKind, ChannelSpecFormValues> =
|
||||
{
|
||||
[ChannelKind.slack]: SlackInitialConfig,
|
||||
[ChannelKind.msteams]: SlackInitialConfig,
|
||||
[ChannelKind.googlechat]: GoogleChatInitialConfig,
|
||||
[ChannelKind.jira]: JiraInitialConfig,
|
||||
[ChannelKind.jsmops]: JsmOpsInitialConfig,
|
||||
[ChannelKind.incidentio]: IncidentIOInitialConfig,
|
||||
[ChannelKind.pagerduty]: PagerInitialConfig,
|
||||
[ChannelKind.opsgenie]: OpsgenieInitialConfig,
|
||||
[ChannelKind.email]: EmailInitialConfig,
|
||||
[ChannelKind.webhook]: {},
|
||||
};
|
||||
|
||||
@@ -1,818 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import createEmail from 'api/channels/createEmail';
|
||||
import createMsTeamsApi from 'api/channels/createMsTeams';
|
||||
import createOpsgenie from 'api/channels/createOpsgenie';
|
||||
import createPagerApi from 'api/channels/createPager';
|
||||
import createSlackApi from 'api/channels/createSlack';
|
||||
import createWebhookApi from 'api/channels/createWebhook';
|
||||
import testEmail from 'api/channels/testEmail';
|
||||
import testMsTeamsApi from 'api/channels/testMsTeams';
|
||||
import testOpsGenie from 'api/channels/testOpsgenie';
|
||||
import testPagerApi from 'api/channels/testPager';
|
||||
import testSlackApi from 'api/channels/testSlack';
|
||||
import testWebhookApi from 'api/channels/testWebhook';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
useCreateChannel,
|
||||
useTestChannel,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
ValidatePagerChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
import { ChannelInitialConfig } from './defaults';
|
||||
import {
|
||||
isChannelType,
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidIncidentIOURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareIncidentIORequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from './utils';
|
||||
|
||||
import './CreateAlertChannels.styles.scss';
|
||||
|
||||
function CreateAlertChannels({
|
||||
preType = ChannelType.Slack,
|
||||
}: CreateAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Alert Channel: Create channel page visited', {});
|
||||
}, []);
|
||||
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>
|
||||
>(() => ({
|
||||
send_resolved: true,
|
||||
...ChannelInitialConfig[preType],
|
||||
}));
|
||||
const [savingState, setSavingState] = useState<boolean>(false);
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutateAsync: createChannel } = useCreateChannel();
|
||||
const { mutateAsync: testChannel } = useTestChannel();
|
||||
|
||||
const [type, setType] = useState<ChannelType>(preType);
|
||||
const onTypeChangeHandler = useCallback(
|
||||
(value: string) => {
|
||||
const nextType = value as ChannelType;
|
||||
if (nextType === type) {
|
||||
return;
|
||||
}
|
||||
|
||||
setType(nextType);
|
||||
|
||||
// the fields the types share (title, text, description) keep the value of
|
||||
// the type that was selected before, so the new type's defaults have to be
|
||||
// written to both the config and the form
|
||||
const defaults = ChannelInitialConfig[nextType];
|
||||
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
|
||||
formInstance.setFieldsValue(defaults);
|
||||
},
|
||||
[type, formInstance],
|
||||
);
|
||||
|
||||
const prepareSlackRequest = useCallback(
|
||||
() => ({
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
channel: selectedConfig?.channel || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onSlackHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createSlackApi(prepareSlackRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
|
||||
|
||||
const prepareWebhookRequest = useCallback(() => {
|
||||
// initial api request without auth params
|
||||
let request: WebhookChannel = {
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
};
|
||||
|
||||
if (selectedConfig?.username !== '' || selectedConfig?.password !== '') {
|
||||
if (selectedConfig?.username !== '') {
|
||||
// if username is not null then password must be passed
|
||||
if (selectedConfig?.password !== '') {
|
||||
request = {
|
||||
...request,
|
||||
username: selectedConfig.username,
|
||||
password: selectedConfig.password,
|
||||
};
|
||||
} else {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('username_no_password'),
|
||||
});
|
||||
}
|
||||
} else if (selectedConfig?.password !== '') {
|
||||
// only password entered, set bearer token
|
||||
request = {
|
||||
...request,
|
||||
username: '',
|
||||
password: selectedConfig.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}, [notifications, t, selectedConfig]);
|
||||
|
||||
const onWebhookHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
const request = prepareWebhookRequest();
|
||||
await createWebhookApi(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.api_url,
|
||||
notifications,
|
||||
t,
|
||||
prepareWebhookRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const preparePagerRequest = useCallback(() => {
|
||||
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
routing_key: selectedConfig?.routing_key || '',
|
||||
client: selectedConfig?.client || '',
|
||||
client_url: selectedConfig?.client_url || '',
|
||||
description: selectedConfig?.description || '',
|
||||
severity: selectedConfig?.severity || '',
|
||||
component: selectedConfig?.component || '',
|
||||
group: selectedConfig?.group || '',
|
||||
class: selectedConfig?.class || '',
|
||||
details: selectedConfig.details || '',
|
||||
detailsArray: JSON.parse(selectedConfig.details || '{}'),
|
||||
};
|
||||
}, [selectedConfig, notifications]);
|
||||
|
||||
const onPagerHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
const request = preparePagerRequest();
|
||||
|
||||
try {
|
||||
if (request) {
|
||||
await createPagerApi(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
}
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [preparePagerRequest, t, notifications, showErrorModal]);
|
||||
|
||||
const prepareOpsgenieRequest = useCallback(
|
||||
() => ({
|
||||
api_key: selectedConfig?.api_key || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
description: selectedConfig?.description || '',
|
||||
message: selectedConfig?.message || '',
|
||||
priority: selectedConfig?.priority || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onOpsgenieHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
await createOpsgenie(prepareOpsgenieRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.api_key,
|
||||
notifications,
|
||||
t,
|
||||
prepareOpsgenieRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const prepareEmailRequest = useCallback(
|
||||
() => ({
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
to: selectedConfig?.to || '',
|
||||
html: selectedConfig?.html || '',
|
||||
headers: selectedConfig?.headers || {},
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onEmailHandler = useCallback(async () => {
|
||||
if (!selectedConfig.to) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('to_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
const request = prepareEmailRequest();
|
||||
await createEmail(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
|
||||
|
||||
const prepareMsTeamsRequest = useCallback(
|
||||
() => ({
|
||||
webhook_url: selectedConfig?.webhook_url || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onMsTeamsHandler = useCallback(async () => {
|
||||
if (!selectedConfig.webhook_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createMsTeamsApi(prepareMsTeamsRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.webhook_url,
|
||||
notifications,
|
||||
t,
|
||||
prepareMsTeamsRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateGoogleChatConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.webhook_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('google_chat_webhook_url_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig.webhook_url, notifications, t]);
|
||||
|
||||
const onGoogleChatHandler = useCallback(async () => {
|
||||
if (!validateGoogleChatConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateGoogleChatConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): boolean => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_site_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_reopen_duration_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig, notifications, t]);
|
||||
|
||||
const onJiraHandler = useCallback(async () => {
|
||||
if (!validateJiraConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [selectedConfig.api_key, notifications, t]);
|
||||
|
||||
const onJsmOpsHandler = useCallback(async () => {
|
||||
if (!validateJsmOpsConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateIncidentIOConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.url || !selectedConfig.token) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('incidentio_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidIncidentIOURL(selectedConfig.url)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('incidentio_url_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig.url, selectedConfig.token, notifications, t]);
|
||||
|
||||
const onIncidentIOHandler = useCallback(async () => {
|
||||
if (!validateIncidentIOConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareIncidentIORequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateIncidentIOConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
if (!selectedConfig.name) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('channel_name_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const functionMapper = {
|
||||
[ChannelType.Slack]: onSlackHandler,
|
||||
[ChannelType.Webhook]: onWebhookHandler,
|
||||
[ChannelType.Pagerduty]: onPagerHandler,
|
||||
[ChannelType.Opsgenie]: onOpsgenieHandler,
|
||||
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||
[ChannelType.Email]: onEmailHandler,
|
||||
[ChannelType.GoogleChat]: onGoogleChatHandler,
|
||||
[ChannelType.Jira]: onJiraHandler,
|
||||
[ChannelType.JsmOps]: onJsmOpsHandler,
|
||||
[ChannelType.IncidentIO]: onIncidentIOHandler,
|
||||
};
|
||||
|
||||
if (isChannelType(value)) {
|
||||
const functionToCall = functionMapper[value as keyof typeof functionMapper];
|
||||
|
||||
if (functionToCall) {
|
||||
const result = await functionToCall();
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: result?.status,
|
||||
statusMessage: result?.statusMessage,
|
||||
});
|
||||
} else {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('selected_channel_invalid'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
onSlackHandler,
|
||||
onWebhookHandler,
|
||||
onPagerHandler,
|
||||
onOpsgenieHandler,
|
||||
onMsTeamsHandler,
|
||||
onEmailHandler,
|
||||
onGoogleChatHandler,
|
||||
onJiraHandler,
|
||||
onJsmOpsHandler,
|
||||
onIncidentIOHandler,
|
||||
notifications,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const performChannelTest = useCallback(
|
||||
async (channelType: ChannelType) => {
|
||||
setTestingState(true);
|
||||
try {
|
||||
let request;
|
||||
switch (channelType) {
|
||||
case ChannelType.Webhook:
|
||||
request = prepareWebhookRequest();
|
||||
await testWebhookApi(request);
|
||||
break;
|
||||
case ChannelType.Slack:
|
||||
request = prepareSlackRequest();
|
||||
await testSlackApi(request);
|
||||
break;
|
||||
case ChannelType.Pagerduty:
|
||||
request = preparePagerRequest();
|
||||
if (request) {
|
||||
await testPagerApi(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.MsTeams:
|
||||
request = prepareMsTeamsRequest();
|
||||
await testMsTeamsApi(request);
|
||||
break;
|
||||
case ChannelType.Opsgenie:
|
||||
request = prepareOpsgenieRequest();
|
||||
await testOpsGenie(request);
|
||||
break;
|
||||
case ChannelType.Email:
|
||||
request = prepareEmailRequest();
|
||||
await testEmail(request);
|
||||
break;
|
||||
case ChannelType.GoogleChat:
|
||||
if (!validateGoogleChatConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.Jira:
|
||||
if (!validateJiraConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.JsmOps:
|
||||
if (!validateJsmOpsConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.IncidentIO:
|
||||
if (!validateIncidentIOConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
|
||||
break;
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('test_unsupported'),
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_test_done'),
|
||||
});
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(
|
||||
error instanceof APIError
|
||||
? error
|
||||
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
|
||||
);
|
||||
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: 'Test failed',
|
||||
});
|
||||
}
|
||||
|
||||
setTestingState(false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
prepareWebhookRequest,
|
||||
t,
|
||||
preparePagerRequest,
|
||||
prepareOpsgenieRequest,
|
||||
prepareSlackRequest,
|
||||
prepareMsTeamsRequest,
|
||||
prepareEmailRequest,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
validateIncidentIOConfig,
|
||||
testChannel,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
const onTestHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
performChannelTest(value);
|
||||
},
|
||||
[performChannelTest],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="create-alert-channels-container">
|
||||
<FormAlertChannels
|
||||
{...{
|
||||
formInstance,
|
||||
onTypeChangeHandler,
|
||||
setSelectedConfig,
|
||||
type,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
savingState,
|
||||
testingState,
|
||||
title: t('page_title_create'),
|
||||
initialValue: {
|
||||
type,
|
||||
...selectedConfig,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateAlertChannelsProps {
|
||||
preType: ChannelType;
|
||||
}
|
||||
|
||||
export default CreateAlertChannels;
|
||||
43
frontend/src/container/CreateAlertChannels/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelKindDTO,
|
||||
AlertmanagertypesChannelMSTeamsConfigDTO,
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO,
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO,
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
AlertmanagertypesChannelWebhookConfigDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** The API's own vocabulary for a channel's kind. */
|
||||
export const ChannelKind = AlertmanagertypesChannelKindDTO;
|
||||
export type ChannelKind = AlertmanagertypesChannelKindDTO;
|
||||
|
||||
/**
|
||||
* Every kind's spec merged into one object, so switching kind mid-form keeps the
|
||||
* fields the kinds share (title, text, description). Each field is typed by the
|
||||
* generated schema, so what is optional here is what the API models as optional.
|
||||
*/
|
||||
export type ChannelSpecFormValues = Partial<
|
||||
AlertmanagertypesChannelSlackConfigDTO &
|
||||
AlertmanagertypesChannelWebhookConfigDTO &
|
||||
AlertmanagertypesChannelEmailConfigDTO &
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO &
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO &
|
||||
AlertmanagertypesChannelMSTeamsConfigDTO &
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO &
|
||||
AlertmanagertypesChannelJiraConfigDTO &
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO &
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* The form edits a spec plus the channel's display name, which the API carries
|
||||
* outside the config.
|
||||
*/
|
||||
export type ChannelFormValues = ChannelSpecFormValues & {
|
||||
name?: string;
|
||||
};
|
||||
@@ -1,23 +1,3 @@
|
||||
import {
|
||||
AlertmanagertypesIncidentIOReceiverConfigDTO,
|
||||
AlertmanagertypesJiraReceiverConfigDTO,
|
||||
AlertmanagertypesJSMOpsReceiverConfigDTO,
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigSecretURLDTO,
|
||||
ModelDurationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
} from './config';
|
||||
|
||||
export const isChannelType = (type: string): type is ChannelType =>
|
||||
Object.values(ChannelType).includes(type as ChannelType);
|
||||
|
||||
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
|
||||
|
||||
// the backend enforces the same two rules, this is only for a nicer error experience
|
||||
@@ -32,22 +12,6 @@ export const isValidGoogleChatWebhookURL = (url: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape
|
||||
export const prepareGoogleChatRequest = (
|
||||
config: Partial<GoogleChatChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => ({
|
||||
name: config.name || '',
|
||||
googlechat_configs: [
|
||||
{
|
||||
// the generated type models go's config.SecretURL as an object, the api takes a string
|
||||
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
|
||||
title: config.title || '',
|
||||
text: config.text || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
@@ -93,85 +57,51 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
return totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJiraRequest = (
|
||||
config: Partial<JiraChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
|
||||
site: config.site || '',
|
||||
project: config.project || '',
|
||||
issue_type: config.issue_type || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
http_config: {
|
||||
basic_auth: {
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
/**
|
||||
* How prometheus `model.Duration` prints itself, which is the only spelling the
|
||||
* API accepts back: years and weeks only when the remainder divides exactly,
|
||||
* then days, hours, minutes, seconds and milliseconds greedily. "72h" prints as
|
||||
* "3d", "7d" as "1w".
|
||||
*/
|
||||
const JIRA_DURATION_FORMAT: { unit: string; ms: number; exact: boolean }[] = [
|
||||
{ unit: 'y', ms: JIRA_DURATION_UNIT_MS.y, exact: true },
|
||||
{ unit: 'w', ms: JIRA_DURATION_UNIT_MS.w, exact: true },
|
||||
{ unit: 'd', ms: JIRA_DURATION_UNIT_MS.d, exact: false },
|
||||
{ unit: 'h', ms: JIRA_DURATION_UNIT_MS.h, exact: false },
|
||||
{ unit: 'm', ms: JIRA_DURATION_UNIT_MS.m, exact: false },
|
||||
{ unit: 's', ms: JIRA_DURATION_UNIT_MS.s, exact: false },
|
||||
{ unit: 'ms', ms: JIRA_DURATION_UNIT_MS.ms, exact: false },
|
||||
];
|
||||
|
||||
if (config.summary) {
|
||||
jira.summary = config.summary;
|
||||
}
|
||||
if (config.description) {
|
||||
jira.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jira.priority = config.priority;
|
||||
}
|
||||
if (config.labels?.length) {
|
||||
jira.labels = config.labels;
|
||||
}
|
||||
if (config.resolve_transition) {
|
||||
jira.resolve_transition = config.resolve_transition;
|
||||
}
|
||||
if (config.reopen_transition) {
|
||||
jira.reopen_transition = config.reopen_transition;
|
||||
}
|
||||
if (config.wont_fix_resolution) {
|
||||
jira.wont_fix_resolution = config.wont_fix_resolution;
|
||||
}
|
||||
if (config.reopen_duration) {
|
||||
// the generated type models go's model.Duration as a number, the api takes a
|
||||
// duration string like "72h"
|
||||
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
|
||||
/**
|
||||
* The API rejects a duration it would print differently, so the user's spelling
|
||||
* is normalised before it is sent. A value it cannot parse is left alone for the
|
||||
* validator to reject.
|
||||
*/
|
||||
export const canonicalJiraReopenDuration = (value: string): string => {
|
||||
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jira_configs: [jira],
|
||||
};
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJsmOpsRequest = (
|
||||
config: Partial<JsmOpsChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
|
||||
api_key: config.api_key || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
};
|
||||
|
||||
if (config.message) {
|
||||
jsmops.message = config.message;
|
||||
let remaining = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
if (config.description) {
|
||||
jsmops.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jsmops.priority = config.priority;
|
||||
}
|
||||
if (config.tags?.length) {
|
||||
// the backend takes a comma-separated string and splits it back
|
||||
jsmops.tags = config.tags.join(',');
|
||||
if (remaining === 0) {
|
||||
return '0s';
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jsmops_configs: [jsmops],
|
||||
};
|
||||
return JIRA_DURATION_FORMAT.reduce((canonical, { unit, ms, exact }) => {
|
||||
if (exact && remaining % ms !== 0) {
|
||||
return canonical;
|
||||
}
|
||||
const count = Math.floor(remaining / ms);
|
||||
if (count === 0) {
|
||||
return canonical;
|
||||
}
|
||||
remaining -= count * ms;
|
||||
return `${canonical}${count}${unit}`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
|
||||
@@ -190,33 +120,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],
|
||||
};
|
||||
};
|
||||
|
||||
105
frontend/src/container/CreateAlertChannels/validation.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidIncidentIOURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
} from './utils';
|
||||
|
||||
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
|
||||
|
||||
const requireWebhookUrl: Validator = (values, t) =>
|
||||
values.webhookUrl ? null : t('webhook_url_required');
|
||||
|
||||
const requireApiKey: Validator = (values, t) =>
|
||||
values.apiKey ? null : t('api_key_required');
|
||||
|
||||
const validateSlack: Validator = (values, t) =>
|
||||
values.apiUrl ? null : t('webhook_url_required');
|
||||
|
||||
const validateWebhook: Validator = (values, t) => {
|
||||
if (!values.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, t) =>
|
||||
values.routingKey ? null : t('routing_key_required');
|
||||
|
||||
const validateEmail: Validator = (values, t) =>
|
||||
values.to ? null : t('to_required');
|
||||
|
||||
const validateGoogleChat: Validator = (values, t) => {
|
||||
if (!values.webhookUrl) {
|
||||
return t('webhook_url_required');
|
||||
}
|
||||
return isValidGoogleChatWebhookURL(values.webhookUrl)
|
||||
? null
|
||||
: t('google_chat_webhook_url_invalid');
|
||||
};
|
||||
|
||||
const validateJira: Validator = (values, t) => {
|
||||
if (
|
||||
!values.site ||
|
||||
!values.email ||
|
||||
!values.apiToken ||
|
||||
!values.project ||
|
||||
!values.issueType
|
||||
) {
|
||||
return t('jira_required_fields');
|
||||
}
|
||||
if (!isValidJiraSiteURL(values.site)) {
|
||||
return t('jira_site_invalid');
|
||||
}
|
||||
if (
|
||||
values.reopenDuration &&
|
||||
!isValidJiraReopenDuration(values.reopenDuration)
|
||||
) {
|
||||
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<ChannelKind, Validator> = {
|
||||
[ChannelKind.slack]: validateSlack,
|
||||
[ChannelKind.webhook]: validateWebhook,
|
||||
[ChannelKind.pagerduty]: validatePagerduty,
|
||||
[ChannelKind.opsgenie]: requireApiKey,
|
||||
[ChannelKind.jsmops]: requireApiKey,
|
||||
[ChannelKind.email]: validateEmail,
|
||||
[ChannelKind.msteams]: requireWebhookUrl,
|
||||
[ChannelKind.googlechat]: validateGoogleChat,
|
||||
[ChannelKind.jira]: validateJira,
|
||||
[ChannelKind.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. Each rule mirrors the
|
||||
* matching `Validate()` in `pkg/types/alertmanagertypes`. Returns the message to
|
||||
* show, or null when the form can be submitted.
|
||||
*/
|
||||
export function validateChannel(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
if (!values.name) {
|
||||
return t('channel_name_required');
|
||||
}
|
||||
|
||||
const validate = VALIDATORS[kind];
|
||||
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';
|
||||
@@ -21,14 +17,11 @@ function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
|
||||
const {
|
||||
data,
|
||||
data: channels,
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refreshChannels,
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
} = useChannelOptions();
|
||||
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertThreshold from '../AlertThreshold';
|
||||
|
||||
const mockChannels: Channels[] = [];
|
||||
const mockChannels: ChannelOption[] = [];
|
||||
const mockRefreshChannels = jest.fn();
|
||||
const mockIsLoadingChannels = false;
|
||||
const mockIsErrorChannels = false;
|
||||
@@ -85,16 +85,17 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
|
||||
// Mock getAllChannels API
|
||||
jest.mock('api/channels/getAll', () => ({
|
||||
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ id: '1', name: 'Email Channel' },
|
||||
{ id: '2', name: 'Slack Channel' },
|
||||
] as Channels[],
|
||||
}),
|
||||
),
|
||||
useChannelOptions: jest.fn(() => ({
|
||||
data: [
|
||||
{ id: '1', name: 'Email Channel' },
|
||||
{ id: '2', name: 'Slack Channel' },
|
||||
] as ChannelOption[],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock alert format categories
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
@@ -57,7 +57,7 @@ const mockThreshold = {
|
||||
color: '#ff0000',
|
||||
};
|
||||
|
||||
const mockChannels: Channels[] = [
|
||||
const mockChannels: ChannelOption[] = [
|
||||
{
|
||||
id: TEST_CONSTANTS.CHANNEL_1,
|
||||
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
import {
|
||||
NotificationSettingsAction,
|
||||
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
|
||||
updateThreshold: UpdateThreshold;
|
||||
removeThreshold: (thresholdId: string) => void;
|
||||
showRemoveButton: boolean;
|
||||
channels: Channels[];
|
||||
channels: ChannelOption[];
|
||||
isLoadingChannels: boolean;
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
channels: Channels[];
|
||||
channels: ChannelOption[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
|
||||
@@ -1,863 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import editEmail from 'api/channels/editEmail';
|
||||
import editMsTeamsApi from 'api/channels/editMsTeams';
|
||||
import editOpsgenie from 'api/channels/editOpsgenie';
|
||||
import editPagerApi from 'api/channels/editPager';
|
||||
import editSlackApi from 'api/channels/editSlack';
|
||||
import editWebhookApi from 'api/channels/editWebhook';
|
||||
import testEmail from 'api/channels/testEmail';
|
||||
import testMsTeamsApi from 'api/channels/testMsTeams';
|
||||
import testOpsgenie from 'api/channels/testOpsgenie';
|
||||
import testPagerApi from 'api/channels/testPager';
|
||||
import testSlackApi from 'api/channels/testSlack';
|
||||
import testWebhookApi from 'api/channels/testWebhook';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
useTestChannel,
|
||||
useUpdateChannelByID,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
ValidatePagerChannel,
|
||||
WebhookChannel,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidIncidentIOURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareIncidentIORequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from 'container/CreateAlertChannels/utils';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
function EditAlertChannels({
|
||||
initialValue,
|
||||
channelId: id,
|
||||
}: EditAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const [formInstance] = Form.useForm();
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>
|
||||
>({
|
||||
...initialValue,
|
||||
});
|
||||
const [savingState, setSavingState] = useState<boolean>(false);
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutateAsync: updateChannel } = useUpdateChannelByID();
|
||||
const { mutateAsync: testChannel } = useTestChannel();
|
||||
|
||||
const notifyError = useCallback(
|
||||
(error: unknown): APIError => {
|
||||
const apiError =
|
||||
error instanceof APIError
|
||||
? error
|
||||
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
|
||||
|
||||
notifications.error({
|
||||
message: apiError.getErrorCode(),
|
||||
description: apiError.getErrorMessage(),
|
||||
});
|
||||
|
||||
return apiError;
|
||||
},
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const [type, setType] = useState<ChannelType>(
|
||||
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||
);
|
||||
|
||||
const onTypeChangeHandler = useCallback((value: string) => {
|
||||
setType(value as ChannelType);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
formInstance.setFieldsValue({
|
||||
...initialValue,
|
||||
});
|
||||
}, [formInstance, initialValue]);
|
||||
|
||||
const prepareSlackRequest = useCallback(
|
||||
() => ({
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
channel: selectedConfig?.channel || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
id,
|
||||
}),
|
||||
[id, selectedConfig],
|
||||
);
|
||||
|
||||
const onSlackEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
|
||||
if (selectedConfig?.api_url === '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: t('webhook_url_required') };
|
||||
}
|
||||
|
||||
try {
|
||||
await editSlackApi(prepareSlackRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareSlackRequest, t, notifications, selectedConfig]);
|
||||
|
||||
const prepareWebhookRequest = useCallback(() => {
|
||||
const { name, username, password } = selectedConfig;
|
||||
return {
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
name: name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
username,
|
||||
password,
|
||||
id,
|
||||
};
|
||||
}, [id, selectedConfig]);
|
||||
|
||||
const onWebhookEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
const { username, password } = selectedConfig;
|
||||
|
||||
const showError = (msg: string): void => {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: msg,
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedConfig?.api_url === '') {
|
||||
showError(t('webhook_url_required'));
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: t('webhook_url_required') };
|
||||
}
|
||||
|
||||
if (username && (!password || password === '')) {
|
||||
showError(t('username_no_password'));
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: t('username_no_password') };
|
||||
}
|
||||
|
||||
try {
|
||||
await editWebhookApi(prepareWebhookRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareWebhookRequest, t, notifications, selectedConfig]);
|
||||
|
||||
const prepareEmailRequest = useCallback(
|
||||
() => ({
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
to: selectedConfig.to || '',
|
||||
html: selectedConfig.html || '',
|
||||
headers: selectedConfig.headers || {},
|
||||
id,
|
||||
}),
|
||||
[id, selectedConfig],
|
||||
);
|
||||
|
||||
const onEmailEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
const request = prepareEmailRequest();
|
||||
|
||||
try {
|
||||
await editEmail(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareEmailRequest, t, notifications]);
|
||||
|
||||
const preparePagerRequest = useCallback(
|
||||
() => ({
|
||||
name: selectedConfig.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
routing_key: selectedConfig.routing_key,
|
||||
client: selectedConfig.client,
|
||||
client_url: selectedConfig.client_url,
|
||||
description: selectedConfig.description,
|
||||
severity: selectedConfig.severity,
|
||||
component: selectedConfig.component,
|
||||
class: selectedConfig.class,
|
||||
group: selectedConfig.group,
|
||||
details: selectedConfig.details,
|
||||
detailsArray: JSON.parse(selectedConfig.details || '{}'),
|
||||
id,
|
||||
}),
|
||||
[id, selectedConfig],
|
||||
);
|
||||
|
||||
const onPagerEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
try {
|
||||
await editPagerApi(preparePagerRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [preparePagerRequest, notifications, selectedConfig, t]);
|
||||
|
||||
const prepareOpsgenieRequest = useCallback(
|
||||
() => ({
|
||||
name: selectedConfig.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
api_key: selectedConfig.api_key || '',
|
||||
message: selectedConfig.message || '',
|
||||
description: selectedConfig.description || '',
|
||||
priority: selectedConfig.priority || '',
|
||||
id,
|
||||
}),
|
||||
[id, selectedConfig],
|
||||
);
|
||||
|
||||
const onOpsgenieEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
|
||||
if (selectedConfig?.api_key === '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: t('api_key_required') };
|
||||
}
|
||||
try {
|
||||
await editOpsgenie(prepareOpsgenieRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
|
||||
|
||||
const prepareMsTeamsRequest = useCallback(
|
||||
() => ({
|
||||
webhook_url: selectedConfig?.webhook_url || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
id,
|
||||
}),
|
||||
[id, selectedConfig],
|
||||
);
|
||||
|
||||
const onMsTeamsEditHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
|
||||
if (selectedConfig?.webhook_url === '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
setSavingState(false);
|
||||
return { status: 'failed', statusMessage: t('webhook_url_required') };
|
||||
}
|
||||
|
||||
try {
|
||||
await editMsTeamsApi(prepareMsTeamsRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage:
|
||||
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
|
||||
|
||||
const validateGoogleChatConfig = useCallback((): string => {
|
||||
if (!selectedConfig?.webhook_url) {
|
||||
return t('webhook_url_required');
|
||||
}
|
||||
|
||||
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
|
||||
return t('google_chat_webhook_url_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onGoogleChatEditHandler = useCallback(async () => {
|
||||
const validationError = validateGoogleChatConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareGoogleChatRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateGoogleChatConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): string => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
return t('jira_required_fields');
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
return t('jira_site_invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
return t('jira_reopen_duration_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJiraEditHandler = useCallback(async () => {
|
||||
const validationError = validateJiraConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJiraRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): string => {
|
||||
if (!selectedConfig.api_key) {
|
||||
return t('api_key_required');
|
||||
}
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onJsmOpsEditHandler = useCallback(async () => {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareJsmOpsRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const validateIncidentIOConfig = useCallback((): string => {
|
||||
if (!selectedConfig.url || !selectedConfig.token) {
|
||||
return t('incidentio_required_fields');
|
||||
}
|
||||
|
||||
if (!isValidIncidentIOURL(selectedConfig.url)) {
|
||||
return t('incidentio_url_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onIncidentIOEditHandler = useCallback(async () => {
|
||||
const validationError = validateIncidentIOConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareIncidentIORequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateIncidentIOConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
let result;
|
||||
if (value === ChannelType.Slack) {
|
||||
result = await onSlackEditHandler();
|
||||
} else if (value === ChannelType.Webhook) {
|
||||
result = await onWebhookEditHandler();
|
||||
} else if (value === ChannelType.Pagerduty) {
|
||||
result = await onPagerEditHandler();
|
||||
} else if (value === ChannelType.MsTeams) {
|
||||
result = await onMsTeamsEditHandler();
|
||||
} else if (value === ChannelType.Opsgenie) {
|
||||
result = await onOpsgenieEditHandler();
|
||||
} else if (value === ChannelType.Email) {
|
||||
result = await onEmailEditHandler();
|
||||
} else if (value === ChannelType.GoogleChat) {
|
||||
result = await onGoogleChatEditHandler();
|
||||
} else if (value === ChannelType.Jira) {
|
||||
result = await onJiraEditHandler();
|
||||
} else if (value === ChannelType.JsmOps) {
|
||||
result = await onJsmOpsEditHandler();
|
||||
} else if (value === ChannelType.IncidentIO) {
|
||||
result = await onIncidentIOEditHandler();
|
||||
}
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'false',
|
||||
status: result?.status,
|
||||
statusMessage: result?.statusMessage,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
onSlackEditHandler,
|
||||
onWebhookEditHandler,
|
||||
onPagerEditHandler,
|
||||
onMsTeamsEditHandler,
|
||||
onOpsgenieEditHandler,
|
||||
onEmailEditHandler,
|
||||
onGoogleChatEditHandler,
|
||||
onJiraEditHandler,
|
||||
onJsmOpsEditHandler,
|
||||
onIncidentIOEditHandler,
|
||||
],
|
||||
);
|
||||
|
||||
const performChannelTest = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async (channelType: ChannelType) => {
|
||||
setTestingState(true);
|
||||
try {
|
||||
let request;
|
||||
switch (channelType) {
|
||||
case ChannelType.Webhook:
|
||||
request = prepareWebhookRequest();
|
||||
await testWebhookApi(request);
|
||||
break;
|
||||
case ChannelType.Slack:
|
||||
request = prepareSlackRequest();
|
||||
await testSlackApi(request);
|
||||
break;
|
||||
case ChannelType.Pagerduty:
|
||||
request = preparePagerRequest();
|
||||
if (request) {
|
||||
await testPagerApi(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.MsTeams:
|
||||
request = prepareMsTeamsRequest();
|
||||
if (request) {
|
||||
await testMsTeamsApi(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.Opsgenie:
|
||||
request = prepareOpsgenieRequest();
|
||||
if (request) {
|
||||
await testOpsgenie(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.Email:
|
||||
request = prepareEmailRequest();
|
||||
if (request) {
|
||||
await testEmail(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.GoogleChat: {
|
||||
const validationError = validateGoogleChatConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.Jira: {
|
||||
const validationError = validateJiraConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.JsmOps: {
|
||||
const validationError = validateJsmOpsConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
case ChannelType.IncidentIO: {
|
||||
const validationError = validateIncidentIOConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('test_unsupported'),
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_test_done'),
|
||||
});
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'false',
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
notifyError(error);
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'false',
|
||||
status: 'Test failed',
|
||||
});
|
||||
}
|
||||
setTestingState(false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
t,
|
||||
notifyError,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
validateIncidentIOConfig,
|
||||
testChannel,
|
||||
prepareWebhookRequest,
|
||||
preparePagerRequest,
|
||||
prepareSlackRequest,
|
||||
prepareMsTeamsRequest,
|
||||
prepareOpsgenieRequest,
|
||||
prepareEmailRequest,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
const onTestHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
performChannelTest(value);
|
||||
},
|
||||
[performChannelTest],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormAlertChannels
|
||||
{...{
|
||||
formInstance,
|
||||
onTypeChangeHandler,
|
||||
setSelectedConfig,
|
||||
type,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
testingState,
|
||||
savingState,
|
||||
title: t('page_title_edit'),
|
||||
initialValue,
|
||||
editing: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditAlertChannelsProps {
|
||||
initialValue: {
|
||||
[x: string]: unknown;
|
||||
};
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export default EditAlertChannels;
|
||||
@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Form } from 'antd';
|
||||
import { EmailChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -43,7 +43,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface EmailFormProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
}
|
||||
|
||||
export default EmailForm;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
|
||||
|
||||
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
@@ -12,7 +12,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="webhook_url"
|
||||
name="webhookUrl"
|
||||
label={t('field_webhook_url')}
|
||||
required
|
||||
rules={[
|
||||
@@ -38,7 +38,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
webhook_url: event.target.value,
|
||||
webhookUrl: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -76,7 +76,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface GoogleChatProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
}
|
||||
|
||||
export default GoogleChat;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
interface MetadataRow {
|
||||
key: string;
|
||||
@@ -23,7 +23,7 @@ function IncidentIOSettings({
|
||||
})),
|
||||
);
|
||||
|
||||
const update = (patch: Partial<IncidentIOChannel>): void =>
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const syncMetadata = (rows: MetadataRow[]): void => {
|
||||
@@ -161,7 +161,7 @@ function IncidentIOSettings({
|
||||
}
|
||||
|
||||
interface IncidentIOProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
initialMetadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { JiraChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import {
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JiraChannel>): void =>
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
@@ -45,49 +45,49 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="resolve_transition"
|
||||
name="resolveTransition"
|
||||
label={t('field_jira_resolve_transition')}
|
||||
help={t('help_jira_resolve_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_resolve_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ resolve_transition: event.target.value })
|
||||
update({ resolveTransition: event.target.value })
|
||||
}
|
||||
data-testid="jira-resolve-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_transition"
|
||||
name="reopenTransition"
|
||||
label={t('field_jira_reopen_transition')}
|
||||
help={t('help_jira_reopen_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ reopen_transition: event.target.value })
|
||||
update({ reopenTransition: event.target.value })
|
||||
}
|
||||
data-testid="jira-reopen-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="wont_fix_resolution"
|
||||
name="wontFixResolution"
|
||||
label={t('field_jira_wont_fix_resolution')}
|
||||
help={t('help_jira_wont_fix_resolution')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_wont_fix_resolution')}
|
||||
onChange={(event): void =>
|
||||
update({ wont_fix_resolution: event.target.value })
|
||||
update({ wontFixResolution: event.target.value })
|
||||
}
|
||||
data-testid="jira-wont-fix-resolution-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopen_duration"
|
||||
name="reopenDuration"
|
||||
label={t('field_jira_reopen_duration')}
|
||||
extra={t('help_jira_reopen_duration')}
|
||||
rules={[
|
||||
@@ -111,7 +111,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_duration')}
|
||||
onChange={(event): void => update({ reopen_duration: event.target.value })}
|
||||
onChange={(event): void => update({ reopenDuration: event.target.value })}
|
||||
data-testid="jira-reopen-duration-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -167,26 +167,26 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
name="email"
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ username: event.target.value })}
|
||||
onChange={(event): void => update({ email: event.target.value })}
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
name="apiToken"
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ password: event.target.value })}
|
||||
onChange={(event): void => update({ apiToken: event.target.value })}
|
||||
data-testid="jira-api-token-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -200,13 +200,13 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="issue_type"
|
||||
name="issueType"
|
||||
label={t('field_jira_issue_type')}
|
||||
help={t('help_jira_issue_type')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ issue_type: event.target.value })}
|
||||
onChange={(event): void => update({ issueType: event.target.value })}
|
||||
data-testid="jira-issue-type-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -250,7 +250,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface JiraProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
}
|
||||
|
||||
export default JiraSettings;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<JsmOpsChannel>): void =>
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
@@ -29,12 +29,17 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
name="tags"
|
||||
label={t('field_jsmops_tags')}
|
||||
help={t('help_jsmops_tags')}
|
||||
// the API takes one comma-separated string, the control edits chips
|
||||
getValueProps={(value: string | undefined): { value: string[] } => ({
|
||||
value: value ? value.split(',') : [],
|
||||
})}
|
||||
normalize={(value: string[]): string => value.join(',')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={t('placeholder_jsmops_tags')}
|
||||
onChange={(value): void => update({ tags: value as string[] })}
|
||||
onChange={(value): void => update({ tags: (value as string[]).join(',') })}
|
||||
data-testid="jsmops-tags-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -60,14 +65,14 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
name="apiKey"
|
||||
label={t('field_jsmops_api_key')}
|
||||
help={t('help_jsmops_api_key')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ api_key: event.target.value })}
|
||||
onChange={(event): void => update({ apiKey: event.target.value })}
|
||||
data-testid="jsmops-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -111,7 +116,7 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface JsmOpsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
}
|
||||
|
||||
export default JsmOpsSettings;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -11,7 +11,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="webhook_url"
|
||||
name="webhookUrl"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -28,7 +28,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
webhook_url: event.target.value,
|
||||
webhookUrl: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -67,7 +67,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
|
||||
interface MsTeamsProps {
|
||||
setSelectedConfig: React.Dispatch<
|
||||
React.SetStateAction<Partial<MsTeamsChannel>>
|
||||
React.SetStateAction<Partial<ChannelSpecFormValues>>
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -21,7 +21,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="api_key"
|
||||
name="apiKey"
|
||||
label={t('field_opsgenie_api_key')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -36,7 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={handleInputChange('api_key')}
|
||||
onChange={handleInputChange('apiKey')}
|
||||
data-testid="opsgenie-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -88,7 +88,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
|
||||
interface OpsgenieFormProps {
|
||||
setSelectedConfig: React.Dispatch<
|
||||
React.SetStateAction<Partial<OpsgenieChannel>>
|
||||
React.SetStateAction<Partial<ChannelSpecFormValues>>
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { PagerChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import PagerDetails from './PagerDetails';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
function PagerForm({
|
||||
setSelectedConfig,
|
||||
initialDetails,
|
||||
}: PagerFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="routing_key"
|
||||
name="routingKey"
|
||||
label={t('field_pager_routing_key')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -29,7 +33,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
routing_key: event.target.value,
|
||||
routingKey: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="pager-routing-key-textbox"
|
||||
@@ -71,22 +75,10 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="details"
|
||||
help={t('help_pager_details')}
|
||||
label={t('field_pager_details')}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
details: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-additional-details-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
<PagerDetails
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialDetails={initialDetails}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
name="component"
|
||||
@@ -151,7 +143,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="client_url"
|
||||
name="clientUrl"
|
||||
help={t('help_pager_client_url')}
|
||||
label={t('field_pager_client_url')}
|
||||
>
|
||||
@@ -159,7 +151,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
client_url: event.target.value,
|
||||
clientUrl: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-client-url-textarea"
|
||||
@@ -170,7 +162,8 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface PagerFormProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
initialDetails?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default PagerForm;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface PagerDetailsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
|
||||
initialDetails?: Record<string, string>;
|
||||
}
|
||||
|
||||
const serialize = (details?: Record<string, string>): string =>
|
||||
details && Object.keys(details).length > 0 ? JSON.stringify(details) : '';
|
||||
|
||||
/**
|
||||
* The API models these details as a map, so the raw JSON the box edits is held
|
||||
* here: a half-typed object is not valid JSON and would not survive a round trip
|
||||
* through the form's parsed value.
|
||||
*/
|
||||
function PagerDetails({
|
||||
setSelectedConfig,
|
||||
initialDetails,
|
||||
}: PagerDetailsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const [text, setText] = useState<string>(() => serialize(initialDetails));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onChange = (value: string): void => {
|
||||
setText(value);
|
||||
|
||||
if (value.trim() === '') {
|
||||
setError(null);
|
||||
setSelectedConfig((config) => ({ ...config, details: {} }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
setError(null);
|
||||
setSelectedConfig((config) => ({ ...config, details: parsed }));
|
||||
} catch {
|
||||
setError(t('pager_details_invalid_json'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
help={error ?? t('help_pager_details')}
|
||||
label={t('field_pager_details')}
|
||||
validateStatus={error ? 'error' : undefined}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={(event): void => onChange(event.target.value)}
|
||||
data-testid="pager-additional-details-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default PagerDetails;
|
||||
@@ -3,17 +3,28 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { SlackChannel } from '../../CreateAlertChannels/config';
|
||||
import {
|
||||
AlertmanagertypesChannelSlackActionDTO,
|
||||
AlertmanagertypesChannelSlackFieldDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
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 (
|
||||
<>
|
||||
<Form.Item
|
||||
name="api_url"
|
||||
name="apiUrl"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -30,7 +41,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
api_url: event.target.value,
|
||||
apiUrl: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -67,6 +78,18 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="titleLink" label={t('field_slack_title_link')}>
|
||||
<Input
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
titleLink: 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>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
}
|
||||
|
||||
export default Slack;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Form } from 'antd';
|
||||
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
interface SlackActionsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
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 })}
|
||||
testId={`slack-action-text-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.url}
|
||||
placeholder={t('placeholder_slack_action_url')}
|
||||
onChange={(event): void => updateRow(index, { url: event.target.value })}
|
||||
testId={`slack-action-url-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.type}
|
||||
placeholder={t('placeholder_slack_action_type')}
|
||||
onChange={(event): void => updateRow(index, { type: event.target.value })}
|
||||
testId={`slack-action-type-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.name ?? ''}
|
||||
placeholder={t('placeholder_slack_action_name')}
|
||||
onChange={(event): void => updateRow(index, { name: event.target.value })}
|
||||
testId={`slack-action-name-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value ?? ''}
|
||||
placeholder={t('placeholder_slack_action_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
testId={`slack-action-value-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.style ?? ''}
|
||||
placeholder={t('placeholder_slack_action_style')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { style: event.target.value })
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
testId={`slack-action-confirm-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_action')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
testId={`slack-action-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => sync([...rows, { ...emptyAction }])}
|
||||
testId="slack-action-add"
|
||||
>
|
||||
{t('add_slack_action')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackActions;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Form } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
interface SlackFieldsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
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 })
|
||||
}
|
||||
testId={`slack-field-title-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t('placeholder_slack_field_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
testId={`slack-field-value-${index}`}
|
||||
/>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={!!row.short}
|
||||
onChange={(checked): void =>
|
||||
updateRow(index, { short: checked === true })
|
||||
}
|
||||
testId={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_field')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
testId={`slack-field-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
sync([...rows, { title: '', value: '', short: false }])
|
||||
}
|
||||
testId="slack-field-add"
|
||||
>
|
||||
{t('add_slack_field')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackFields;
|
||||
@@ -4,7 +4,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Form } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { WebhookChannel } from '../../CreateAlertChannels/config';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
|
||||
function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -12,7 +12,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="api_url"
|
||||
name="url"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -29,7 +29,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
api_url: event.target.value,
|
||||
url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -66,12 +66,28 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bearerToken"
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
bearerToken: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<WebhookChannel>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
}
|
||||
|
||||
export default WebhookSettings;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Form } from 'antd';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
import { act, render, renderHook, screen } from 'tests/test-utils';
|
||||
|
||||
import FormAlertChannels from '..';
|
||||
@@ -18,7 +18,7 @@ describe('FormAlertChannels', () => {
|
||||
render(
|
||||
<FormAlertChannels
|
||||
formInstance={formInstance}
|
||||
type={ChannelType.Slack}
|
||||
type={ChannelKind.slack}
|
||||
setSelectedConfig={jest.fn()}
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
@@ -26,7 +26,7 @@ describe('FormAlertChannels', () => {
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
title="Edit"
|
||||
initialValue={{ name: 'Dummy-Channel', type: ChannelType.Slack }}
|
||||
initialValue={{ name: 'Dummy-Channel', type: ChannelKind.slack }}
|
||||
editing
|
||||
/>,
|
||||
);
|
||||
@@ -34,7 +34,7 @@ describe('FormAlertChannels', () => {
|
||||
expect(sendResolvedSwitch).not.toBeChecked();
|
||||
|
||||
act(() => {
|
||||
formInstance.setFieldsValue({ send_resolved: true });
|
||||
formInstance.setFieldsValue({ sendResolved: true });
|
||||
});
|
||||
|
||||
expect(sendResolvedSwitch).toBeChecked();
|
||||
|
||||
@@ -7,17 +7,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Store } from 'antd/lib/form/interface';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
ChannelKind,
|
||||
ChannelSpecFormValues,
|
||||
} from 'container/CreateAlertChannels/types';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import history from 'lib/history';
|
||||
|
||||
import EmailSettings from './Settings/Email';
|
||||
@@ -44,35 +38,53 @@ function FormAlertChannels({
|
||||
title,
|
||||
initialValue,
|
||||
editing = false,
|
||||
readOnly = false,
|
||||
saveChecks = [],
|
||||
testChecks = [],
|
||||
}: FormAlertChannelsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const renderSettings = (): ReactElement | null => {
|
||||
switch (type) {
|
||||
case ChannelType.Slack:
|
||||
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Webhook:
|
||||
case ChannelKind.slack:
|
||||
return (
|
||||
<SlackSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialFields={
|
||||
(initialValue?.fields as ChannelSpecFormValues['fields']) ?? undefined
|
||||
}
|
||||
initialActions={
|
||||
(initialValue?.actions as ChannelSpecFormValues['actions']) ?? undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
case ChannelKind.webhook:
|
||||
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Pagerduty:
|
||||
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.MsTeams:
|
||||
case ChannelKind.pagerduty:
|
||||
return (
|
||||
<PagerSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialDetails={initialValue?.details as Record<string, string>}
|
||||
/>
|
||||
);
|
||||
case ChannelKind.msteams:
|
||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.GoogleChat:
|
||||
case ChannelKind.googlechat:
|
||||
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Jira:
|
||||
case ChannelKind.jira:
|
||||
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.JsmOps:
|
||||
case ChannelKind.jsmops:
|
||||
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.IncidentIO:
|
||||
case ChannelKind.incidentio:
|
||||
return (
|
||||
<IncidentIOSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialMetadata={initialValue?.metadata as Record<string, string>}
|
||||
/>
|
||||
);
|
||||
case ChannelType.Opsgenie:
|
||||
case ChannelKind.opsgenie:
|
||||
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Email:
|
||||
case ChannelKind.email:
|
||||
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
|
||||
default:
|
||||
return null;
|
||||
@@ -85,8 +97,18 @@ function FormAlertChannels({
|
||||
{title}
|
||||
</Typography.Title>
|
||||
|
||||
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
|
||||
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
|
||||
<Form
|
||||
initialValues={initialValue}
|
||||
layout="vertical"
|
||||
form={formInstance}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('field_channel_name')}
|
||||
labelAlign="left"
|
||||
name="name"
|
||||
extra={editing ? t('help_channel_name_immutable') : undefined}
|
||||
>
|
||||
<Input
|
||||
data-testid="channel-name-textbox"
|
||||
disabled={editing}
|
||||
@@ -102,7 +124,7 @@ function FormAlertChannels({
|
||||
<Form.Item
|
||||
label={t('field_send_resolved')}
|
||||
labelAlign="left"
|
||||
name="send_resolved"
|
||||
name="sendResolved"
|
||||
getValueProps={(value): { value: boolean } => ({ value: !!value })}
|
||||
>
|
||||
<Switch
|
||||
@@ -112,7 +134,7 @@ function FormAlertChannels({
|
||||
onChange={(value): void => {
|
||||
setSelectedConfig((state) => ({
|
||||
...state,
|
||||
send_resolved: value,
|
||||
sendResolved: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -182,25 +204,30 @@ function FormAlertChannels({
|
||||
<Form.Item>{renderSettings()}</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
onClick={(): void => onSaveHandler(type)}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
<AuthZTooltip checks={saveChecks}>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
onClick={(): void => onSaveHandler(type)}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={testChecks}>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
data-testid="return-button"
|
||||
disabled={false}
|
||||
onClick={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
@@ -215,35 +242,30 @@ function FormAlertChannels({
|
||||
|
||||
interface FormAlertChannelsProps {
|
||||
formInstance: FormInstance;
|
||||
type: ChannelType;
|
||||
setSelectedConfig: Dispatch<
|
||||
SetStateAction<
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>
|
||||
>
|
||||
>;
|
||||
onTypeChangeHandler: (value: ChannelType) => void;
|
||||
onSaveHandler: (props: ChannelType) => void;
|
||||
onTestHandler: (props: ChannelType) => void;
|
||||
type: ChannelKind;
|
||||
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
|
||||
onTypeChangeHandler: (value: ChannelKind) => void;
|
||||
onSaveHandler: (props: ChannelKind) => void;
|
||||
onTestHandler: (props: ChannelKind) => void;
|
||||
testingState: boolean;
|
||||
savingState: boolean;
|
||||
title: string;
|
||||
initialValue: Store;
|
||||
// editing indicates if the form is opened in edit mode
|
||||
editing?: boolean;
|
||||
/** The reader has `read` but not `update`, so the fields render disabled. */
|
||||
readOnly?: boolean;
|
||||
/** Gates saving; a denial disables the button and names the missing scope. */
|
||||
saveChecks?: BrandedPermission[];
|
||||
/** Gates a test send, which the API allows with `create`. */
|
||||
testChecks?: BrandedPermission[];
|
||||
}
|
||||
|
||||
FormAlertChannels.defaultProps = {
|
||||
editing: undefined,
|
||||
readOnly: false,
|
||||
saveChecks: [],
|
||||
testChecks: [],
|
||||
};
|
||||
|
||||
export default FormAlertChannels;
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button, Flex, Form, Select, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import getAll from 'api/channels/getAll';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef, Labels } from 'types/api/alerts/def';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -47,18 +43,16 @@ function BasicInfo({
|
||||
}: BasicInfoProps): JSX.Element {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { isLoading, data, error, isError, refetch } = useQuery<
|
||||
SuccessResponseV2<Channels[]>,
|
||||
APIError
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAll(),
|
||||
});
|
||||
const {
|
||||
isLoading,
|
||||
data: channels,
|
||||
error,
|
||||
isError,
|
||||
refetch,
|
||||
} = useChannelOptions();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
|
||||
useState(false);
|
||||
@@ -81,7 +75,7 @@ function BasicInfo({
|
||||
});
|
||||
};
|
||||
|
||||
const noChannels = data?.data?.length === 0;
|
||||
const noChannels = channels.length === 0;
|
||||
const handleCreateNewChannels = useCallback(() => {
|
||||
logEvent('Alert: Create notification channel button clicked', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
@@ -96,7 +90,7 @@ function BasicInfo({
|
||||
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
|
||||
logEvent('Alert: New alert creation page visited', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
numberOfChannels: data?.data?.length,
|
||||
numberOfChannels: channels.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
@@ -230,7 +224,7 @@ function BasicInfo({
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={data?.data || []}
|
||||
channels={channels}
|
||||
isLoading={isLoading}
|
||||
hasError={isError}
|
||||
error={error as APIError}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Select, Spin } from 'antd';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { StyledCreateChannelOption, StyledSelect } from './styles';
|
||||
@@ -16,7 +15,7 @@ export interface ChannelSelectProps {
|
||||
onSelectChannels: (s: string[]) => void;
|
||||
onDropdownOpen: () => void;
|
||||
isLoading: boolean;
|
||||
channels: Channels[];
|
||||
channels: ChannelOption[];
|
||||
hasError: boolean;
|
||||
error: APIError;
|
||||
handleCreateNewChannels: () => void;
|
||||
@@ -53,11 +52,8 @@ function ChannelSelect({
|
||||
});
|
||||
}
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const renderOptions = (): ReactNode[] => {
|
||||
const children: ReactNode[] = [];
|
||||
|
||||
@@ -560,7 +560,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'springboot',
|
||||
label: 'Spring Boot',
|
||||
imgUrl: springbootUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -570,25 +570,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-springboot/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -597,7 +597,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'tomcat',
|
||||
label: 'Tomcat',
|
||||
imgUrl: tomcatUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -607,25 +607,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-tomcat/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -634,7 +634,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'jboss',
|
||||
label: 'JBoss/WildFly',
|
||||
imgUrl: jbossUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -644,25 +644,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-jboss/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-jboss/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -671,7 +671,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'opentelemetry-quarkus',
|
||||
label: 'Quarkus',
|
||||
imgUrl: quarkusUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -681,25 +681,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-quarkus/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -708,7 +708,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'Others',
|
||||
label: 'Others',
|
||||
imgUrl: javaOthersUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -718,25 +718,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-java/',
|
||||
link: '/docs/instrumentation/java/opentelemetry-java/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -872,7 +872,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nodejs-nestjs',
|
||||
label: 'NodeJs/NestJS',
|
||||
imgUrl: nodejsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -882,25 +882,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-javascript/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -909,7 +909,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nextjs',
|
||||
label: 'NextJS',
|
||||
imgUrl: nextjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -919,25 +919,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nextjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -946,7 +946,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'nuxtjs',
|
||||
label: 'NuxtJS',
|
||||
imgUrl: nuxtjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -956,25 +956,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -983,7 +983,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'react-native',
|
||||
label: 'React Native',
|
||||
imgUrl: reactjsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-react-native/',
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-react-native/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1298,7 +1298,7 @@ const onboardingConfigWithLinks = [
|
||||
'resque',
|
||||
],
|
||||
id: 'ruby',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
question: {
|
||||
desc: 'What is your Environment?',
|
||||
type: 'select',
|
||||
@@ -1308,25 +1308,25 @@ const onboardingConfigWithLinks = [
|
||||
key: 'vm',
|
||||
label: 'VM',
|
||||
imgUrl: vmUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes',
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'docker',
|
||||
label: 'Docker',
|
||||
imgUrl: dockerUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
{
|
||||
key: 'windows',
|
||||
label: 'Windows',
|
||||
imgUrl: windowsUrl,
|
||||
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
|
||||
link: '/docs/instrumentation/opentelemetry-ruby/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2292,7 +2292,7 @@ const onboardingConfigWithLinks = [
|
||||
'python structured logging',
|
||||
'python-logs-auto-instrumentation',
|
||||
],
|
||||
link: '/docs/userguide/python-logs-auto-instrumentation/',
|
||||
link: '/docs/logs-management/send-logs/python-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'otel-java-sdk',
|
||||
@@ -2323,7 +2323,7 @@ const onboardingConfigWithLinks = [
|
||||
'Logback',
|
||||
'log4j',
|
||||
],
|
||||
link: '/docs/userguide/collecting_application_logs_otel_sdk_java/',
|
||||
link: '/docs/logs-management/send-logs/java-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'nodejs-logs',
|
||||
@@ -2463,7 +2463,7 @@ const onboardingConfigWithLinks = [
|
||||
'linux',
|
||||
],
|
||||
imgUrl: hostmetricsUrl,
|
||||
link: '/docs/userguide/hostmetrics/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/hostmetrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'k8s-metrics',
|
||||
@@ -2495,7 +2495,7 @@ const onboardingConfigWithLinks = [
|
||||
'telemetry',
|
||||
],
|
||||
imgUrl: kubernetesUrl,
|
||||
link: '/docs/userguide/k8s-metrics/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/k8s-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'docker-container-metrics',
|
||||
@@ -2583,7 +2583,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ec2-metrics-manual',
|
||||
label: 'Manual Setup',
|
||||
imgUrl: ec2Url,
|
||||
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
|
||||
link: '/docs/opentelemetry-collection-agents/vm/install/',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2772,7 +2772,7 @@ const onboardingConfigWithLinks = [
|
||||
'vpc-logs',
|
||||
],
|
||||
imgUrl: vpcUrl,
|
||||
link: '/docs/aws-monitoring/vpc-logs/',
|
||||
link: '/docs/aws-monitoring/vpc/',
|
||||
},
|
||||
{
|
||||
dataSource: 'aws-rds',
|
||||
@@ -3837,7 +3837,7 @@ const onboardingConfigWithLinks = [
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
link: '/docs/llm/opentelemetry-openai-monitoring/',
|
||||
link: '/docs/openai-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'codex-monitoring',
|
||||
@@ -4779,7 +4779,7 @@ const onboardingConfigWithLinks = [
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-flutter/',
|
||||
},
|
||||
{
|
||||
dataSource: 'swift',
|
||||
dataSource: 'ios-swift',
|
||||
label: 'iOS Swift',
|
||||
imgUrl: swiftMonitoringUrl,
|
||||
tags: ['mobile app monitoring'],
|
||||
@@ -4808,6 +4808,7 @@ const onboardingConfigWithLinks = [
|
||||
'traces',
|
||||
'tracing',
|
||||
],
|
||||
id: 'ios-swift',
|
||||
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-swiftui/',
|
||||
},
|
||||
{
|
||||
@@ -5473,7 +5474,7 @@ const onboardingConfigWithLinks = [
|
||||
'telemetry',
|
||||
],
|
||||
id: 'hasura',
|
||||
link: '/docs/integrations/opentelemetry-hasura/',
|
||||
link: '/docs/integrations/outposts/hasura/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opencode-observability',
|
||||
@@ -5935,7 +5936,7 @@ const onboardingConfigWithLinks = [
|
||||
'metrics',
|
||||
'monitoring',
|
||||
],
|
||||
link: '/docs/integrations/flyio/',
|
||||
link: '/docs/integrations/outposts/flyio/',
|
||||
},
|
||||
{
|
||||
dataSource: 'envoy',
|
||||
@@ -6513,7 +6514,7 @@ const onboardingConfigWithLinks = [
|
||||
'tracing',
|
||||
],
|
||||
id: 'openclaw-observability',
|
||||
link: '/docs/openclaw-monitoring/',
|
||||
link: '/docs/openclaw-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'claude-agent-monitoring',
|
||||
@@ -6555,7 +6556,7 @@ const onboardingConfigWithLinks = [
|
||||
'render monitoring',
|
||||
],
|
||||
id: 'render-metrics',
|
||||
link: '/docs/metrics-management/render-metrics/',
|
||||
link: '/docs/integrations/outposts/render/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cert-manager',
|
||||
@@ -6579,7 +6580,7 @@ const onboardingConfigWithLinks = [
|
||||
'tls',
|
||||
],
|
||||
id: 'cert-manager',
|
||||
link: '/docs/infrastructure-monitoring/cert-manager/',
|
||||
link: '/docs/infrastructure-monitoring/user-guides/cert-manager/',
|
||||
},
|
||||
{
|
||||
dataSource: 'pgbouncer',
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -337,6 +337,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'notification-channel',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -430,6 +431,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'notification-channel',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
Cable,
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
@@ -95,6 +96,15 @@ 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:
|
||||
'Destinations for alert notifications, such as Slack, PagerDuty, webhooks, etc.',
|
||||
icon: Cable,
|
||||
selectorPlaceholder:
|
||||
'Type notification channel ID, separate multiple with comma or space',
|
||||
docsAnchor: 'notification-channel',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
|
||||
|
||||
@@ -28,21 +28,13 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
|
||||
updatedBy: 'user2@signoz.io',
|
||||
};
|
||||
|
||||
export const MOCK_CHANNEL_1: Channels = {
|
||||
export const MOCK_CHANNEL_1: ChannelOption = {
|
||||
name: 'Channel 1',
|
||||
created_at: '2021-01-01',
|
||||
data: 'data 1',
|
||||
id: '1',
|
||||
type: 'type 1',
|
||||
updated_at: '2021-01-01',
|
||||
};
|
||||
export const MOCK_CHANNEL_2: Channels = {
|
||||
export const MOCK_CHANNEL_2: ChannelOption = {
|
||||
name: 'Channel 2',
|
||||
created_at: '2021-01-02',
|
||||
data: 'data 2',
|
||||
id: '2',
|
||||
type: 'type 2',
|
||||
updated_at: '2021-01-02',
|
||||
};
|
||||
|
||||
export function getUseRoutingPoliciesMockData(
|
||||
|
||||
@@ -77,12 +77,14 @@ jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('api/channels/getAll', () => ({
|
||||
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): any =>
|
||||
Promise.resolve({
|
||||
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
|
||||
}),
|
||||
useChannelOptions: (): any => ({
|
||||
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
export interface RoutingPolicy {
|
||||
id: string;
|
||||
@@ -62,7 +62,7 @@ export interface RoutingPolicyDetailsProps {
|
||||
routingPolicy: RoutingPolicy | null;
|
||||
closeModal: () => void;
|
||||
mode: PolicyDetailsModalMode;
|
||||
channels: Channels[];
|
||||
channels: ChannelOption[];
|
||||
isErrorChannels: boolean;
|
||||
isLoadingChannels: boolean;
|
||||
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
|
||||
@@ -86,7 +86,7 @@ export interface UseRoutingPoliciesReturn {
|
||||
isErrorRoutingPolicies: boolean;
|
||||
refetchRoutingPolicies: () => void;
|
||||
// Channels
|
||||
channels: Channels[];
|
||||
channels: ChannelOption[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
|
||||
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
|
||||
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
PolicyDetailsModalMode,
|
||||
@@ -83,14 +81,11 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
|
||||
// Channels list
|
||||
const {
|
||||
data,
|
||||
data: channels,
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refetchChannels,
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
} = useChannelOptions();
|
||||
|
||||
const refreshChannels = (): void => {
|
||||
refetchChannels();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
@@ -164,6 +165,27 @@ describe('BarPanel utils', () => {
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareBarPanelConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -89,6 +89,9 @@ export function prepareBarPanelConfig({
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
@@ -185,6 +186,28 @@ describe('TimeSeriesPanel utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes partial timestamps to the series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
],
|
||||
partialTimestamps: [1060],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const series = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: null as unknown as Query,
|
||||
}).getConfig().series?.[1] as ExtendedSeries;
|
||||
|
||||
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
|
||||
@@ -101,6 +101,9 @@ export const prepareUPlotConfig = ({
|
||||
fillMode: widget.fillMode || FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
partialTimestamps: series.partialTimestamps
|
||||
? new Set(series.partialTimestamps)
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
68
frontend/src/hooks/notificationChannels/useChannelOptions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { listNotificationChannels } from 'api/generated/services/channels';
|
||||
import {
|
||||
AlertmanagertypesChannelListOrderDTO,
|
||||
AlertmanagertypesChannelListSortDTO,
|
||||
AlertmanagertypesListedNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
|
||||
|
||||
export interface ChannelOption {
|
||||
id: string;
|
||||
/** The display name, which is what rules and routing policies reference. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type UseChannelOptionsResult = Omit<
|
||||
UseQueryResult<ChannelOption[], Error>,
|
||||
'data'
|
||||
> & {
|
||||
data: ChannelOption[];
|
||||
};
|
||||
|
||||
function fetchPage(
|
||||
offset: number,
|
||||
): ReturnType<typeof listNotificationChannels> {
|
||||
return listNotificationChannels({
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
sort: AlertmanagertypesChannelListSortDTO.name,
|
||||
order: AlertmanagertypesChannelListOrderDTO.asc,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel, for the pickers that let a rule or a policy name one. The list
|
||||
* API is paginated and silently clamps a larger limit, so the first page reports
|
||||
* the total and any remainder is fetched alongside rather than truncating. One
|
||||
* request covers an org with a page's worth of channels or fewer.
|
||||
*/
|
||||
async function fetchAllChannels(): Promise<ChannelOption[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
|
||||
...first.data.channels,
|
||||
...rest.flatMap((page) => page.data.channels),
|
||||
];
|
||||
|
||||
return channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.displayName,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useChannelOptions(): UseChannelOptionsResult {
|
||||
const query = useQuery<ChannelOption[], Error>(
|
||||
CHANNEL_OPTIONS_QUERY_KEY,
|
||||
fetchAllChannels,
|
||||
);
|
||||
|
||||
return { ...query, data: query.data ?? [] };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
NotificationChannelCreatePermission,
|
||||
NotificationChannelListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface NotificationChannelCollectionPermissions {
|
||||
canList: boolean;
|
||||
canCreate: boolean;
|
||||
/** A test send is gated on `create` against the wildcard. */
|
||||
canTest: boolean;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* The check itself failed. Callers should fall open (behave as before authz
|
||||
* and let the API decide) rather than treat an outage as a denial.
|
||||
*/
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
// Module-level so the useQueries identity stays stable across renders.
|
||||
const CHECKS = [
|
||||
NotificationChannelListPermission,
|
||||
NotificationChannelCreatePermission,
|
||||
];
|
||||
|
||||
/** Collection-level notification channel permissions (wildcard selector). */
|
||||
export function useNotificationChannelCollectionPermissions(): NotificationChannelCollectionPermissions {
|
||||
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
|
||||
|
||||
const canCreate = isGranted(NotificationChannelCreatePermission);
|
||||
|
||||
return {
|
||||
canList: isGranted(NotificationChannelListPermission),
|
||||
canCreate,
|
||||
canTest: canCreate,
|
||||
isLoading,
|
||||
hasError: !!error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
buildNotificationChannelDeletePermission,
|
||||
buildNotificationChannelReadPermission,
|
||||
buildNotificationChannelUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface NotificationChannelPermissions {
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
|
||||
canEdit: boolean;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* The check itself failed. Callers should fall open — behave as before authz
|
||||
* and let the API decide — rather than read an outage as a denial.
|
||||
*/
|
||||
hasError: boolean;
|
||||
readPermission: BrandedPermission;
|
||||
updatePermission: BrandedPermission;
|
||||
deletePermission: BrandedPermission;
|
||||
/** `[read, update]`, so a denial names both. */
|
||||
editChecks: BrandedPermission[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-level notification channel permissions. Pass `enabled: false` while
|
||||
* the id is unknown, so no check fires against an empty selector.
|
||||
*/
|
||||
export function useNotificationChannelPermissions(
|
||||
channelId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): NotificationChannelPermissions {
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const { readPermission, updatePermission, deletePermission } = useMemo(
|
||||
() => ({
|
||||
readPermission: buildNotificationChannelReadPermission(channelId),
|
||||
updatePermission: buildNotificationChannelUpdatePermission(channelId),
|
||||
deletePermission: buildNotificationChannelDeletePermission(channelId),
|
||||
}),
|
||||
[channelId],
|
||||
);
|
||||
|
||||
const checks = useMemo(
|
||||
() => [readPermission, updatePermission, deletePermission],
|
||||
[readPermission, updatePermission, deletePermission],
|
||||
);
|
||||
|
||||
const { isGranted, isLoading, error } = useAuthZ(checks, { enabled });
|
||||
|
||||
const canRead = isGranted(readPermission);
|
||||
const canUpdate = isGranted(updatePermission);
|
||||
|
||||
const editChecks = useMemo(
|
||||
() => [readPermission, updatePermission],
|
||||
[readPermission, updatePermission],
|
||||
);
|
||||
|
||||
return {
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete: isGranted(deletePermission),
|
||||
canEdit: canRead && canUpdate,
|
||||
isLoading,
|
||||
hasError: !!error,
|
||||
readPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
editChecks,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export default {
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'notification-channel',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'quick-filter',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { convertNewDataToOld } from '../convertNewDataToOld';
|
||||
|
||||
describe('convertNewDataToOld', () => {
|
||||
it('carries partial values as epoch-second partialTimestamps', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
resultType: 'time_series',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
series: [
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [
|
||||
{ timestamp: 1_000_000, value: '1' },
|
||||
{ timestamp: 1_060_000, value: '0.2', partial: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labels: {},
|
||||
labelsArray: [],
|
||||
values: [{ timestamp: 1_000_000, value: '1' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as MetricRangePayloadV3;
|
||||
|
||||
const [partial, complete] = convertNewDataToOld(payload).data.result;
|
||||
|
||||
expect(partial.values).toStrictEqual([
|
||||
[1000, '1'],
|
||||
[1060, '0.2'],
|
||||
]);
|
||||
expect(partial.partialTimestamps).toStrictEqual([1060]);
|
||||
expect(complete).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ export const convertNewDataToOld = (
|
||||
return [...acc, renderValues];
|
||||
}, []);
|
||||
|
||||
const partialTimestamps = series.values
|
||||
.filter((value) => value.partial)
|
||||
.map((value) => value.timestamp / 1000);
|
||||
|
||||
const result: QueryData = {
|
||||
metric: series.labels,
|
||||
values,
|
||||
queryName: `${item.queryName}`,
|
||||
metaData: series.metaData,
|
||||
...(partialTimestamps.length ? { partialTimestamps } : {}),
|
||||
};
|
||||
|
||||
oldResult.push(result);
|
||||
|
||||
@@ -122,6 +122,27 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
|
||||
const withSeries = (series: unknown[]): uPlot =>
|
||||
Object.assign(createUPlotInstance(0), { series });
|
||||
const partial = { show: true, partialTimestamps: new Set([1]) };
|
||||
|
||||
const { unmount } = renderTooltip({
|
||||
uPlotInstance: withSeries([{}, partial]),
|
||||
});
|
||||
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
|
||||
'(partial)',
|
||||
);
|
||||
unmount();
|
||||
|
||||
renderTooltip({
|
||||
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('uplot-tooltip-header-partial'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the date from the header title for a point on the current day', () => {
|
||||
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
|
||||
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.headerEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.partialLabel {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import type { ExtendedSeries } from '../../../../config/types';
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
@@ -34,7 +35,7 @@ export default function TooltipHeader({
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
const header = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
@@ -46,14 +47,21 @@ export default function TooltipHeader({
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
);
|
||||
return {
|
||||
title: formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
),
|
||||
isPartial: (uPlotInstance.series ?? []).some(
|
||||
(series: ExtendedSeries) =>
|
||||
series.show && series.partialTimestamps?.has(timestamp),
|
||||
),
|
||||
};
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.series,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
@@ -64,10 +72,22 @@ export default function TooltipHeader({
|
||||
className={Styles.headerContainer}
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
{showTooltipHeader && header && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
<span>{header.title}</span>
|
||||
{(header.isPartial || isPinned) && (
|
||||
<span className={Styles.headerEnd}>
|
||||
{header.isPartial && (
|
||||
<span
|
||||
className={Styles.partialLabel}
|
||||
data-testid="uplot-tooltip-header-partial"
|
||||
>
|
||||
(partial)
|
||||
</span>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
|
||||
@@ -81,6 +82,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
|
||||
|
||||
private hasPartialPointsHook = false;
|
||||
|
||||
private tzDate: ((timestamp: number) => Date) | undefined;
|
||||
|
||||
private id: string;
|
||||
@@ -177,6 +180,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
*/
|
||||
addSeries(props: SeriesProps): void {
|
||||
this.series.push(new UPlotSeriesBuilder(props));
|
||||
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
|
||||
this.hasPartialPointsHook = true;
|
||||
this.addHook('draw', partialPointsDrawHook);
|
||||
}
|
||||
}
|
||||
|
||||
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
|
||||
import { reportBarRect } from '../utils/barRect';
|
||||
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
|
||||
import {
|
||||
@@ -218,7 +219,14 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
spanGaps,
|
||||
show = true,
|
||||
metric,
|
||||
partialTimestamps,
|
||||
} = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +254,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(partialTimestamps?.size ? { partialTimestamps } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -358,6 +367,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, effectiveBarMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,6 +382,7 @@ function getBarPathBuilder({
|
||||
builders[barsCfgKey] = barsPathBuilderFactory({
|
||||
size: [barWidthFactor, barMaxWidth],
|
||||
align: barAlignment,
|
||||
each: reportBarRect,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -650,4 +650,20 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
|
||||
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
|
||||
const props: SeriesProps = {
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
};
|
||||
const builder = new UPlotConfigBuilder({ id: 'partial' });
|
||||
builder.addSeries(props);
|
||||
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
|
||||
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
|
||||
@@ -398,4 +399,29 @@ describe('UPlotSeriesBuilder', () => {
|
||||
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes partial timestamps only when there are some', () => {
|
||||
const partialTimestamps = new Set([40]);
|
||||
|
||||
expect(
|
||||
new UPlotSeriesBuilder(createBaseProps({ partialTimestamps })).getConfig(),
|
||||
).toHaveProperty('partialTimestamps', partialTimestamps);
|
||||
expect(
|
||||
new UPlotSeriesBuilder(
|
||||
createBaseProps({ partialTimestamps: new Set() }),
|
||||
).getConfig(),
|
||||
).not.toHaveProperty('partialTimestamps');
|
||||
});
|
||||
|
||||
it('reports bar rects from bar path builders', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Bar, barMaxWidth: 321 }),
|
||||
).getConfig();
|
||||
|
||||
config.paths?.({} as uPlot, 1, 0, 1);
|
||||
|
||||
expect(uPlot.paths.bars).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ each: reportBarRect }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +201,7 @@ export enum FillMode {
|
||||
|
||||
export type ExtendedSeries = Series & {
|
||||
metric?: { [key: string]: string };
|
||||
partialTimestamps?: Set<number>;
|
||||
};
|
||||
|
||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
@@ -229,6 +230,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
|
||||
partialTimestamps?: Set<number>;
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { reportBarRect } from '../../utils/barRect';
|
||||
import { partialPointsDrawHook } from '../partialPointsDrawHook';
|
||||
|
||||
function createPlot(
|
||||
series: Partial<ExtendedSeries>,
|
||||
values: (number | null)[] = [1, 2, 3],
|
||||
): { u: uPlot; ctx: Record<string, jest.Mock> } {
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
arc: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
strokeRect: jest.fn(),
|
||||
};
|
||||
const xValues = [10, 20, 30];
|
||||
const u = {
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
data: [xValues, values],
|
||||
series: [{}, { show: true, stroke: 'red', points: { size: 4 }, ...series }],
|
||||
valToIdx: (value: number): number => xValues.indexOf(value),
|
||||
valToPos: (value: number): number => value,
|
||||
} as unknown as uPlot;
|
||||
return { u, ctx };
|
||||
}
|
||||
|
||||
describe('partialPointsDrawHook', () => {
|
||||
it('draws a dot and a dotted ring on each partial point', () => {
|
||||
const { u, ctx } = createPlot({ partialTimestamps: new Set([30]) });
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 2, 0, 2 * Math.PI);
|
||||
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 5, 0, 2 * Math.PI);
|
||||
expect(ctx.setLineDash).toHaveBeenCalledWith([2, 2]);
|
||||
expect(ctx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips hidden series, null values and timestamps not in the data', () => {
|
||||
const hidden = createPlot({ show: false, partialTimestamps: new Set([30]) });
|
||||
const missing = createPlot({ partialTimestamps: new Set([30]) }, [
|
||||
1,
|
||||
2,
|
||||
null,
|
||||
]);
|
||||
const unknown = createPlot({ partialTimestamps: new Set([40]) });
|
||||
|
||||
[hidden, missing, unknown].forEach(({ u, ctx }) => {
|
||||
partialPointsDrawHook(u);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('draws a dotted outline around a partial bar', () => {
|
||||
const { u, ctx } = createPlot({
|
||||
partialTimestamps: new Set([30]),
|
||||
paths: (plot, seriesIdx, idx) => {
|
||||
reportBarRect(plot, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
partialPointsDrawHook(u);
|
||||
|
||||
expect(ctx.strokeRect).toHaveBeenCalledWith(7, 17, 14, 36);
|
||||
expect(ctx.arc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/uPlotV2/hooks/partialPointsDrawHook.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ExtendedSeries } from '../config/types';
|
||||
import { BarRect, measureBar } from '../utils/barRect';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
const RING_GAP = 3;
|
||||
const RING_WIDTH = 1.5;
|
||||
const RING_DASH = [2, 2];
|
||||
|
||||
function drawRing(
|
||||
u: uPlot,
|
||||
series: ExtendedSeries,
|
||||
idx: number,
|
||||
value: number,
|
||||
): void {
|
||||
const { ctx } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const cx = u.valToPos(u.data[0][idx], 'x', true);
|
||||
const cy = u.valToPos(value, series.scale ?? 'y', true);
|
||||
const radius = ((series.points?.size ?? DEFAULT_POINT_SIZE) / 2) * pxRatio;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
ctx.lineWidth = RING_WIDTH * pxRatio;
|
||||
ctx.setLineDash(RING_DASH.map((segment) => segment * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius + RING_GAP * pxRatio, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawBarOutline(u: uPlot, rect: BarRect): void {
|
||||
const gap = RING_GAP * uPlot.pxRatio;
|
||||
u.ctx.lineWidth = RING_WIDTH * uPlot.pxRatio;
|
||||
u.ctx.setLineDash(RING_DASH.map((segment) => segment * uPlot.pxRatio));
|
||||
u.ctx.strokeRect(
|
||||
rect.left - gap,
|
||||
rect.top - gap,
|
||||
rect.width + 2 * gap,
|
||||
rect.height + 2 * gap,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks each series' partial buckets: a dotted outline around bars, a dot and dotted
|
||||
* ring on line and point series.
|
||||
*/
|
||||
export function partialPointsDrawHook(u: uPlot): void {
|
||||
const { ctx, bbox } = u;
|
||||
const xValues = u.data[0];
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
|
||||
ctx.clip();
|
||||
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx += 1) {
|
||||
const series = u.series[seriesIdx] as ExtendedSeries;
|
||||
if (!series.show || !series.partialTimestamps) {
|
||||
continue;
|
||||
}
|
||||
const color =
|
||||
typeof series.stroke === 'function'
|
||||
? series.stroke(u, seriesIdx)
|
||||
: (series.stroke ?? '');
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = series.alpha ?? 1;
|
||||
|
||||
series.partialTimestamps.forEach((timestamp) => {
|
||||
const idx = u.valToIdx(timestamp);
|
||||
const value = u.data[seriesIdx][idx];
|
||||
if (xValues[idx] !== timestamp || value == null) {
|
||||
return;
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
const bar = measureBar(u, seriesIdx, idx);
|
||||
if (bar) {
|
||||
drawBarOutline(u, bar);
|
||||
} else {
|
||||
drawRing(u, series, idx, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
30
frontend/src/lib/uPlotV2/utils/__tests__/barRect.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { measureBar, reportBarRect } from '../barRect';
|
||||
|
||||
function plotWithPaths(paths?: uPlot.Series['paths']): uPlot {
|
||||
return { series: [{}, { paths }] } as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('measureBar', () => {
|
||||
it('returns the rect the bar paths report for that index', () => {
|
||||
const paths = jest.fn((u: uPlot, seriesIdx: number, idx: number) => {
|
||||
reportBarRect(u, seriesIdx, idx, 10, 20, 8, 30);
|
||||
return null;
|
||||
});
|
||||
const u = plotWithPaths(paths);
|
||||
|
||||
expect(measureBar(u, 1, 4)).toStrictEqual({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 8,
|
||||
height: 30,
|
||||
});
|
||||
expect(paths).toHaveBeenCalledWith(u, 1, 4, 4);
|
||||
});
|
||||
|
||||
it('returns null for series whose paths report no bar', () => {
|
||||
expect(measureBar(plotWithPaths(jest.fn(() => null)), 1, 0)).toBeNull();
|
||||
expect(measureBar(plotWithPaths(), 1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/uPlotV2/utils/barRect.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export interface BarRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
let onBarRect: ((rect: BarRect) => void) | null = null;
|
||||
|
||||
/** `each` callback for bar path builders; reports bar rects while {@link measureBar} runs. */
|
||||
// eslint-disable-next-line max-params
|
||||
export function reportBarRect(
|
||||
_u: uPlot,
|
||||
_seriesIdx: number,
|
||||
_idx: number,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
onBarRect?.({ left, top, width, height });
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas rect of one bar, from re-running the series' paths for that index.
|
||||
* `null` for series not drawn as bars.
|
||||
*/
|
||||
export function measureBar(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
idx: number,
|
||||
): BarRect | null {
|
||||
let rect: BarRect | null = null;
|
||||
onBarRect = (measured): void => {
|
||||
rect = measured;
|
||||
};
|
||||
try {
|
||||
u.series[seriesIdx].paths?.(u, seriesIdx, idx, idx);
|
||||
} finally {
|
||||
onBarRect = null;
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
channelsResponse,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
FIRST_RULE_NAME,
|
||||
type AlertSchema,
|
||||
@@ -148,8 +148,8 @@ export const alertHistoryMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(CHANNEL_MAX)),
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
response.json(() => notificationChannelsResponse(CHANNEL_MAX)),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
|
||||
@@ -12,7 +12,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
channelsResponse,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
@@ -116,8 +116,8 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
response.json(() => notificationChannelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
CHANNEL_MAX,
|
||||
channelsResponse,
|
||||
notificationChannelsResponse,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../../types';
|
||||
|
||||
@@ -27,8 +27,8 @@ export const channelsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels',
|
||||
response.json(() => channelsResponse(values.channels)),
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
response.json(() => notificationChannelsResponse(values.channels)),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CHANNEL_ACTION_OUTCOMES,
|
||||
channelActionError,
|
||||
CHANNEL_TYPES,
|
||||
channelResponse,
|
||||
notificationChannelResponse,
|
||||
type ChannelActionOutcome,
|
||||
type ChannelType,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
@@ -51,21 +51,21 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v1/channels/:id',
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
response.json((req) =>
|
||||
channelResponse(String(req.params.id), values.channelType),
|
||||
notificationChannelResponse(String(req.params.id), values.channelType),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
'http://localhost/api/v1/channels/:id',
|
||||
'http://localhost/api/v2/notification_channels/:id',
|
||||
values.saveOutcome === 'fails'
|
||||
? rejectChannelAction
|
||||
: resolveChannelActionSuccess,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/testChannel',
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
values.testOutcome === 'fails'
|
||||
? rejectChannelAction
|
||||
: resolveChannelActionSuccess,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -64,13 +64,17 @@ export const TestChannelFailure: Story = {
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'test-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
const test = await within(canvasElement).findByTestId(
|
||||
'test-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
// the button is disabled while its permission check is in flight, so a
|
||||
// click any earlier lands on nothing
|
||||
await waitFor(() => expect(test).toBeEnabled(), untilLoaded);
|
||||
await userEvent.click(test);
|
||||
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,12 +46,12 @@ export const channelsNewMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values) => [
|
||||
rest.post(
|
||||
'http://localhost/api/v1/channels',
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
values.saveOutcome === 'fails' ? rejectChannelAction : resolveChannelCreated,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/testChannel',
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -79,9 +79,18 @@ export const TestChannelFailure: Story = {
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('test-channel-button'),
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// the form validates the configuration before it sends a test
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('channel-name-textbox'),
|
||||
'Storybook channel',
|
||||
);
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('webhook-url-textbox'),
|
||||
'https://hooks.slack.com/services/storybook',
|
||||
);
|
||||
await userEvent.click(await canvas.findByTestId('test-channel-button'));
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||