mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-24 12:20:41 +01:00
Compare commits
4 Commits
chore/remo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a7f4fd603 | ||
|
|
ee35fc351f | ||
|
|
720810d424 | ||
|
|
7424885a14 |
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -200,6 +200,15 @@ go.mod @therealpandey
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -207,6 +216,8 @@ go.mod @therealpandey
|
||||
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
23
cmd/authz.go
23
cmd/authz.go
@@ -93,18 +93,17 @@ 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.ResourceMetaResourceNotificationChannel).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,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -8935,6 +8935,89 @@ components:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesLabelPair:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
RuletypesListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
RuletypesListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
- state
|
||||
- severity
|
||||
type: string
|
||||
RuletypesListableRule:
|
||||
properties:
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
$ref: '#/components/schemas/RuletypesAlertType'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
type: object
|
||||
RuletypesListableRules:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesLabelPair'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesListableRule'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- rules
|
||||
- total
|
||||
- labels
|
||||
- reservedKeywords
|
||||
type: object
|
||||
RuletypesMatchType:
|
||||
enum:
|
||||
- at_least_once
|
||||
@@ -20887,9 +20970,10 @@ paths:
|
||||
- users
|
||||
/api/v2/rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all alert rules with their current evaluation
|
||||
state
|
||||
deprecated: true
|
||||
description: 'This endpoint lists all alert rules with their current evaluation
|
||||
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
|
||||
pagination.'
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
@@ -26671,6 +26755,93 @@ paths:
|
||||
summary: Get metric dashboards (v2)
|
||||
tags:
|
||||
- metrics
|
||||
/api/v3/rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of alert rules with their current evaluation state,
|
||||
trimmed to the fields the list page renders. Supports a filter DSL (`query`),
|
||||
a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`),
|
||||
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the
|
||||
filter DSL, a non-reserved key is matched as a rule label directly (`team
|
||||
= infra`); a key that collides with a reserved keyword matches either interpretation
|
||||
(negative operators exclude both), and `labels.<key>` targets only the label.
|
||||
The response also carries the org's label pairs and the reserved filter keys
|
||||
for building filter suggestions.
|
||||
operationId: ListRulesV3
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: states
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesListableRules'
|
||||
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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return append(f.TextToJsonColumn(column), ops...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
sql := f.TextToJsonColumn(column)
|
||||
sql = append(sql, "->"...)
|
||||
sql = schema.Append(f.bunf, sql, mapField)
|
||||
sql = append(sql, "->>"...)
|
||||
sql = schema.Append(f.bunf, sql, key)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
|
||||
@@ -55,6 +55,67 @@ func TestJSONExtractString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "PlainKey",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `"data"::jsonb->'labels'->>'team'`,
|
||||
},
|
||||
{
|
||||
name: "DottedKey_OneMapEntry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
|
||||
},
|
||||
{
|
||||
name: "SingleQuoteInKey_Doubled",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "o'brien",
|
||||
expected: `"data"::jsonb->'labels'->>'o''brien'`,
|
||||
},
|
||||
{
|
||||
name: "BackslashInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a\b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a\b'`,
|
||||
},
|
||||
{
|
||||
name: "DoubleQuoteInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a"b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a"b'`,
|
||||
},
|
||||
{
|
||||
name: "QualifiedColumn",
|
||||
column: "rule.data",
|
||||
mapField: "labels",
|
||||
key: "severity",
|
||||
expected: `"rule"."data"::jsonb->'labels'->>'severity'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -151,37 +151,5 @@
|
||||
"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",
|
||||
"page_title_view": "View Notification Channel",
|
||||
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
|
||||
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
|
||||
"field_slack_title_link": "Title link",
|
||||
"field_slack_color": "Color",
|
||||
"help_slack_color": "good, warning, danger, or a hex value like #439FE0. Templates are allowed.",
|
||||
"placeholder_slack_color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
|
||||
"field_slack_pretext": "Pretext",
|
||||
"help_slack_pretext": "Shown above the attachment block",
|
||||
"field_slack_fallback": "Fallback text",
|
||||
"help_slack_fallback": "Plain text shown where the attachment cannot render, such as push notifications",
|
||||
"field_slack_footer": "Footer",
|
||||
"field_slack_fields": "Fields",
|
||||
"help_slack_fields": "Extra entries rendered as a table inside the attachment",
|
||||
"placeholder_slack_field_title": "Title",
|
||||
"placeholder_slack_field_value": "Value",
|
||||
"field_slack_field_short": "Short",
|
||||
"add_slack_field": "Add field",
|
||||
"remove_slack_field": "Remove field",
|
||||
"field_slack_actions": "Actions",
|
||||
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
|
||||
"placeholder_slack_action_text": "Button text",
|
||||
"placeholder_slack_action_url": "https://runbook.example.com",
|
||||
"placeholder_slack_action_type": "button",
|
||||
"placeholder_slack_action_name": "Name (Slack app callbacks)",
|
||||
"placeholder_slack_action_value": "Value (Slack app callbacks)",
|
||||
"placeholder_slack_action_style": "Style: default, primary or danger",
|
||||
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
|
||||
"add_slack_action": "Add action",
|
||||
"remove_slack_action": "Remove action",
|
||||
"field_webhook_bearer_token": "Bearer token (optional)",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it."
|
||||
}
|
||||
"channel_name_required": "Channel name is mandatory"
|
||||
}
|
||||
@@ -1462,9 +1462,10 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
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.
|
||||
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.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.CHANNELS_NEW,
|
||||
appContext: {
|
||||
@@ -1473,7 +1474,7 @@ describe('PrivateRoute', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
|
||||
@@ -1557,11 +1558,6 @@ describe('PrivateRoute', () => {
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
CHANNELS_NEW: { path: ROUTES.CHANNELS_NEW, deniedRoles: DENIED_ROLES },
|
||||
CHANNELS_EDIT: {
|
||||
path: ROUTES.CHANNELS_EDIT.replace(':channelId', 'channel-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
|
||||
DASHBOARD: {
|
||||
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import routes from '../routes';
|
||||
|
||||
/**
|
||||
* The channel form renders inside the alerts page, which owns the tab strip.
|
||||
* Pointing these routes at the standalone pages tears that page down on every
|
||||
* open and rebuilds it on the way back, which reads as a full reload.
|
||||
*/
|
||||
describe('channel routes', () => {
|
||||
const findRoute = (key: string): (typeof routes)[number] | undefined =>
|
||||
routes.find((route) => route.key === key);
|
||||
|
||||
it('mounts the same component as the alerts list', () => {
|
||||
const list = findRoute('LIST_ALL_ALERT');
|
||||
const create = findRoute('CHANNELS_NEW');
|
||||
const edit = findRoute('CHANNELS_EDIT');
|
||||
|
||||
expect(list?.component).toBeDefined();
|
||||
expect(create?.component).toBe(list?.component);
|
||||
expect(edit?.component).toBe(list?.component);
|
||||
});
|
||||
|
||||
it('keeps the channel paths under /alerts', () => {
|
||||
expect(ROUTES.CHANNELS_NEW.startsWith('/alerts')).toBe(true);
|
||||
expect(ROUTES.CHANNELS_EDIT.startsWith('/alerts')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AlertOverview,
|
||||
AllErrors,
|
||||
ApiMonitoring,
|
||||
ChannelsEdit,
|
||||
ChannelsNew,
|
||||
CreateNewAlerts,
|
||||
DashboardPage,
|
||||
DashboardPanelEditorPage,
|
||||
@@ -241,14 +243,14 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.CHANNELS_NEW,
|
||||
exact: true,
|
||||
component: ListAllALertsPage,
|
||||
component: ChannelsNew,
|
||||
isPrivate: true,
|
||||
key: 'CHANNELS_NEW',
|
||||
},
|
||||
{
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
exact: true,
|
||||
component: ListAllALertsPage,
|
||||
component: ChannelsEdit,
|
||||
isPrivate: true,
|
||||
key: 'CHANNELS_EDIT',
|
||||
},
|
||||
|
||||
40
frontend/src/api/channels/createEmail.ts
Normal file
40
frontend/src/api/channels/createEmail.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createEmail';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
email_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
to: props.to,
|
||||
html: props.html,
|
||||
headers: props.headers,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
40
frontend/src/api/channels/createMsTeams.ts
Normal file
40
frontend/src/api/channels/createMsTeams.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
msteamsv2_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
webhook_url: props.webhook_url,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
43
frontend/src/api/channels/createOpsgenie.ts
Normal file
43
frontend/src/api/channels/createOpsgenie.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
opsgenie_configs: [
|
||||
{
|
||||
api_key: props.api_key,
|
||||
description: props.description,
|
||||
priority: props.priority,
|
||||
message: props.message,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
48
frontend/src/api/channels/createPager.ts
Normal file
48
frontend/src/api/channels/createPager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createPager';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
pagerduty_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
routing_key: props.routing_key,
|
||||
client: props.client,
|
||||
client_url: props.client_url,
|
||||
description: props.description,
|
||||
severity: props.severity,
|
||||
class: props.class,
|
||||
component: props.component,
|
||||
group: props.group,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
41
frontend/src/api/channels/createSlack.ts
Normal file
41
frontend/src/api/channels/createSlack.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createSlack';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
slack_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
api_url: props.api_url,
|
||||
channel: props.channel,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
59
frontend/src/api/channels/createWebhook.ts
Normal file
59
frontend/src/api/channels/createWebhook.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
let httpConfig = {};
|
||||
const username = props.username ? props.username.trim() : '';
|
||||
const password = props.password ? props.password.trim() : '';
|
||||
|
||||
if (username !== '' && password !== '') {
|
||||
httpConfig = {
|
||||
basic_auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
};
|
||||
} else if (username === '' && password !== '') {
|
||||
httpConfig = {
|
||||
authorization: {
|
||||
type: 'Bearer',
|
||||
credentials: password,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.post<PayloadProps>('/channels', {
|
||||
name: props.name,
|
||||
webhook_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
url: props.api_url,
|
||||
http_config: httpConfig,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default create;
|
||||
30
frontend/src/api/channels/delete.ts
Normal file
30
frontend/src/api/channels/delete.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/delete';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const deleteChannel = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.delete<PayloadProps>(`/channels/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteChannel;
|
||||
40
frontend/src/api/channels/editEmail.ts
Normal file
40
frontend/src/api/channels/editEmail.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editEmail';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editEmail = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
email_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
to: props.to,
|
||||
html: props.html,
|
||||
headers: props.headers,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editEmail;
|
||||
40
frontend/src/api/channels/editMsTeams.ts
Normal file
40
frontend/src/api/channels/editMsTeams.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editMsTeams = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
msteamsv2_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
webhook_url: props.webhook_url,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editMsTeams;
|
||||
44
frontend/src/api/channels/editOpsgenie.ts
Normal file
44
frontend/src/api/channels/editOpsgenie.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editOpsgenie = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
opsgenie_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
api_key: props.api_key,
|
||||
description: props.description,
|
||||
priority: props.priority,
|
||||
message: props.message,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editOpsgenie;
|
||||
48
frontend/src/api/channels/editPager.ts
Normal file
48
frontend/src/api/channels/editPager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editPager';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editPager = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
pagerduty_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
routing_key: props.routing_key,
|
||||
client: props.client,
|
||||
client_url: props.client_url,
|
||||
description: props.description,
|
||||
severity: props.severity,
|
||||
class: props.class,
|
||||
component: props.component,
|
||||
group: props.group,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editPager;
|
||||
41
frontend/src/api/channels/editSlack.ts
Normal file
41
frontend/src/api/channels/editSlack.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editSlack';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editSlack = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
slack_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
api_url: props.api_url,
|
||||
channel: props.channel,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editSlack;
|
||||
59
frontend/src/api/channels/editWebhook.ts
Normal file
59
frontend/src/api/channels/editWebhook.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editWebhook = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
let httpConfig = {};
|
||||
const username = props.username ? props.username.trim() : '';
|
||||
const password = props.password ? props.password.trim() : '';
|
||||
|
||||
if (username !== '' && password !== '') {
|
||||
httpConfig = {
|
||||
basic_auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
};
|
||||
} else if (username === '' && password !== '') {
|
||||
httpConfig = {
|
||||
authorization: {
|
||||
type: 'Bearer',
|
||||
credentials: password,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
|
||||
name: props.name,
|
||||
webhook_configs: [
|
||||
{
|
||||
send_resolved: props.send_resolved,
|
||||
url: props.api_url,
|
||||
http_config: httpConfig,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default editWebhook;
|
||||
29
frontend/src/api/channels/get.ts
Normal file
29
frontend/src/api/channels/get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/get';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
28
frontend/src/api/channels/getAll.ts
Normal file
28
frontend/src/api/channels/getAll.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Channels, PayloadProps } from 'types/api/channels/getAll';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>('/channels');
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default getAll;
|
||||
33
frontend/src/api/channels/testEmail.ts
Normal file
33
frontend/src/api/channels/testEmail.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createEmail';
|
||||
|
||||
const testEmail = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
email_configs: [
|
||||
{
|
||||
send_resolved: true,
|
||||
to: props.to,
|
||||
html: props.html,
|
||||
headers: props.headers,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testEmail;
|
||||
33
frontend/src/api/channels/testMsTeams.ts
Normal file
33
frontend/src/api/channels/testMsTeams.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
|
||||
|
||||
const testMsTeams = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
msteamsv2_configs: [
|
||||
{
|
||||
send_resolved: true,
|
||||
webhook_url: props.webhook_url,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testMsTeams;
|
||||
36
frontend/src/api/channels/testOpsgenie.ts
Normal file
36
frontend/src/api/channels/testOpsgenie.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
|
||||
|
||||
const testOpsgenie = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
opsgenie_configs: [
|
||||
{
|
||||
api_key: props.api_key,
|
||||
description: props.description,
|
||||
priority: props.priority,
|
||||
message: props.message,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testOpsgenie;
|
||||
41
frontend/src/api/channels/testPager.ts
Normal file
41
frontend/src/api/channels/testPager.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createPager';
|
||||
|
||||
const testPager = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
pagerduty_configs: [
|
||||
{
|
||||
send_resolved: true,
|
||||
routing_key: props.routing_key,
|
||||
client: props.client,
|
||||
client_url: props.client_url,
|
||||
description: props.description,
|
||||
severity: props.severity,
|
||||
class: props.class,
|
||||
component: props.component,
|
||||
group: props.group,
|
||||
details: {
|
||||
...props.detailsArray,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testPager;
|
||||
34
frontend/src/api/channels/testSlack.ts
Normal file
34
frontend/src/api/channels/testSlack.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createSlack';
|
||||
|
||||
const testSlack = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
slack_configs: [
|
||||
{
|
||||
send_resolved: true,
|
||||
api_url: props.api_url,
|
||||
channel: props.channel,
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testSlack;
|
||||
52
frontend/src/api/channels/testWebhook.ts
Normal file
52
frontend/src/api/channels/testWebhook.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
|
||||
|
||||
const testWebhook = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
let httpConfig = {};
|
||||
const username = props.username ? props.username.trim() : '';
|
||||
const password = props.password ? props.password.trim() : '';
|
||||
|
||||
if (username !== '' && password !== '') {
|
||||
httpConfig = {
|
||||
basic_auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
};
|
||||
} else if (username === '' && password !== '') {
|
||||
httpConfig = {
|
||||
authorization: {
|
||||
type: 'Bearer',
|
||||
credentials: password,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.post<PayloadProps>('/testChannel', {
|
||||
name: props.name,
|
||||
webhook_configs: [
|
||||
{
|
||||
send_resolved: true,
|
||||
url: props.api_url,
|
||||
http_config: httpConfig,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default testWebhook;
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -73,7 +75,8 @@ const withQueryKey = <T extends object, K>(
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
@@ -115,6 +118,7 @@ export type ListRulesQueryResult = NonNullable<
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
@@ -134,6 +138,7 @@ export function useListRules<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
@@ -1388,3 +1393,97 @@ export const useTestRule = <
|
||||
> => {
|
||||
return useMutation(getTestRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const listRulesV3 = (
|
||||
params?: ListRulesV3Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: `/api/v3/rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
|
||||
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
|
||||
signal,
|
||||
}) => listRulesV3(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesV3QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRulesV3>>
|
||||
>;
|
||||
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
|
||||
export function useListRulesV3<
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesV3QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const invalidateListRulesV3 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListRulesV3Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesV3QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -10188,6 +10188,99 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesLabelPairDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum RuletypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum RuletypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
state = 'state',
|
||||
severity = 'severity',
|
||||
}
|
||||
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesListableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesListableRuleDTOLabels;
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesListableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -10284,11 +10377,6 @@ export interface RuletypesRuleConditionDTO {
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -14189,6 +14277,45 @@ export type GetMetricDashboardsV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRulesV3Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @description undefined
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListRulesV3200 = {
|
||||
data: RuletypesListableRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.breadcrumb {
|
||||
/* the bar sat flush against the tab strip above it */
|
||||
padding: var(--spacing-4) 16px var(--spacing-2);
|
||||
padding-left: 16px;
|
||||
|
||||
ol {
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,27 +6,12 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&.qf-logs-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-exceptions {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.qf-api-monitoring {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-traces-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,20 +5,32 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -50,11 +62,16 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: <Component />,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
77
frontend/src/container/AllAlertChannels/AlertChannels.tsx
Normal file
77
frontend/src/container/AllAlertChannels/AlertChannels.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import type { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import Delete from './Delete';
|
||||
|
||||
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
||||
const { t } = useTranslation(['channels']);
|
||||
const { notifications } = useNotifications();
|
||||
const { user } = useAppContext();
|
||||
const [action] = useComponentPermission(['new_alert_action'], user.role);
|
||||
|
||||
const onClickEditHandler = useCallback((id: string) => {
|
||||
history.push(
|
||||
generatePath(ROUTES.CHANNELS_EDIT, {
|
||||
channelId: id,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Channels> = [
|
||||
{
|
||||
title: t('column_channel_name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t('column_channel_type'),
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
|
||||
if (action) {
|
||||
columns.push({
|
||||
title: t('column_channel_action'),
|
||||
dataIndex: 'id',
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (id: string): JSX.Element => (
|
||||
<>
|
||||
<Button onClick={(): void => onClickEditHandler(id)} type="link">
|
||||
{t('column_channel_edit')}
|
||||
</Button>
|
||||
<Delete id={id} notifications={notifications} />
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
dataSource={allChannels}
|
||||
rowKey="id"
|
||||
bordered
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertChannelsProps {
|
||||
allChannels: Channels[];
|
||||
}
|
||||
|
||||
export default AlertChannels;
|
||||
@@ -0,0 +1,4 @@
|
||||
.alert-channels-container {
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
54
frontend/src/container/AllAlertChannels/Delete.tsx
Normal file
54
frontend/src/container/AllAlertChannels/Delete.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import deleteChannel from 'api/channels/delete';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function Delete({ notifications, id }: DeleteProps): JSX.Element {
|
||||
const { t } = useTranslation(['channels']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onClickHandler = async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteChannel({
|
||||
id,
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_delete_success'),
|
||||
});
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['getChannels']);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
type="link"
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteProps {
|
||||
notifications: NotificationInstance;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default Delete;
|
||||
@@ -0,0 +1,84 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertChannels from 'container/AllAlertChannels';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
success: successNotification,
|
||||
error: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Alert Channels Settings List page', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
render(<AlertChannels />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Should display the Alert Channels page properly', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if "New Alert Channel" Button is visble', () => {
|
||||
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
|
||||
const helpIcon = screen.getByRole('img', { name: /help/i });
|
||||
|
||||
fireEvent.mouseOver(helpIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByText('tooltip_notification_channels');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Should check if the channels table is properly displayed', () => {
|
||||
it('Should check if the table columns are properly displayed', () => {
|
||||
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
|
||||
expect(screen.getByText('column_channel_action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if the data in the table is displayed properly', () => {
|
||||
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => {
|
||||
const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0];
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_delete_success',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertChannels from 'container/AllAlertChannels';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
success: successNotification,
|
||||
error: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useComponentPermission', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => [false]),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
render(<AlertChannels />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Should display the Alert Channels page properly', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check if "New Alert Channel" Button is visble and disabled', async () => {
|
||||
const newAlertButton = screen.getByRole('button', {
|
||||
name: /button_new_channel/i,
|
||||
});
|
||||
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
|
||||
expect(newAlertButton).toBeDisabled();
|
||||
});
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
|
||||
const helpIcon = screen.getByRole('img', { name: /help/i });
|
||||
fireEvent.mouseOver(helpIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByText('tooltip_notification_channels');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Should check if the channels table is properly displayed', () => {
|
||||
it('Should check if the table columns are properly displayed', async () => {
|
||||
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
|
||||
expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if the data in the table is displayed properly', async () => {
|
||||
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,914 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
95
frontend/src/container/AllAlertChannels/index.tsx
Normal file
95
frontend/src/container/AllAlertChannels/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Tooltip, Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getAll from 'api/channels/getAll';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import AlertChannelsComponent from './AlertChannels';
|
||||
import { Button, ButtonContainer, RightActionContainer } from './styles';
|
||||
|
||||
import './AllAlertChannels.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function AlertChannels(): JSX.Element {
|
||||
const { t } = useTranslation(['channels']);
|
||||
const { user } = useAppContext();
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
const onToggleHandler = useCallback(() => {
|
||||
history.push(ROUTES.CHANNELS_NEW);
|
||||
}, []);
|
||||
|
||||
const { isLoading, data, error } = useQuery<
|
||||
SuccessResponseV2<Channels[]>,
|
||||
APIError
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUndefined(data?.data)) {
|
||||
logEvent('Alert Channel: Channel list page visited', {
|
||||
number: data?.data?.length,
|
||||
});
|
||||
}
|
||||
}, [data?.data]);
|
||||
|
||||
if (error) {
|
||||
return <Typography>{error.getErrorMessage()}</Typography>;
|
||||
}
|
||||
|
||||
if (isLoading || isUndefined(data?.data)) {
|
||||
return <Spinner tip={t('loading_channels_message')} height="90vh" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-channels-container">
|
||||
<ButtonContainer>
|
||||
<Text truncate={1} color="muted">
|
||||
{t('sending_channels_note')}
|
||||
</Text>
|
||||
|
||||
<RightActionContainer>
|
||||
<TextToolTip
|
||||
text={t('tooltip_notification_channels')}
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
!addNewChannelPermission
|
||||
? 'Ask an admin to create alert channel'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button onClick={onToggleHandler} disabled={!addNewChannelPermission}>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<Plus size="md" /> {t('button_new_channel')}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</RightActionContainer>
|
||||
</ButtonContainer>
|
||||
|
||||
<AlertChannelsComponent allChannels={data?.data || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertChannels;
|
||||
26
frontend/src/container/AllAlertChannels/styles.ts
Normal file
26
frontend/src/container/AllAlertChannels/styles.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button as ButtonComponent } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const RightActionContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
`;
|
||||
@@ -1,23 +1,15 @@
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -161,16 +153,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<DomainList />
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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 untouched fields so the api applies its own defaults', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.webhook, { url: 'https://example.com/hook' }),
|
||||
).toStrictEqual({
|
||||
kind: 'webhook',
|
||||
spec: { url: 'https://example.com/hook' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a false the user chose rather than treating it as blank', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.webhook, {
|
||||
url: 'https://example.com/hook',
|
||||
sendResolved: false,
|
||||
}),
|
||||
).toMatchObject({ spec: { sendResolved: false } });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import {
|
||||
AlertmanagertypesChannelConfigDTO,
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelMSTeamsConfigDTO,
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO,
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO,
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
AlertmanagertypesChannelWebhookConfigDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { isEmpty, isNil, omitBy, pick } from 'lodash-es';
|
||||
|
||||
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)[]>;
|
||||
|
||||
/**
|
||||
* The API rejects a key it does not model and applies its own default for an
|
||||
* absent one, so an untouched field is dropped rather than sent empty. `false`
|
||||
* and `0` are values a user chose, so only blanks go.
|
||||
*/
|
||||
function omitBlank(spec: Record<string, unknown>): Record<string, unknown> {
|
||||
return omitBy(
|
||||
spec,
|
||||
(value) =>
|
||||
isNil(value) ||
|
||||
value === '' ||
|
||||
((Array.isArray(value) || typeof value === 'object') && isEmpty(value)),
|
||||
);
|
||||
}
|
||||
|
||||
export function toChannelConfig(
|
||||
kind: ChannelKind,
|
||||
values: ChannelFormValues,
|
||||
): AlertmanagertypesChannelConfigDTO {
|
||||
const spec = omitBlank(pick(values, SPEC_FIELDS[kind]));
|
||||
|
||||
// Each variant narrows `kind` to its own single-member enum, which a value
|
||||
// typed as the shared ChannelKind cannot satisfy. The string values are the
|
||||
// same, so the union is asserted once here rather than per kind.
|
||||
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) };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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',
|
||||
reopenDuration: '72h',
|
||||
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');
|
||||
});
|
||||
});
|
||||
194
frontend/src/container/CreateAlertChannels/config.ts
Normal file
194
frontend/src/container/CreateAlertChannels/config.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export interface Channel {
|
||||
send_resolved?: boolean;
|
||||
name: string;
|
||||
filter?: Partial<Array<LabelFilterStatement>>;
|
||||
}
|
||||
|
||||
export interface SlackChannel extends Channel {
|
||||
api_url?: string;
|
||||
channel?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface WebhookChannel extends Channel {
|
||||
api_url?: string;
|
||||
// basic auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// PagerChannel configures alert manager to send
|
||||
// events to pagerduty
|
||||
export interface PagerChannel extends Channel {
|
||||
// ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config
|
||||
routing_key?: string;
|
||||
// displays source of the event in pager duty
|
||||
client?: string;
|
||||
client_url?: string;
|
||||
// A description of the incident
|
||||
description?: string;
|
||||
// Severity of the incident
|
||||
severity?: string;
|
||||
// The part or component of the affected system that is broken
|
||||
component?: string;
|
||||
// A cluster or grouping of sources
|
||||
group?: string;
|
||||
// The class/type of the event.
|
||||
class?: string;
|
||||
|
||||
details?: string;
|
||||
detailsArray?: Record<string, string>;
|
||||
}
|
||||
|
||||
// OpsgenieChannel configures alert manager to send
|
||||
// events to opsgenie
|
||||
export interface OpsgenieChannel extends Channel {
|
||||
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
|
||||
api_key: string;
|
||||
|
||||
message?: string;
|
||||
|
||||
// A description of the incident
|
||||
description?: string;
|
||||
|
||||
// A backlink to the sender of the notification.
|
||||
source?: string;
|
||||
|
||||
// A set of arbitrary key/value pairs that provide further detail
|
||||
// about the alert.
|
||||
details?: string;
|
||||
detailsArray?: Record<string, string>;
|
||||
|
||||
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export interface EmailChannel extends Channel {
|
||||
// comma separated list of email addresses to send alerts to
|
||||
to: string;
|
||||
// HTML body of the email notification.
|
||||
html: string;
|
||||
// Further headers email header key/value pairs.
|
||||
// [ headers: { <string>: <tmpl_string>, ... } ]
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export const ValidatePagerChannel = (p: PagerChannel): string => {
|
||||
if (!p) {
|
||||
return 'Received unexpected input for this channel, please contact your administrator ';
|
||||
}
|
||||
|
||||
if (!p.name || p.name === '') {
|
||||
return 'Name is mandatory for creating a channel';
|
||||
}
|
||||
|
||||
if (!p.routing_key || p.routing_key === '') {
|
||||
return 'Routing Key is mandatory for creating pagerduty channel';
|
||||
}
|
||||
|
||||
// validate details json
|
||||
try {
|
||||
JSON.parse(p.details || '{}');
|
||||
} catch (e) {
|
||||
return 'failed to parse additional information, please enter a valid json';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export enum ChannelType {
|
||||
Slack = 'slack',
|
||||
Email = 'email',
|
||||
Webhook = 'webhook',
|
||||
Pagerduty = 'pagerduty',
|
||||
Opsgenie = 'opsgenie',
|
||||
MsTeams = 'msteams',
|
||||
GoogleChat = 'googlechat',
|
||||
Jira = 'jira',
|
||||
JsmOps = 'jsmops',
|
||||
IncidentIO = 'incidentio',
|
||||
}
|
||||
|
||||
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
||||
export interface LabelFilterStatement {
|
||||
// ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher
|
||||
|
||||
// label name
|
||||
name: string;
|
||||
|
||||
// comparators supported by promql are =, !=, =~, or !~. =
|
||||
comparator: string;
|
||||
|
||||
// filter value
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface MsTeamsChannel extends Channel {
|
||||
webhook_url?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface GoogleChatChannel extends Channel {
|
||||
// incoming webhook url of the google chat space, must be an
|
||||
// https url on chat.googleapis.com
|
||||
webhook_url?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
|
||||
// (Atlassian account email + API token) carried in username / password.
|
||||
export interface JiraChannel extends Channel {
|
||||
// Jira Cloud base URL, e.g. https://acme.atlassian.net
|
||||
site: string;
|
||||
project: string;
|
||||
issue_type: string;
|
||||
// issue title template
|
||||
summary?: string;
|
||||
// issue body template, rendered to rich text server-side
|
||||
description?: string;
|
||||
// basic auth: username is the Atlassian account email, password is the API token
|
||||
username: string;
|
||||
password: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
resolve_transition?: string;
|
||||
reopen_transition?: string;
|
||||
// issues resolved with this resolution are never reopened; a refire
|
||||
// creates a new issue instead
|
||||
wont_fix_resolution?: string;
|
||||
// duration string, e.g. 72h or 3d
|
||||
reopen_duration?: string;
|
||||
}
|
||||
|
||||
// IncidentIOChannel configures the incident.io alert channel, backed by an
|
||||
// incident.io HTTP alert source (Alert Events V2 API).
|
||||
export interface IncidentIOChannel extends Channel {
|
||||
// per-source alert events URL, e.g.
|
||||
// https://api.incident.io/v2/alert_events/http/<source_config_id>
|
||||
url: string;
|
||||
// the alert source's secret token
|
||||
token: string;
|
||||
// alert title template
|
||||
title?: string;
|
||||
// alert body template (markdown, rendered natively by incident.io)
|
||||
description?: string;
|
||||
// extra metadata pairs merged over the alert's labels (channel wins on clash)
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// JsmOpsChannel configures the Jira Service Management Ops alert channel
|
||||
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
|
||||
export interface JsmOpsChannel extends Channel {
|
||||
api_key: string;
|
||||
// alert title template
|
||||
message?: string;
|
||||
// alert body template (markdown, rendered to HTML server-side)
|
||||
description?: string;
|
||||
// priority template, resolves to P1-P5
|
||||
priority?: string;
|
||||
// tags, joined to a comma-separated string for the backend
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO,
|
||||
AlertmanagertypesChannelPagerdutyConfigDTO,
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelKind, ChannelSpecFormValues } from './types';
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
|
||||
// shared by slack and ms teams, both render the same title / description boxes
|
||||
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
|
||||
{
|
||||
text: `{{ range .Alerts -}}
|
||||
export const SlackInitialConfig: Partial<SlackChannel> = {
|
||||
text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||
|
||||
*Summary:* {{ .Annotations.summary }}
|
||||
@@ -26,7 +26,7 @@ export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO>
|
||||
{{ 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,29 +37,27 @@ export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO>
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
|
||||
// which the backend applies when title / text are left empty
|
||||
export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChatConfigDTO> =
|
||||
{
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
text: `{{ range .Alerts -}}
|
||||
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
|
||||
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<AlertmanagertypesChannelJiraConfigDTO> =
|
||||
{
|
||||
issueType: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const JiraInitialConfig: Partial<JiraChannel> = {
|
||||
issue_type: '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 }}
|
||||
@@ -67,11 +65,10 @@ export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfigDTO> =
|
||||
{
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
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 }}
|
||||
@@ -82,21 +79,20 @@ export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfig
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
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 }}',
|
||||
},
|
||||
};
|
||||
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 }}',
|
||||
}),
|
||||
};
|
||||
|
||||
export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConfigDTO> =
|
||||
{
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: `{{ if gt (len .Alerts.Firing) 0 -}}
|
||||
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
description: `{{ if gt (len .Alerts.Firing) 0 -}}
|
||||
Alerts Firing:
|
||||
{{ range .Alerts.Firing }}
|
||||
- Message: {{ .Annotations.description }}
|
||||
@@ -118,20 +114,19 @@ export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConf
|
||||
{{ 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<AlertmanagertypesChannelJSMOpsConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
|
||||
send_resolved: 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 }}
|
||||
@@ -145,20 +140,19 @@ export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDT
|
||||
{{ 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<AlertmanagertypesChannelIncidentIOConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
|
||||
send_resolved: 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 }}
|
||||
@@ -172,12 +166,11 @@ export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIO
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO> =
|
||||
{
|
||||
sendResolved: true,
|
||||
html: `<!--
|
||||
export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
send_resolved: 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">
|
||||
@@ -569,20 +562,33 @@ export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO>
|
||||
</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<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]: {},
|
||||
};
|
||||
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]: {},
|
||||
};
|
||||
|
||||
818
frontend/src/container/CreateAlertChannels/index.tsx
Normal file
818
frontend/src/container/CreateAlertChannels/index.tsx
Normal file
@@ -0,0 +1,818 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import createEmail from 'api/channels/createEmail';
|
||||
import createMsTeamsApi from 'api/channels/createMsTeams';
|
||||
import createOpsgenie from 'api/channels/createOpsgenie';
|
||||
import createPagerApi from 'api/channels/createPager';
|
||||
import createSlackApi from 'api/channels/createSlack';
|
||||
import createWebhookApi from 'api/channels/createWebhook';
|
||||
import testEmail from 'api/channels/testEmail';
|
||||
import testMsTeamsApi from 'api/channels/testMsTeams';
|
||||
import testOpsGenie from 'api/channels/testOpsgenie';
|
||||
import testPagerApi from 'api/channels/testPager';
|
||||
import testSlackApi from 'api/channels/testSlack';
|
||||
import testWebhookApi from 'api/channels/testWebhook';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
useCreateChannel,
|
||||
useTestChannel,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
ValidatePagerChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
import { ChannelInitialConfig } from './defaults';
|
||||
import {
|
||||
isChannelType,
|
||||
isValidGoogleChatWebhookURL,
|
||||
isValidIncidentIOURL,
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
prepareGoogleChatRequest,
|
||||
prepareIncidentIORequest,
|
||||
prepareJiraRequest,
|
||||
prepareJsmOpsRequest,
|
||||
} from './utils';
|
||||
|
||||
import './CreateAlertChannels.styles.scss';
|
||||
|
||||
function CreateAlertChannels({
|
||||
preType = ChannelType.Slack,
|
||||
}: CreateAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Alert Channel: Create channel page visited', {});
|
||||
}, []);
|
||||
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel &
|
||||
JiraChannel &
|
||||
JsmOpsChannel &
|
||||
IncidentIOChannel
|
||||
>
|
||||
>(() => ({
|
||||
send_resolved: true,
|
||||
...ChannelInitialConfig[preType],
|
||||
}));
|
||||
const [savingState, setSavingState] = useState<boolean>(false);
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutateAsync: createChannel } = useCreateChannel();
|
||||
const { mutateAsync: testChannel } = useTestChannel();
|
||||
|
||||
const [type, setType] = useState<ChannelType>(preType);
|
||||
const onTypeChangeHandler = useCallback(
|
||||
(value: string) => {
|
||||
const nextType = value as ChannelType;
|
||||
if (nextType === type) {
|
||||
return;
|
||||
}
|
||||
|
||||
setType(nextType);
|
||||
|
||||
// the fields the types share (title, text, description) keep the value of
|
||||
// the type that was selected before, so the new type's defaults have to be
|
||||
// written to both the config and the form
|
||||
const defaults = ChannelInitialConfig[nextType];
|
||||
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
|
||||
formInstance.setFieldsValue(defaults);
|
||||
},
|
||||
[type, formInstance],
|
||||
);
|
||||
|
||||
const prepareSlackRequest = useCallback(
|
||||
() => ({
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
channel: selectedConfig?.channel || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onSlackHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createSlackApi(prepareSlackRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
|
||||
|
||||
const prepareWebhookRequest = useCallback(() => {
|
||||
// initial api request without auth params
|
||||
let request: WebhookChannel = {
|
||||
api_url: selectedConfig?.api_url || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
};
|
||||
|
||||
if (selectedConfig?.username !== '' || selectedConfig?.password !== '') {
|
||||
if (selectedConfig?.username !== '') {
|
||||
// if username is not null then password must be passed
|
||||
if (selectedConfig?.password !== '') {
|
||||
request = {
|
||||
...request,
|
||||
username: selectedConfig.username,
|
||||
password: selectedConfig.password,
|
||||
};
|
||||
} else {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('username_no_password'),
|
||||
});
|
||||
}
|
||||
} else if (selectedConfig?.password !== '') {
|
||||
// only password entered, set bearer token
|
||||
request = {
|
||||
...request,
|
||||
username: '',
|
||||
password: selectedConfig.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}, [notifications, t, selectedConfig]);
|
||||
|
||||
const onWebhookHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
const request = prepareWebhookRequest();
|
||||
await createWebhookApi(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.api_url,
|
||||
notifications,
|
||||
t,
|
||||
prepareWebhookRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const preparePagerRequest = useCallback(() => {
|
||||
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
routing_key: selectedConfig?.routing_key || '',
|
||||
client: selectedConfig?.client || '',
|
||||
client_url: selectedConfig?.client_url || '',
|
||||
description: selectedConfig?.description || '',
|
||||
severity: selectedConfig?.severity || '',
|
||||
component: selectedConfig?.component || '',
|
||||
group: selectedConfig?.group || '',
|
||||
class: selectedConfig?.class || '',
|
||||
details: selectedConfig.details || '',
|
||||
detailsArray: JSON.parse(selectedConfig.details || '{}'),
|
||||
};
|
||||
}, [selectedConfig, notifications]);
|
||||
|
||||
const onPagerHandler = useCallback(async () => {
|
||||
setSavingState(true);
|
||||
const request = preparePagerRequest();
|
||||
|
||||
try {
|
||||
if (request) {
|
||||
await createPagerApi(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
}
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [preparePagerRequest, t, notifications, showErrorModal]);
|
||||
|
||||
const prepareOpsgenieRequest = useCallback(
|
||||
() => ({
|
||||
api_key: selectedConfig?.api_key || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
description: selectedConfig?.description || '',
|
||||
message: selectedConfig?.message || '',
|
||||
priority: selectedConfig?.priority || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onOpsgenieHandler = useCallback(async () => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
await createOpsgenie(prepareOpsgenieRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.api_key,
|
||||
notifications,
|
||||
t,
|
||||
prepareOpsgenieRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const prepareEmailRequest = useCallback(
|
||||
() => ({
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
to: selectedConfig?.to || '',
|
||||
html: selectedConfig?.html || '',
|
||||
headers: selectedConfig?.headers || {},
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onEmailHandler = useCallback(async () => {
|
||||
if (!selectedConfig.to) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('to_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
try {
|
||||
const request = prepareEmailRequest();
|
||||
await createEmail(request);
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
|
||||
|
||||
const prepareMsTeamsRequest = useCallback(
|
||||
() => ({
|
||||
webhook_url: selectedConfig?.webhook_url || '',
|
||||
name: selectedConfig?.name || '',
|
||||
send_resolved: selectedConfig?.send_resolved || false,
|
||||
text: selectedConfig?.text || '',
|
||||
title: selectedConfig?.title || '',
|
||||
}),
|
||||
[selectedConfig],
|
||||
);
|
||||
|
||||
const onMsTeamsHandler = useCallback(async () => {
|
||||
if (!selectedConfig.webhook_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createMsTeamsApi(prepareMsTeamsRequest());
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
selectedConfig.webhook_url,
|
||||
notifications,
|
||||
t,
|
||||
prepareMsTeamsRequest,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateGoogleChatConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.webhook_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('google_chat_webhook_url_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig.webhook_url, notifications, t]);
|
||||
|
||||
const onGoogleChatHandler = useCallback(async () => {
|
||||
if (!validateGoogleChatConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateGoogleChatConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJiraConfig = useCallback((): boolean => {
|
||||
if (
|
||||
!selectedConfig.site ||
|
||||
!selectedConfig.username ||
|
||||
!selectedConfig.password ||
|
||||
!selectedConfig.project ||
|
||||
!selectedConfig.issue_type
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidJiraSiteURL(selectedConfig.site)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_site_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedConfig.reopen_duration &&
|
||||
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
|
||||
) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('jira_reopen_duration_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig, notifications, t]);
|
||||
|
||||
const onJiraHandler = useCallback(async () => {
|
||||
if (!validateJiraConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJiraConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateJsmOpsConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.api_key) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('api_key_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [selectedConfig.api_key, notifications, t]);
|
||||
|
||||
const onJsmOpsHandler = useCallback(async () => {
|
||||
if (!validateJsmOpsConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateJsmOpsConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateIncidentIOConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.url || !selectedConfig.token) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('incidentio_required_fields'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidIncidentIOURL(selectedConfig.url)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('incidentio_url_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig.url, selectedConfig.token, notifications, t]);
|
||||
|
||||
const onIncidentIOHandler = useCallback(async () => {
|
||||
if (!validateIncidentIOConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareIncidentIORequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateIncidentIOConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
if (!selectedConfig.name) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('channel_name_required'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const functionMapper = {
|
||||
[ChannelType.Slack]: onSlackHandler,
|
||||
[ChannelType.Webhook]: onWebhookHandler,
|
||||
[ChannelType.Pagerduty]: onPagerHandler,
|
||||
[ChannelType.Opsgenie]: onOpsgenieHandler,
|
||||
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||
[ChannelType.Email]: onEmailHandler,
|
||||
[ChannelType.GoogleChat]: onGoogleChatHandler,
|
||||
[ChannelType.Jira]: onJiraHandler,
|
||||
[ChannelType.JsmOps]: onJsmOpsHandler,
|
||||
[ChannelType.IncidentIO]: onIncidentIOHandler,
|
||||
};
|
||||
|
||||
if (isChannelType(value)) {
|
||||
const functionToCall = functionMapper[value as keyof typeof functionMapper];
|
||||
|
||||
if (functionToCall) {
|
||||
const result = await functionToCall();
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: result?.status,
|
||||
statusMessage: result?.statusMessage,
|
||||
});
|
||||
} else {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('selected_channel_invalid'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
onSlackHandler,
|
||||
onWebhookHandler,
|
||||
onPagerHandler,
|
||||
onOpsgenieHandler,
|
||||
onMsTeamsHandler,
|
||||
onEmailHandler,
|
||||
onGoogleChatHandler,
|
||||
onJiraHandler,
|
||||
onJsmOpsHandler,
|
||||
onIncidentIOHandler,
|
||||
notifications,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const performChannelTest = useCallback(
|
||||
async (channelType: ChannelType) => {
|
||||
setTestingState(true);
|
||||
try {
|
||||
let request;
|
||||
switch (channelType) {
|
||||
case ChannelType.Webhook:
|
||||
request = prepareWebhookRequest();
|
||||
await testWebhookApi(request);
|
||||
break;
|
||||
case ChannelType.Slack:
|
||||
request = prepareSlackRequest();
|
||||
await testSlackApi(request);
|
||||
break;
|
||||
case ChannelType.Pagerduty:
|
||||
request = preparePagerRequest();
|
||||
if (request) {
|
||||
await testPagerApi(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.MsTeams:
|
||||
request = prepareMsTeamsRequest();
|
||||
await testMsTeamsApi(request);
|
||||
break;
|
||||
case ChannelType.Opsgenie:
|
||||
request = prepareOpsgenieRequest();
|
||||
await testOpsGenie(request);
|
||||
break;
|
||||
case ChannelType.Email:
|
||||
request = prepareEmailRequest();
|
||||
await testEmail(request);
|
||||
break;
|
||||
case ChannelType.GoogleChat:
|
||||
if (!validateGoogleChatConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.Jira:
|
||||
if (!validateJiraConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJiraRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.JsmOps:
|
||||
if (!validateJsmOpsConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
|
||||
break;
|
||||
case ChannelType.IncidentIO:
|
||||
if (!validateIncidentIOConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
|
||||
break;
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('test_unsupported'),
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_test_done'),
|
||||
});
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(
|
||||
error instanceof APIError
|
||||
? error
|
||||
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
|
||||
);
|
||||
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
name: selectedConfig?.name,
|
||||
new: 'true',
|
||||
status: 'Test failed',
|
||||
});
|
||||
}
|
||||
|
||||
setTestingState(false);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
prepareWebhookRequest,
|
||||
t,
|
||||
preparePagerRequest,
|
||||
prepareOpsgenieRequest,
|
||||
prepareSlackRequest,
|
||||
prepareMsTeamsRequest,
|
||||
prepareEmailRequest,
|
||||
validateGoogleChatConfig,
|
||||
validateJiraConfig,
|
||||
validateJsmOpsConfig,
|
||||
validateIncidentIOConfig,
|
||||
testChannel,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
|
||||
const onTestHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
performChannelTest(value);
|
||||
},
|
||||
[performChannelTest],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="create-alert-channels-container">
|
||||
<FormAlertChannels
|
||||
{...{
|
||||
formInstance,
|
||||
onTypeChangeHandler,
|
||||
setSelectedConfig,
|
||||
type,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
savingState,
|
||||
testingState,
|
||||
title: t('page_title_create'),
|
||||
initialValue: {
|
||||
type,
|
||||
...selectedConfig,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateAlertChannelsProps {
|
||||
preType: ChannelType;
|
||||
}
|
||||
|
||||
export default CreateAlertChannels;
|
||||
@@ -1,43 +0,0 @@
|
||||
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,3 +1,23 @@
|
||||
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
|
||||
@@ -12,6 +32,22 @@ 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
|
||||
@@ -57,6 +93,87 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
|
||||
return totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJiraRequest = (
|
||||
config: Partial<JiraChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
|
||||
site: config.site || '',
|
||||
project: config.project || '',
|
||||
issue_type: config.issue_type || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
http_config: {
|
||||
basic_auth: {
|
||||
username: config.username || '',
|
||||
password: config.password || '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (config.summary) {
|
||||
jira.summary = config.summary;
|
||||
}
|
||||
if (config.description) {
|
||||
jira.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jira.priority = config.priority;
|
||||
}
|
||||
if (config.labels?.length) {
|
||||
jira.labels = config.labels;
|
||||
}
|
||||
if (config.resolve_transition) {
|
||||
jira.resolve_transition = config.resolve_transition;
|
||||
}
|
||||
if (config.reopen_transition) {
|
||||
jira.reopen_transition = config.reopen_transition;
|
||||
}
|
||||
if (config.wont_fix_resolution) {
|
||||
jira.wont_fix_resolution = config.wont_fix_resolution;
|
||||
}
|
||||
if (config.reopen_duration) {
|
||||
// the generated type models go's model.Duration as a number, the api takes a
|
||||
// duration string like "72h"
|
||||
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jira_configs: [jira],
|
||||
};
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape. Optional fields are
|
||||
// omitted when empty so the backend applies its defaults.
|
||||
export const prepareJsmOpsRequest = (
|
||||
config: Partial<JsmOpsChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => {
|
||||
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
|
||||
api_key: config.api_key || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
};
|
||||
|
||||
if (config.message) {
|
||||
jsmops.message = config.message;
|
||||
}
|
||||
if (config.description) {
|
||||
jsmops.description = config.description;
|
||||
}
|
||||
if (config.priority) {
|
||||
jsmops.priority = config.priority;
|
||||
}
|
||||
if (config.tags?.length) {
|
||||
// the backend takes a comma-separated string and splits it back
|
||||
jsmops.tags = config.tags.join(',');
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name || '',
|
||||
jsmops_configs: [jsmops],
|
||||
};
|
||||
};
|
||||
|
||||
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
@@ -73,3 +190,33 @@ 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],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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. 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,8 +1,12 @@
|
||||
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 { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
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,8 +25,10 @@ function AlertCondition(): JSX.Element {
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refreshChannels,
|
||||
} = useChannelOptions();
|
||||
const channels = data || [];
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertThreshold from '../AlertThreshold';
|
||||
|
||||
const mockChannels: ChannelOption[] = [];
|
||||
const mockChannels: Channels[] = [];
|
||||
const mockRefreshChannels = jest.fn();
|
||||
const mockIsLoadingChannels = false;
|
||||
const mockIsErrorChannels = false;
|
||||
@@ -85,17 +85,16 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
|
||||
// Mock getAllChannels API
|
||||
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
|
||||
jest.mock('api/channels/getAll', () => ({
|
||||
__esModule: true,
|
||||
useChannelOptions: jest.fn(() => ({
|
||||
data: [
|
||||
{ id: '1', name: 'Email Channel' },
|
||||
{ id: '2', name: 'Slack Channel' },
|
||||
] as ChannelOption[],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ id: '1', name: 'Email Channel' },
|
||||
{ id: '2', name: 'Slack Channel' },
|
||||
] as Channels[],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
// 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 { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
@@ -57,7 +57,7 @@ const mockThreshold = {
|
||||
color: '#ff0000',
|
||||
};
|
||||
|
||||
const mockChannels: ChannelOption[] = [
|
||||
const mockChannels: Channels[] = [
|
||||
{
|
||||
id: TEST_CONSTANTS.CHANNEL_1,
|
||||
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import {
|
||||
NotificationSettingsAction,
|
||||
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
|
||||
updateThreshold: UpdateThreshold;
|
||||
removeThreshold: (thresholdId: string) => void;
|
||||
showRemoveButton: boolean;
|
||||
channels: ChannelOption[];
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
channels: ChannelOption[];
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
|
||||
863
frontend/src/container/EditAlertChannels/index.tsx
Normal file
863
frontend/src/container/EditAlertChannels/index.tsx
Normal file
@@ -0,0 +1,863 @@
|
||||
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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { EmailChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
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<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
|
||||
}
|
||||
|
||||
export default EmailForm;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
|
||||
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="webhookUrl"
|
||||
name="webhook_url"
|
||||
label={t('field_webhook_url')}
|
||||
required
|
||||
rules={[
|
||||
@@ -38,7 +38,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
webhookUrl: event.target.value,
|
||||
webhook_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -76,7 +76,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface GoogleChatProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
|
||||
}
|
||||
|
||||
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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
interface MetadataRow {
|
||||
key: string;
|
||||
@@ -23,7 +23,7 @@ function IncidentIOSettings({
|
||||
})),
|
||||
);
|
||||
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
const update = (patch: Partial<IncidentIOChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const syncMetadata = (rows: MetadataRow[]): void => {
|
||||
@@ -161,7 +161,7 @@ function IncidentIOSettings({
|
||||
}
|
||||
|
||||
interface IncidentIOProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
|
||||
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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { JiraChannel } from '../../CreateAlertChannels/config';
|
||||
import {
|
||||
isValidJiraReopenDuration,
|
||||
isValidJiraSiteURL,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
const update = (patch: Partial<JiraChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
@@ -45,49 +45,49 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="resolveTransition"
|
||||
name="resolve_transition"
|
||||
label={t('field_jira_resolve_transition')}
|
||||
help={t('help_jira_resolve_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_resolve_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ resolveTransition: event.target.value })
|
||||
update({ resolve_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-resolve-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopenTransition"
|
||||
name="reopen_transition"
|
||||
label={t('field_jira_reopen_transition')}
|
||||
help={t('help_jira_reopen_transition')}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder_jira_reopen_transition')}
|
||||
onChange={(event): void =>
|
||||
update({ reopenTransition: event.target.value })
|
||||
update({ reopen_transition: event.target.value })
|
||||
}
|
||||
data-testid="jira-reopen-transition-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="wontFixResolution"
|
||||
name="wont_fix_resolution"
|
||||
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({ wontFixResolution: event.target.value })
|
||||
update({ wont_fix_resolution: event.target.value })
|
||||
}
|
||||
data-testid="jira-wont-fix-resolution-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reopenDuration"
|
||||
name="reopen_duration"
|
||||
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({ reopenDuration: event.target.value })}
|
||||
onChange={(event): void => update({ reopen_duration: 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="email"
|
||||
name="username"
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ email: event.target.value })}
|
||||
onChange={(event): void => update({ username: event.target.value })}
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="apiToken"
|
||||
name="password"
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ apiToken: event.target.value })}
|
||||
onChange={(event): void => update({ password: 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="issueType"
|
||||
name="issue_type"
|
||||
label={t('field_jira_issue_type')}
|
||||
help={t('help_jira_issue_type')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => update({ issueType: event.target.value })}
|
||||
onChange={(event): void => update({ issue_type: 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<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
|
||||
}
|
||||
|
||||
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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const update = (patch: Partial<ChannelSpecFormValues>): void =>
|
||||
const update = (patch: Partial<JsmOpsChannel>): void =>
|
||||
setSelectedConfig((value) => ({ ...value, ...patch }));
|
||||
|
||||
const advanced = (
|
||||
@@ -29,17 +29,12 @@ 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[]).join(',') })}
|
||||
onChange={(value): void => update({ tags: value as string[] })}
|
||||
data-testid="jsmops-tags-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -65,14 +60,14 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
name="api_key"
|
||||
label={t('field_jsmops_api_key')}
|
||||
help={t('help_jsmops_api_key')}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(event): void => update({ apiKey: event.target.value })}
|
||||
onChange={(event): void => update({ api_key: event.target.value })}
|
||||
data-testid="jsmops-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -116,7 +111,7 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface JsmOpsProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
|
||||
}
|
||||
|
||||
export default JsmOpsSettings;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -11,7 +11,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="webhookUrl"
|
||||
name="webhook_url"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -28,7 +28,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
webhookUrl: event.target.value,
|
||||
webhook_url: 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<ChannelSpecFormValues>>
|
||||
React.SetStateAction<Partial<MsTeamsChannel>>
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -21,7 +21,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="apiKey"
|
||||
name="api_key"
|
||||
label={t('field_opsgenie_api_key')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -36,7 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
required
|
||||
>
|
||||
<Input
|
||||
onChange={handleInputChange('apiKey')}
|
||||
onChange={handleInputChange('api_key')}
|
||||
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<ChannelSpecFormValues>>
|
||||
React.SetStateAction<Partial<OpsgenieChannel>>
|
||||
>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import PagerDetails from './PagerDetails';
|
||||
import { PagerChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
function PagerForm({
|
||||
setSelectedConfig,
|
||||
initialDetails,
|
||||
}: PagerFormProps): JSX.Element {
|
||||
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="routingKey"
|
||||
name="routing_key"
|
||||
label={t('field_pager_routing_key')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -33,7 +29,7 @@ function PagerForm({
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
routingKey: event.target.value,
|
||||
routing_key: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="pager-routing-key-textbox"
|
||||
@@ -75,10 +71,22 @@ function PagerForm({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<PagerDetails
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialDetails={initialDetails}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Form.Item
|
||||
name="component"
|
||||
@@ -143,7 +151,7 @@ function PagerForm({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="clientUrl"
|
||||
name="client_url"
|
||||
help={t('help_pager_client_url')}
|
||||
label={t('field_pager_client_url')}
|
||||
>
|
||||
@@ -151,7 +159,7 @@ function PagerForm({
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
clientUrl: event.target.value,
|
||||
client_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-client-url-textarea"
|
||||
@@ -162,8 +170,7 @@ function PagerForm({
|
||||
}
|
||||
|
||||
interface PagerFormProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
|
||||
initialDetails?: Record<string, string>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
|
||||
}
|
||||
|
||||
export default PagerForm;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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,28 +3,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import {
|
||||
AlertmanagertypesChannelSlackActionDTO,
|
||||
AlertmanagertypesChannelSlackFieldDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import SlackActions from './SlackActions';
|
||||
import SlackFields from './SlackFields';
|
||||
import { SlackChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
function Slack({
|
||||
setSelectedConfig,
|
||||
initialFields,
|
||||
initialActions,
|
||||
}: SlackProps): JSX.Element {
|
||||
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="apiUrl"
|
||||
name="api_url"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -41,7 +30,7 @@ function Slack({
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
apiUrl: event.target.value,
|
||||
api_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -78,18 +67,6 @@ function Slack({
|
||||
/>
|
||||
</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 =>
|
||||
@@ -102,85 +79,12 @@ function Slack({
|
||||
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<ChannelSpecFormValues>>>;
|
||||
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
|
||||
}
|
||||
|
||||
export default Slack;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { 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 })}
|
||||
data-testid={`slack-action-text-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.url}
|
||||
placeholder={t('placeholder_slack_action_url')}
|
||||
onChange={(event): void => updateRow(index, { url: event.target.value })}
|
||||
data-testid={`slack-action-url-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.type}
|
||||
placeholder={t('placeholder_slack_action_type')}
|
||||
onChange={(event): void => updateRow(index, { type: event.target.value })}
|
||||
data-testid={`slack-action-type-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.name ?? ''}
|
||||
placeholder={t('placeholder_slack_action_name')}
|
||||
onChange={(event): void => updateRow(index, { name: event.target.value })}
|
||||
data-testid={`slack-action-name-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value ?? ''}
|
||||
placeholder={t('placeholder_slack_action_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
data-testid={`slack-action-value-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.style ?? ''}
|
||||
placeholder={t('placeholder_slack_action_style')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { style: event.target.value })
|
||||
}
|
||||
data-testid={`slack-action-style-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.confirm?.text ?? ''}
|
||||
placeholder={t('placeholder_slack_action_confirm')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, {
|
||||
confirm: event.target.value ? { text: event.target.value } : undefined,
|
||||
})
|
||||
}
|
||||
data-testid={`slack-action-confirm-${index}`}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Minus size={14} />}
|
||||
aria-label={t('remove_slack_action')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
data-testid={`slack-action-remove-${index}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => sync([...rows, { ...emptyAction }])}
|
||||
data-testid="slack-action-add"
|
||||
>
|
||||
{t('add_slack_action')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackActions;
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button, Checkbox, Form, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { 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 })
|
||||
}
|
||||
data-testid={`slack-field-title-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t('placeholder_slack_field_value')}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { value: event.target.value })
|
||||
}
|
||||
data-testid={`slack-field-value-${index}`}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={!!row.short}
|
||||
onChange={(event): void =>
|
||||
updateRow(index, { short: event.target.checked })
|
||||
}
|
||||
data-testid={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Minus size={14} />}
|
||||
aria-label={t('remove_slack_field')}
|
||||
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
|
||||
data-testid={`slack-field-remove-${index}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
sync([...rows, { title: '', value: '', short: false }])
|
||||
}
|
||||
data-testid="slack-field-add"
|
||||
>
|
||||
{t('add_slack_field')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlackFields;
|
||||
@@ -4,7 +4,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Form } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import { WebhookChannel } from '../../CreateAlertChannels/config';
|
||||
|
||||
function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -12,7 +12,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="url"
|
||||
name="api_url"
|
||||
label={t('field_webhook_url')}
|
||||
tooltip={{
|
||||
title: (
|
||||
@@ -29,7 +29,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
url: event.target.value,
|
||||
api_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
@@ -66,28 +66,12 @@ 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<ChannelSpecFormValues>>>;
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<WebhookChannel>>>;
|
||||
}
|
||||
|
||||
export default WebhookSettings;
|
||||
|
||||
@@ -7,9 +7,17 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import type { Store } from 'antd/lib/form/interface';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelKind,
|
||||
ChannelSpecFormValues,
|
||||
} from 'container/CreateAlertChannels/types';
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
IncidentIOChannel,
|
||||
JiraChannel,
|
||||
JsmOpsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import history from 'lib/history';
|
||||
|
||||
import EmailSettings from './Settings/Email';
|
||||
@@ -36,47 +44,35 @@ function FormAlertChannels({
|
||||
title,
|
||||
initialValue,
|
||||
editing = false,
|
||||
readOnly = false,
|
||||
}: FormAlertChannelsProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
const renderSettings = (): ReactElement | null => {
|
||||
switch (type) {
|
||||
case ChannelKind.slack:
|
||||
return (
|
||||
<SlackSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialFields={initialValue?.fields as ChannelSpecFormValues['fields']}
|
||||
initialActions={initialValue?.actions as ChannelSpecFormValues['actions']}
|
||||
/>
|
||||
);
|
||||
case ChannelKind.webhook:
|
||||
case ChannelType.Slack:
|
||||
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Webhook:
|
||||
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.pagerduty:
|
||||
return (
|
||||
<PagerSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialDetails={initialValue?.details as Record<string, string>}
|
||||
/>
|
||||
);
|
||||
case ChannelKind.msteams:
|
||||
case ChannelType.Pagerduty:
|
||||
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.MsTeams:
|
||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.googlechat:
|
||||
case ChannelType.GoogleChat:
|
||||
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.jira:
|
||||
case ChannelType.Jira:
|
||||
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.jsmops:
|
||||
case ChannelType.JsmOps:
|
||||
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.incidentio:
|
||||
case ChannelType.IncidentIO:
|
||||
return (
|
||||
<IncidentIOSettings
|
||||
setSelectedConfig={setSelectedConfig}
|
||||
initialMetadata={initialValue?.metadata as Record<string, string>}
|
||||
/>
|
||||
);
|
||||
case ChannelKind.opsgenie:
|
||||
case ChannelType.Opsgenie:
|
||||
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelKind.email:
|
||||
case ChannelType.Email:
|
||||
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
|
||||
default:
|
||||
return null;
|
||||
@@ -89,12 +85,7 @@ function FormAlertChannels({
|
||||
{title}
|
||||
</Typography.Title>
|
||||
|
||||
<Form
|
||||
initialValues={initialValue}
|
||||
layout="vertical"
|
||||
form={formInstance}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
|
||||
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
|
||||
<Input
|
||||
data-testid="channel-name-textbox"
|
||||
@@ -111,15 +102,15 @@ function FormAlertChannels({
|
||||
<Form.Item
|
||||
label={t('field_send_resolved')}
|
||||
labelAlign="left"
|
||||
name="sendResolved"
|
||||
name="send_resolved"
|
||||
>
|
||||
<Switch
|
||||
defaultValue={initialValue?.sendResolved}
|
||||
defaultValue={initialValue?.send_resolved}
|
||||
testId="field-send-resolved-checkbox"
|
||||
onChange={(value): void => {
|
||||
setSelectedConfig((state) => ({
|
||||
...state,
|
||||
sendResolved: value,
|
||||
send_resolved: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -189,30 +180,25 @@ function FormAlertChannels({
|
||||
<Form.Item>{renderSettings()}</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
onClick={(): void => onSaveHandler(type)}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
onClick={(): void => onSaveHandler(type)}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="return-button"
|
||||
disabled={false}
|
||||
onClick={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
@@ -227,24 +213,35 @@ function FormAlertChannels({
|
||||
|
||||
interface FormAlertChannelsProps {
|
||||
formInstance: FormInstance;
|
||||
type: ChannelKind;
|
||||
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
|
||||
onTypeChangeHandler: (value: ChannelKind) => void;
|
||||
onSaveHandler: (props: ChannelKind) => void;
|
||||
onTestHandler: (props: ChannelKind) => void;
|
||||
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;
|
||||
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 form shows without saving. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
FormAlertChannels.defaultProps = {
|
||||
editing: undefined,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default FormAlertChannels;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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 { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
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';
|
||||
@@ -43,10 +47,18 @@ function BasicInfo({
|
||||
}: BasicInfoProps): JSX.Element {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { isLoading, data, error, isError, refetch } = useChannelOptions();
|
||||
const { isLoading, data, error, isError, refetch } = useQuery<
|
||||
SuccessResponseV2<Channels[]>,
|
||||
APIError
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAll(),
|
||||
});
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
const { user } = useAppContext();
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
|
||||
useState(false);
|
||||
@@ -69,7 +81,7 @@ function BasicInfo({
|
||||
});
|
||||
};
|
||||
|
||||
const noChannels = data?.length === 0;
|
||||
const noChannels = data?.data?.length === 0;
|
||||
const handleCreateNewChannels = useCallback(() => {
|
||||
logEvent('Alert: Create notification channel button clicked', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
@@ -84,7 +96,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?.length,
|
||||
numberOfChannels: data?.data?.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
@@ -220,7 +232,7 @@ function BasicInfo({
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={data || []}
|
||||
channels={data?.data || []}
|
||||
isLoading={isLoading}
|
||||
hasError={isError}
|
||||
error={error as APIError}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { StyledCreateChannelOption, StyledSelect } from './styles';
|
||||
@@ -15,7 +16,7 @@ export interface ChannelSelectProps {
|
||||
onSelectChannels: (s: string[]) => void;
|
||||
onDropdownOpen: () => void;
|
||||
isLoading: boolean;
|
||||
channels: ChannelOption[];
|
||||
channels: Channels[];
|
||||
hasError: boolean;
|
||||
error: APIError;
|
||||
handleCreateNewChannels: () => void;
|
||||
@@ -52,8 +53,11 @@ function ChannelSelect({
|
||||
});
|
||||
}
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
const { user } = useAppContext();
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const renderOptions = (): ReactNode[] => {
|
||||
const children: ReactNode[] = [];
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
// Clearance for the fixed ExplorerOptions bar.
|
||||
padding-bottom: 80px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
@@ -83,14 +72,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
.metrics-table-container {
|
||||
padding-bottom: 48px;
|
||||
.ant-table {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Bot,
|
||||
Cable,
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
@@ -96,15 +95,6 @@ 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 or webhooks.',
|
||||
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 { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
|
||||
|
||||
@@ -28,13 +28,21 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
|
||||
updatedBy: 'user2@signoz.io',
|
||||
};
|
||||
|
||||
export const MOCK_CHANNEL_1: ChannelOption = {
|
||||
export const MOCK_CHANNEL_1: Channels = {
|
||||
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: ChannelOption = {
|
||||
export const MOCK_CHANNEL_2: Channels = {
|
||||
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,14 +77,12 @@ jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
|
||||
jest.mock('api/channels/getAll', () => ({
|
||||
__esModule: true,
|
||||
useChannelOptions: (): any => ({
|
||||
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
default: (): any =>
|
||||
Promise.resolve({
|
||||
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
|
||||
}),
|
||||
}));
|
||||
|
||||
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
export interface RoutingPolicy {
|
||||
id: string;
|
||||
@@ -62,7 +62,7 @@ export interface RoutingPolicyDetailsProps {
|
||||
routingPolicy: RoutingPolicy | null;
|
||||
closeModal: () => void;
|
||||
mode: PolicyDetailsModalMode;
|
||||
channels: ChannelOption[];
|
||||
channels: Channels[];
|
||||
isErrorChannels: boolean;
|
||||
isLoadingChannels: boolean;
|
||||
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
|
||||
@@ -86,7 +86,7 @@ export interface UseRoutingPoliciesReturn {
|
||||
isErrorRoutingPolicies: boolean;
|
||||
refetchRoutingPolicies: () => void;
|
||||
// Channels
|
||||
channels: ChannelOption[];
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
|
||||
@@ -2,16 +2,17 @@ import { useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
|
||||
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
|
||||
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
@@ -86,8 +87,10 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refetchChannels,
|
||||
} = useChannelOptions();
|
||||
const channels = data || [];
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
|
||||
const refreshChannels = (): void => {
|
||||
refetchChannels();
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { listNotificationChannels } from 'api/generated/services/channels';
|
||||
import {
|
||||
AlertmanagertypesChannelListOrderDTO,
|
||||
AlertmanagertypesChannelListSortDTO,
|
||||
AlertmanagertypesListedNotificationChannelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** The list API's own ceiling; a bigger limit is clamped to it server-side. */
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
|
||||
|
||||
export interface ChannelOption {
|
||||
id: string;
|
||||
/** The display name, which is what rules and routing policies reference. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel, for the pickers that let a rule or a policy name one. The list
|
||||
* API pages at 200, so this walks the pages rather than silently truncating.
|
||||
*/
|
||||
async function fetchAllChannels(): Promise<ChannelOption[]> {
|
||||
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [];
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const page = await listNotificationChannels({
|
||||
limit: MAX_PAGE_SIZE,
|
||||
offset: channels.length,
|
||||
sort: AlertmanagertypesChannelListSortDTO.name,
|
||||
order: AlertmanagertypesChannelListOrderDTO.asc,
|
||||
});
|
||||
|
||||
total = page.data.total;
|
||||
channels.push(...page.data.channels);
|
||||
|
||||
if (page.data.channels.length === 0) {
|
||||
break;
|
||||
}
|
||||
} while (channels.length < total);
|
||||
|
||||
return channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.displayName,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useChannelOptions(): UseQueryResult<ChannelOption[], Error> {
|
||||
return useQuery<ChannelOption[], Error>(
|
||||
CHANNEL_OPTIONS_QUERY_KEY,
|
||||
fetchAllChannels,
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
buildNotificationChannelDeletePermission,
|
||||
buildNotificationChannelReadPermission,
|
||||
buildNotificationChannelUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface NotificationChannelPermissions {
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
|
||||
canEdit: boolean;
|
||||
isLoading: boolean;
|
||||
readPermission: BrandedPermission;
|
||||
updatePermission: BrandedPermission;
|
||||
deletePermission: BrandedPermission;
|
||||
/** `[read, update]`, so a denial names both. */
|
||||
editChecks: BrandedPermission[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-level notification channel permissions. Pass `enabled: false` while
|
||||
* the id is unknown, so no check fires against an empty selector.
|
||||
*/
|
||||
export function useNotificationChannelPermissions(
|
||||
channelId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): NotificationChannelPermissions {
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const { readPermission, updatePermission, deletePermission } = useMemo(
|
||||
() => ({
|
||||
readPermission: buildNotificationChannelReadPermission(channelId),
|
||||
updatePermission: buildNotificationChannelUpdatePermission(channelId),
|
||||
deletePermission: buildNotificationChannelDeletePermission(channelId),
|
||||
}),
|
||||
[channelId],
|
||||
);
|
||||
|
||||
const checks = useMemo(
|
||||
() => [readPermission, updatePermission, deletePermission],
|
||||
[readPermission, updatePermission, deletePermission],
|
||||
);
|
||||
|
||||
const { isGranted, isLoading } = useAuthZ(checks, { enabled });
|
||||
|
||||
const canRead = isGranted(readPermission);
|
||||
const canUpdate = isGranted(updatePermission);
|
||||
|
||||
const editChecks = useMemo(
|
||||
() => [readPermission, updatePermission],
|
||||
[readPermission, updatePermission],
|
||||
);
|
||||
|
||||
return {
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete: isGranted(deletePermission),
|
||||
canEdit: canRead && canUpdate,
|
||||
isLoading,
|
||||
readPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
editChecks,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user