Compare commits

...

18 Commits

Author SHA1 Message Date
Ashwin Bhatkal
3b637b1b9a refactor(authz): apply review feedback 2026-09-24 21:51:11 +05:30
Ashwin Bhatkal
e5fbab34bc feat(authz): gate the notification channel screens on the new permissions 2026-09-24 21:43:52 +05:30
Ashwin Bhatkal
a1fc342eb3 feat(authz): add notification channel permission helpers 2026-09-24 21:42:59 +05:30
Ashwin Bhatkal
8756695572 feat(authz): expose notification channels as a fine-grained resource 2026-09-24 21:42:59 +05:30
Ashwin Bhatkal
b5d6d945ff refactor(notification-channels): apply review feedback 2026-09-24 21:42:41 +05:30
Ashwin Bhatkal
9531dec1dd test(notification-channels): move the channel story mocks onto the v2 API 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
eeadaeb38a test(notification-channels): point the channel e2e specs at the v2 screen 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
9942f29c03 refactor(notification-channels): read channel pickers from the v2 list API 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
ba4fe13f15 fix(notification-channels): keep the alerts page mounted when opening a channel 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
fdd6884549 feat(notification-channels): rebuild the channels list on the shared table 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
25f8460667 feat(notification-channels): add a read-only JSON view for a channel 2026-09-24 08:09:48 +05:30
Ashwin Bhatkal
f391704916 refactor(notification-channels): move the channel form onto the v2 schemas and API 2026-09-24 08:09:48 +05:30
Abhi kumar
2c09fedde1 fix(dashboard): panel UX fixes — bar gap, tooltip date, palette, legend search (#12959)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Four independent panel-UX fixes from the dashboards epic, one commit
each.

- **Bar gap.** The gap between bars was wider than half a bar. The bar
width factor goes 0.6 → 0.85, leaving just enough to separate them.
Shared by bar and histogram panels.
- **Tooltip date.** A tooltip on a point from today now shows only the
time; the date still appears for any other day.
- **Red in the palette.** Roughly one series in six was being coloured
red — 18 of 117 entries in `chartcolors`, 15 of 74 in `lightModeColor` —
which spends the one colour that should mean "something is wrong". Each
red entry is rotated onto a free hue with its original lightness
preserved and saturation clamped, and its key renamed to match. Entries
are replaced, never removed: `generateColor` indexes by `hash %
Object.keys(...).length`, so changing the count would recolour every
existing chart. Pinks and magentas are left alone.
- **Bottom legend search.** A bottom legend that overflows the rows the
panel reserves for it now gets a search box and a "Showing N of M
series" readout, on a single row above the series. Whether that row
exists is the chart layout's call, since it is the layout that reserves
the height — a row nobody reserved would eat a row of series. One
`LegendToolbar` serves both placements, with position driving the layout
only, so the right column is unchanged. The readout counts the rows the
search left listed, against the whole series set.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/3959
Closes https://github.com/SigNoz/engineering-pod/issues/3967
Closes https://github.com/SigNoz/engineering-pod/issues/3976
Closes https://github.com/SigNoz/engineering-pod/issues/3977

#### Additional Information

- The palette replacements are computed (even spread across the non-red
arc), not hand-designed — worth a visual pass across light and dark
before merging.
- Two tests asserted a palette hex for a given series label and are
updated to the new value.
- The search row's 24px height and 4px gap are pinned as
`LEGEND_TOOLBAR_HEIGHT` / `LEGEND_TOOLBAR_GAP` beside the existing row
constants; they must match the stylesheet or the reserved rectangle
clips a row.

<img width="1611" height="634" alt="image"
src="https://github.com/user-attachments/assets/2c21a458-e142-4e4f-baff-5bbcbb072140"
/>
2026-09-23 18:58:55 +00:00
Nityananda Gohain
5a1be60745 fix(ai-o11y): scope overview dashboard to gen_ai spans and move message attributes (#12967)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Top span names and Cost by service use `builder_ai_query`, so non-AI
spans no longer show up.
- LLM cost/token panels filter on `gen_ai.request.model EXISTS`; with
variables on "All" they scanned every span.
- Default span mappers now move (not copy) vendor message keys into
`gen_ai.input.messages` / `gen_ai.output.messages`, as documented.
- Bumped versions: dashboard to 3, `gen_ai.llm` mapper to 3,
`gen_ai.agent` mapper to 2.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 14:56:04 +00:00
Gaurav Tewari
cad93a8063 chore: remove beta tag class (#12965)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Removed beta tag class from AI o11y . 
since it remove margin and breaks alignment. don't see any use of it .
it also shifts the pin icon a bit

before - 
<img width="149" height="160" alt="image"
src="https://github.com/user-attachments/assets/26770802-b5b0-452b-b66a-fecda0d49c38"
/>
 
 
<img width="331" height="152" alt="image"
src="https://github.com/user-attachments/assets/fba73ebe-2655-4de5-8488-eb6bb0bfebca"
/>


 now - 
 
<img width="158" height="192" alt="image"
src="https://github.com/user-attachments/assets/fb0ca483-e8db-4ff0-bb36-e0186c2e9458"
/>

<img width="272" height="63" alt="image"
src="https://github.com/user-attachments/assets/68486c3f-a704-4077-82e5-089452abf773"
/>

 



it was added for long text .
https://github.com/SigNoz/signoz/pull/5801/changes#r1736497688
for now in side nav we don't have any long text. and maybe we could have
handled this better
 

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 11:45:33 +00:00
Abhi kumar
aed096bf27 refactor(query-builder): compose the panel-type field map instead of listing it (#12781)
#### Description

`panelTypeDataSourceFormValuesMap` — the map deciding which builder
fields survive a panel-type switch — spelled out all 21 panel-type ×
data-source combinations as literal field lists, 435 lines of them.

They reduce to seven distinct sets:

- logs and traces carry **identical** fields for every panel type
- metrics adds its two aggregation steps (`timeAggregation`,
`spaceAggregation`)
- every panel type is one of four query shapes: series, scalar table,
single value, raw rows

Much of the apparent variation was ordering noise — a bar chart and a
table on logs have the *same* field set, listed in a different order.

Composed from those rules it's 84 lines, and the policy is legible at a
glance: charts, table and pie share a surface; table and pie differ only
by `reduceTo` on metrics; a single value has nothing to group, limit or
order; raw rows carry no aggregation. Two asymmetries that were buried
in the literals are now called out where they're decided, rather than
silently reproduced.

**No behaviour change.** Adding a panel type becomes one line — "which
shape is it?"

#### Additional Information

- Equivalence was checked cell by cell against the previous literal
table before it was removed; all 21 cells matched as sets. The old table
is in git history at `main:frontend/src/lib/query/panelQuery.ts` if you
want to re-run that comparison.
- The specs pin the **rules**, not the values, so they fail when a rule
changes — the moment to stop and decide — rather than whenever a field
moves. Two are worth reading:
- *"gives bar / histogram / table / pie the same non-metrics fields as a
time series"* states the hazard composing introduces: the aggregating
types share one field list, so an edit meant for charts reaches table
and pie too. A failure there names the reason.
- *"gives every cell its own array instance"* — the `QueryBuilder`
provider does `propsRequired?.push('dataSource')` on the list it reads
from this map, so cells sharing an instance would leak fields into each
other. My first draft shared one array across 10 cells; this test is
what guards it.
- Order is not asserted anywhere: `handleQueryChange` and the provider
both assign each field independently via `set()`, so sequence carries no
meaning.
- Three consumers, all exercised: `handleQueryChange` (dashboards v2's
kind switcher and V1's `PanelTypeSelector`) and the shared
`QueryBuilder` provider every explorer uses. Verified with `tsgo`,
`oxlint`, and the `lib` / `providers` / `WidgetCard` / Logs+Traces
explorer / `DashboardPage` suites: 243 suites, 2056 tests.
- Pre-existing and deliberately left alone: that `push` mutates module
state, so the arrays grow by one `'dataSource'` entry on every
query-builder change. Harmless today only because the assignment is
idempotent; `[...propsRequired, 'dataSource']` would fix it, but that's
the provider's bug, not this map's.
2026-09-23 11:24:51 +00:00
Nityananda Gohain
6b66ab64c8 fix: add ai-o11y quick filter migration (#12964)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Added migration to update old instances where quick filters for ai-o11y
is not present.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 11:15:42 +00:00
Naman Verma
362d3a4fdf fix: backfill notification channel tuples (#12960)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Same migration logic as number 128. Needed for enterprise servers as
these tuples decide whether a role may create, list, read, update or
delete channels at all. An existing org with zero channels still needs
them, otherwise its admin can't create the first one or even list the
empty page.
2026-09-23 09:48:11 +00:00
130 changed files with 5134 additions and 4722 deletions

View File

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

View File

@@ -15,14 +15,104 @@
"button_test_channel": "Test",
"button_return": "Back",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"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",
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"placeholder_slack_description": "Description",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"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",
"tooltip_email_to": "Enter email addresses separated by commas.",
"help_email_to": "Email address(es) to send alerts to (comma separated)",
"field_email_to": "To",
"placeholder_email_to": "To",
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_url": "Webhook URL",
"tooltip_webhook_url": "The URL of the webhook to send alerts to. Learn more about webhook integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/webhook/). Integrates with [Incident.io](https://signoz.io/docs/alerts-management/notification-channel/incident-io/), [Rootly](https://signoz.io/docs/alerts-management/notification-channel/rootly/), [Zenduty](https://signoz.io/docs/alerts-management/notification-channel/zenduty/) and [more](https://signoz.io/docs/alerts-management/notification-channel/webhook/#my-incident-management-tool-is-not-listed-can-i-still-integrate).",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"webhook_url_required": "Webhook URL is mandatory",
"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.",
"tooltip_pager_routing_key": "Learn how to obtain the routing key from your PagerDuty account [here](https://signoz.io/docs/alerts-management/notification-channel/pagerduty/#obtaining-integration-or-routing-key).",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
"field_pager_details": "Additional Information",
"field_pager_component": "Component",
"field_pager_group": "Group",
"field_pager_class": "Class",
"field_pager_client": "Client",
"field_pager_client_url": "Client URL",
"placeholder_pager_description": "Description",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_pager_description": "Shows up as description in pagerduty",
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
"field_opsgenie_message": "Message",
"field_opsgenie_priority": "Priority",
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"help_opsgenie_description": "Shows up as description in opsgenie",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
@@ -78,14 +168,14 @@
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"incidentio_tip": "Create an HTTP alert source in incident.io (On-call \u2192 Alert routing \u2192 Sources) and paste its URL and token below.",
"incidentio_tip": "Create an HTTP alert source in incident.io (On-call → Alert routing → Sources) and paste its URL and token below.",
"incidentio_tip_link": "Learn how",
"field_incidentio_url": "Alert source URL",
"help_incidentio_url": "The alert events URL from the source's setup page, e.g. https://api.incident.io/v2/alert_events/http/<source_config_id>.",
"field_incidentio_token": "Token",
"help_incidentio_token": "The alert source's secret token, from the same setup page.",
"field_incidentio_title": "Title",
"help_incidentio_title": "Template for the alert title. Kept stable while the alert fires \u2014 incident.io ignores content updates on repeat events.",
"help_incidentio_title": "Template for the alert title. Kept stable while the alert fires — incident.io ignores content updates on repeat events.",
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
@@ -94,62 +184,5 @@
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
"placeholder_incidentio_metadata_value": "Value",
"button_incidentio_add_metadata": "Add metadata",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
"help_email_to": "Email address(es) to send alerts to (comma separated)",
"field_email_to": "To",
"placeholder_email_to": "To",
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
"field_pager_details": "Additional Information",
"field_pager_component": "Component",
"field_pager_group": "Group",
"field_pager_class": "Class",
"field_pager_client": "Client",
"field_pager_client_url": "Client URL",
"field_opsgenie_message": "Message",
"field_opsgenie_priority": "Priority",
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"help_opsgenie_description": "Shows up as description in opsgenie",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"webhook_url_required": "Webhook URL is mandatory",
"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"
}
"button_incidentio_add_metadata": "Add metadata"
}

View File

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

View File

@@ -0,0 +1,28 @@
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);
});
});

View File

@@ -7,8 +7,6 @@ import {
AlertOverview,
AllErrors,
ApiMonitoring,
ChannelsEdit,
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
@@ -243,14 +241,14 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
component: ListAllALertsPage,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
component: ListAllALertsPage,
isPrivate: true,
key: 'CHANNELS_EDIT',
},

View File

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

View File

@@ -129,6 +129,10 @@ const themeColors = {
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
/* Series palette (dark). Hues in the red band are deliberately absent: red is
reserved for thresholds and error states, so an arbitrary series must never
claim it. generateColor indexes by `hash % Object.keys(...).length`, so
adding or removing an entry recolors every existing chart. */
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',
@@ -152,13 +156,13 @@ const themeColors = {
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
amber1: '#E1A155',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Olives / Greens (3)
olive1: '#DFC33A',
olive2: '#D5E55D',
green7: '#81C220',
// Pinks (3)
hotPink: '#E84393',
@@ -191,9 +195,9 @@ const themeColors = {
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
green8: '#5AC02B',
green9: '#48E043',
green10: '#68E788',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
@@ -212,9 +216,9 @@ const themeColors = {
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
teal7: '#2BC07B',
teal8: '#43E0C5',
teal9: '#68D9E7',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
@@ -224,18 +228,18 @@ const themeColors = {
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
cyan4: '#83C2EB',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
blue7: '#4375E0',
blue8: '#686DE7',
indigo1: '#A68EED',
indigo2: '#B980EA',
purple6: '#EE98D9',
olive3: '#F2F0AE',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
@@ -254,9 +258,9 @@ const themeColors = {
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
purple4: '#A52BC0',
purple5: '#E043D0',
magenta4: '#E768B5',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
@@ -267,18 +271,19 @@ const themeColors = {
gold5: '#F1C40F',
gold6: '#F4D03F',
},
/* Series palette (light). Same red-free constraint as chartcolors above. */
lightModeColor: {
radicalRed: '#D81B60',
magenta1: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
amber1: '#E1A14B',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
olive1: '#C9BD3A',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
@@ -286,17 +291,17 @@ const themeColors = {
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
green1: '#ACDB24',
green2: '#66CC21',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
green3: '#3F8B3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
green4: '#3CC964',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
@@ -304,13 +309,13 @@ const themeColors = {
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
teal1: '#1FBF83',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
teal2: '#28C6C1',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
@@ -320,7 +325,7 @@ const themeColors = {
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
cyan1: '#1B546D',
navy: '#0D1B5E',
gray: '#616161',
@@ -328,25 +333,25 @@ const themeColors = {
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
blue1: '#3B74DF',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
blue2: '#4041B0',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
indigo1: '#6644A9',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
indigo2: '#AD42E0',
purple1: '#C83AC5',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
purple2: '#E573BC',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
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');
});
});

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,3 @@
import {
AlertmanagertypesIncidentIOReceiverConfigDTO,
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ChannelType,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
@@ -32,22 +12,6 @@ export const isValidGoogleChatWebhookURL = (url: string): boolean => {
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -93,87 +57,6 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
return totalMs >= JIRA_MIN_REOPEN_MS;
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.wont_fix_resolution) {
jira.wont_fix_resolution = config.wont_fix_resolution;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
}
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
}
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
}
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -190,33 +73,3 @@ export const isValidIncidentIOURL = (url: string): boolean => {
return false;
}
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareIncidentIORequest = (
config: Partial<IncidentIOChannel>,
): AlertmanagertypesPostableChannelDTO => {
const incidentio: AlertmanagertypesIncidentIOReceiverConfigDTO = {
url: config.url || '',
token: config.token || '',
send_resolved: config.send_resolved || false,
};
if (config.title) {
incidentio.title = config.title;
}
if (config.description) {
incidentio.description = config.description;
}
const metadata = Object.fromEntries(
Object.entries(config.metadata || {}).filter(([key]) => key.trim() !== ''),
);
if (Object.keys(metadata).length > 0) {
incidentio.metadata = metadata;
}
return {
name: config.name || '',
incidentio_configs: [incidentio],
};
};

View File

@@ -0,0 +1,105 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind } from './types';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from './utils';
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
const requireWebhookUrl: Validator = (values, t) =>
values.webhookUrl ? null : t('webhook_url_required');
const requireApiKey: Validator = (values, t) =>
values.apiKey ? null : t('api_key_required');
const validateSlack: Validator = (values, t) =>
values.apiUrl ? null : t('webhook_url_required');
const validateWebhook: Validator = (values, t) => {
if (!values.url) {
return t('webhook_url_required');
}
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
return values.username && !values.password ? t('username_no_password') : null;
};
const validatePagerduty: Validator = (values, t) =>
values.routingKey ? null : t('routing_key_required');
const validateEmail: Validator = (values, t) =>
values.to ? null : t('to_required');
const validateGoogleChat: Validator = (values, t) => {
if (!values.webhookUrl) {
return t('webhook_url_required');
}
return isValidGoogleChatWebhookURL(values.webhookUrl)
? null
: t('google_chat_webhook_url_invalid');
};
const validateJira: Validator = (values, t) => {
if (
!values.site ||
!values.email ||
!values.apiToken ||
!values.project ||
!values.issueType
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(values.site)) {
return t('jira_site_invalid');
}
if (
values.reopenDuration &&
!isValidJiraReopenDuration(values.reopenDuration)
) {
return t('jira_reopen_duration_invalid');
}
return null;
};
const validateIncidentIO: Validator = (values, t) => {
if (!values.url || !values.token) {
return t('incidentio_required_fields');
}
return isValidIncidentIOURL(values.url) ? null : t('incidentio_url_invalid');
};
const VALIDATORS: Record<ChannelKind, Validator> = {
[ChannelKind.slack]: validateSlack,
[ChannelKind.webhook]: validateWebhook,
[ChannelKind.pagerduty]: validatePagerduty,
[ChannelKind.opsgenie]: requireApiKey,
[ChannelKind.jsmops]: requireApiKey,
[ChannelKind.email]: validateEmail,
[ChannelKind.msteams]: requireWebhookUrl,
[ChannelKind.googlechat]: validateGoogleChat,
[ChannelKind.jira]: validateJira,
[ChannelKind.incidentio]: validateIncidentIO,
};
/**
* Client-side validation for the fields the API rejects outright, so a save
* round trip is not spent on an obviously incomplete form. Each rule mirrors the
* matching `Validate()` in `pkg/types/alertmanagertypes`. Returns the message to
* show, or null when the form can be submitted.
*/
export function validateChannel(
kind: ChannelKind,
values: ChannelFormValues,
t: TFunction,
): string | null {
if (!values.name) {
return t('channel_name_required');
}
const validate = VALIDATORS[kind];
return validate ? validate(values, t) : t('selected_channel_invalid');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -43,7 +43,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default EmailForm;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
@@ -12,7 +12,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
return (
<>
<Form.Item
name="webhook_url"
name="webhookUrl"
label={t('field_webhook_url')}
required
rules={[
@@ -38,7 +38,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
webhookUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -76,7 +76,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default GoogleChat;

View File

@@ -4,7 +4,7 @@ import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface MetadataRow {
key: string;
@@ -23,7 +23,7 @@ function IncidentIOSettings({
})),
);
const update = (patch: Partial<IncidentIOChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
@@ -161,7 +161,7 @@ function IncidentIOSettings({
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialMetadata?: Record<string, string>;
}

View File

@@ -4,7 +4,7 @@ import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { JiraChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
@@ -13,7 +13,7 @@ import {
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JiraChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -45,49 +45,49 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="resolve_transition"
name="resolveTransition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
>
<Input
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
update({ resolveTransition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_transition"
name="reopenTransition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
>
<Input
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
update({ reopenTransition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
<Form.Item
name="wont_fix_resolution"
name="wontFixResolution"
label={t('field_jira_wont_fix_resolution')}
help={t('help_jira_wont_fix_resolution')}
>
<Input
placeholder={t('placeholder_jira_wont_fix_resolution')}
onChange={(event): void =>
update({ wont_fix_resolution: event.target.value })
update({ wontFixResolution: event.target.value })
}
data-testid="jira-wont-fix-resolution-textbox"
/>
</Form.Item>
<Form.Item
name="reopen_duration"
name="reopenDuration"
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
@@ -111,7 +111,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
>
<Input
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
onChange={(event): void => update({ reopenDuration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
@@ -167,26 +167,26 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="username"
name="email"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ username: event.target.value })}
onChange={(event): void => update({ email: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Form.Item
name="password"
name="apiToken"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ password: event.target.value })}
onChange={(event): void => update({ apiToken: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
@@ -200,13 +200,13 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="issue_type"
name="issueType"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
onChange={(event): void => update({ issueType: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
@@ -250,7 +250,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default JiraSettings;

View File

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<JsmOpsChannel>): void =>
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -29,12 +29,17 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
// the API takes one comma-separated string, the control edits chips
getValueProps={(value: string | undefined): { value: string[] } => ({
value: value ? value.split(',') : [],
})}
normalize={(value: string[]): string => value.join(',')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
onChange={(value): void => update({ tags: (value as string[]).join(',') })}
data-testid="jsmops-tags-select"
/>
</Form.Item>
@@ -60,14 +65,14 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
</Typography.Text>
<Form.Item
name="api_key"
name="apiKey"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
onChange={(event): void => update({ apiKey: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
@@ -111,7 +116,7 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default JsmOpsSettings;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -11,7 +11,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
return (
<>
<Form.Item
name="webhook_url"
name="webhookUrl"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -28,7 +28,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
webhookUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -67,7 +67,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
interface MsTeamsProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<MsTeamsChannel>>
React.SetStateAction<Partial<ChannelSpecFormValues>>
>;
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
const { TextArea } = Input;
@@ -21,7 +21,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
return (
<>
<Form.Item
name="api_key"
name="apiKey"
label={t('field_opsgenie_api_key')}
tooltip={{
title: (
@@ -36,7 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
required
>
<Input
onChange={handleInputChange('api_key')}
onChange={handleInputChange('apiKey')}
data-testid="opsgenie-api-key-textbox"
/>
</Form.Item>
@@ -88,7 +88,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
interface OpsgenieFormProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<OpsgenieChannel>>
React.SetStateAction<Partial<ChannelSpecFormValues>>
>;
}

View File

@@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { PagerChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import PagerDetails from './PagerDetails';
const { TextArea } = Input;
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
function PagerForm({
setSelectedConfig,
initialDetails,
}: PagerFormProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="routing_key"
name="routingKey"
label={t('field_pager_routing_key')}
tooltip={{
title: (
@@ -29,7 +33,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
routing_key: event.target.value,
routingKey: event.target.value,
}));
}}
data-testid="pager-routing-key-textbox"
@@ -71,22 +75,10 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
/>
</Form.Item>
<Form.Item
name="details"
help={t('help_pager_details')}
label={t('field_pager_details')}
>
<TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
details: event.target.value,
}))
}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
<PagerDetails
setSelectedConfig={setSelectedConfig}
initialDetails={initialDetails}
/>
<Form.Item
name="component"
@@ -151,7 +143,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
</Form.Item>
<Form.Item
name="client_url"
name="clientUrl"
help={t('help_pager_client_url')}
label={t('field_pager_client_url')}
>
@@ -159,7 +151,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
client_url: event.target.value,
clientUrl: event.target.value,
}))
}
data-testid="pager-client-url-textarea"
@@ -170,7 +162,8 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
}
interface PagerFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialDetails?: Record<string, string>;
}
export default PagerForm;

View File

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

View File

@@ -3,17 +3,28 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { SlackChannel } from '../../CreateAlertChannels/config';
import {
AlertmanagertypesChannelSlackActionDTO,
AlertmanagertypesChannelSlackFieldDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import SlackActions from './SlackActions';
import SlackFields from './SlackFields';
const { TextArea } = Input;
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
function Slack({
setSelectedConfig,
initialFields,
initialActions,
}: SlackProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="api_url"
name="apiUrl"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -30,7 +41,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
api_url: event.target.value,
apiUrl: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -67,6 +78,18 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
/>
</Form.Item>
<Form.Item name="titleLink" label={t('field_slack_title_link')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
titleLink: event.target.value,
}))
}
data-testid="title-link-textbox"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<TextArea
onChange={(event): void =>
@@ -79,12 +102,85 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
data-testid="description-textarea"
/>
</Form.Item>
<Form.Item
name="color"
label={t('field_slack_color')}
help={t('help_slack_color')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
color: event.target.value,
}))
}
placeholder={t('placeholder_slack_color')}
data-testid="slack-color-textbox"
/>
</Form.Item>
<Form.Item
name="pretext"
label={t('field_slack_pretext')}
help={t('help_slack_pretext')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
pretext: event.target.value,
}))
}
data-testid="slack-pretext-textbox"
/>
</Form.Item>
<Form.Item
name="fallback"
label={t('field_slack_fallback')}
help={t('help_slack_fallback')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
fallback: event.target.value,
}))
}
data-testid="slack-fallback-textbox"
/>
</Form.Item>
<Form.Item name="footer" label={t('field_slack_footer')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
footer: event.target.value,
}))
}
data-testid="slack-footer-textbox"
/>
</Form.Item>
<SlackFields
setSelectedConfig={setSelectedConfig}
initialFields={initialFields}
/>
<SlackActions
setSelectedConfig={setSelectedConfig}
initialActions={initialActions}
/>
</>
);
}
interface SlackProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
}
export default Slack;

View File

@@ -0,0 +1,123 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { 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;

View File

@@ -0,0 +1,95 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Checkbox, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
import { 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;

View File

@@ -4,7 +4,7 @@ import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { WebhookChannel } from '../../CreateAlertChannels/config';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -12,7 +12,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
return (
<>
<Form.Item
name="api_url"
name="url"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -29,7 +29,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
api_url: event.target.value,
url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -66,12 +66,28 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
data-testid="webhook-password-textbox"
/>
</Form.Item>
<Form.Item
name="bearerToken"
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
>
<Input
type="password"
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
bearerToken: event.target.value,
}));
}}
data-testid="webhook-bearer-token-textbox"
/>
</Form.Item>
</>
);
}
interface WebhookProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<WebhookChannel>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
}
export default WebhookSettings;

View File

@@ -7,17 +7,11 @@ import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
ChannelKind,
ChannelSpecFormValues,
} from 'container/CreateAlertChannels/types';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import history from 'lib/history';
import EmailSettings from './Settings/Email';
@@ -44,35 +38,49 @@ function FormAlertChannels({
title,
initialValue,
editing = false,
readOnly = false,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
const { t } = useTranslation('channels');
const renderSettings = (): ReactElement | null => {
switch (type) {
case ChannelType.Slack:
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Webhook:
case ChannelKind.slack:
return (
<SlackSettings
setSelectedConfig={setSelectedConfig}
initialFields={initialValue?.fields as ChannelSpecFormValues['fields']}
initialActions={initialValue?.actions as ChannelSpecFormValues['actions']}
/>
);
case ChannelKind.webhook:
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Pagerduty:
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
case ChannelKind.pagerduty:
return (
<PagerSettings
setSelectedConfig={setSelectedConfig}
initialDetails={initialValue?.details as Record<string, string>}
/>
);
case ChannelKind.msteams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
case ChannelKind.googlechat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Jira:
case ChannelKind.jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.JsmOps:
case ChannelKind.jsmops:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.IncidentIO:
case ChannelKind.incidentio:
return (
<IncidentIOSettings
setSelectedConfig={setSelectedConfig}
initialMetadata={initialValue?.metadata as Record<string, string>}
/>
);
case ChannelType.Opsgenie:
case ChannelKind.opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
case ChannelKind.email:
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
default:
return null;
@@ -85,8 +93,18 @@ function FormAlertChannels({
{title}
</Typography.Title>
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
<Form
initialValues={initialValue}
layout="vertical"
form={formInstance}
disabled={readOnly}
>
<Form.Item
label={t('field_channel_name')}
labelAlign="left"
name="name"
extra={editing ? t('help_channel_name_immutable') : undefined}
>
<Input
data-testid="channel-name-textbox"
disabled={editing}
@@ -102,15 +120,15 @@ function FormAlertChannels({
<Form.Item
label={t('field_send_resolved')}
labelAlign="left"
name="send_resolved"
name="sendResolved"
>
<Switch
defaultValue={initialValue?.send_resolved}
defaultValue={initialValue?.sendResolved}
testId="field-send-resolved-checkbox"
onChange={(value): void => {
setSelectedConfig((state) => ({
...state,
send_resolved: value,
sendResolved: value,
}));
}}
/>
@@ -180,25 +198,30 @@ function FormAlertChannels({
<Form.Item>{renderSettings()}</Form.Item>
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
<AuthZTooltip checks={saveChecks}>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
data-testid="return-button"
disabled={false}
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
@@ -213,35 +236,30 @@ function FormAlertChannels({
interface FormAlertChannelsProps {
formInstance: FormInstance;
type: ChannelType;
setSelectedConfig: Dispatch<
SetStateAction<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>
>;
onTypeChangeHandler: (value: ChannelType) => void;
onSaveHandler: (props: ChannelType) => void;
onTestHandler: (props: ChannelType) => void;
type: ChannelKind;
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: (props: ChannelKind) => void;
onTestHandler: (props: ChannelKind) => void;
testingState: boolean;
savingState: boolean;
title: string;
initialValue: Store;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
testChecks?: BrandedPermission[];
}
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
saveChecks: [],
testChecks: [],
};
export default FormAlertChannels;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,8 +114,4 @@
}
}
}
.beta-tag {
padding-right: 0;
}
}

View File

@@ -51,7 +51,7 @@ export default function NavItem({
data-testid={dataTestId}
>
{showIcon && <div className="nav-item-active-marker" />}
<div className={cx('nav-item-data', isBeta ? 'beta-tag' : '')}>
<div className="nav-item-data">
{showIcon && (
<div className={cx('nav-item-icon', isEarlyAccess ? 'noz-wave' : '')}>
{icon}

View File

@@ -0,0 +1,68 @@
import { useQuery, UseQueryResult } from 'react-query';
import { listNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
/** 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;
}
export type UseChannelOptionsResult = Omit<
UseQueryResult<ChannelOption[], Error>,
'data'
> & {
data: ChannelOption[];
};
function fetchPage(
offset: number,
): ReturnType<typeof listNotificationChannels> {
return listNotificationChannels({
limit: MAX_PAGE_SIZE,
offset,
sort: AlertmanagertypesChannelListSortDTO.name,
order: AlertmanagertypesChannelListOrderDTO.asc,
});
}
/**
* Every channel, for the pickers that let a rule or a policy name one. The list
* API pages at 200, so the first page reports the total and the rest are
* fetched together rather than silently truncating.
*/
async function fetchAllChannels(): Promise<ChannelOption[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(MAX_PAGE_SIZE, first.data.total, MAX_PAGE_SIZE).map(fetchPage),
);
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
...first.data.channels,
...rest.flatMap((page) => page.data.channels),
];
return channels.map((channel) => ({
id: channel.id,
name: channel.displayName,
}));
}
export function useChannelOptions(): UseChannelOptionsResult {
const query = useQuery<ChannelOption[], Error>(
CHANNEL_OPTIONS_QUERY_KEY,
fetchAllChannels,
);
return { ...query, data: query.data ?? [] };
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level. Wildcard selector required for correct response key matching.
export const NotificationChannelListPermission = buildPermission(
'list',
'notification-channel:*',
);
// The test endpoint is gated on `create` against the wildcard, since a test send
// persists nothing and the channel need not exist.
export const NotificationChannelCreatePermission = buildPermission(
'create',
'notification-channel:*',
);
// Resource-level. Requires a specific channel id.
export const buildNotificationChannelReadPermission = (
id: string,
): BrandedPermission => buildPermission('read', `notification-channel:${id}`);
export const buildNotificationChannelUpdatePermission = (
id: string,
): BrandedPermission => buildPermission('update', `notification-channel:${id}`);
export const buildNotificationChannelDeletePermission = (
id: string,
): BrandedPermission => buildPermission('delete', `notification-channel:${id}`);

View File

@@ -0,0 +1,135 @@
import {
type BuilderField,
panelTypeDataSourceFormValuesMap,
type PartialPanelTypes,
} from 'lib/query/panelTypeDataSourceFormValuesMap';
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* The map is composed from a few shape rules rather than spelled out per panel type
* and data source. These specs pin the rules themselves — each one fails only when a
* rule changes, which is the moment to stop and decide, rather than whenever any
* field moves.
*
* The composition it replaced was checked cell by cell against the previous literal
* table, which is in git history at `main:frontend/src/lib/query/panelQuery.ts`.
*/
function fieldsFor(
panelType: keyof PartialPanelTypes,
dataSource: DataSource,
): BuilderField[] {
return panelTypeDataSourceFormValuesMap[panelType][dataSource].builder
.queryData;
}
/** Fields present in `to` but not in `from`. */
function added(from: BuilderField[], to: BuilderField[]): BuilderField[] {
return to.filter((field) => !from.includes(field)).sort();
}
/** Panel types built on the aggregating field list. */
const AGGREGATING_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.AREA,
PANEL_TYPES.BAR,
PANEL_TYPES.HISTOGRAM,
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
/** Panel types that reduce each series to one cell or slice. */
const SCALAR_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
describe('panelTypeDataSourceFormValuesMap', () => {
const seriesLogs = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
const seriesMetrics = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
// A kind with no entry throws on switch: handleQueryChange reads
// map[panelType][dataSource] straight through, and the cast at its call site
// hides the gap. Static kinds return before that call, so they are exempt.
it('covers every panel kind that can be switched to', () => {
const uncovered = Object.entries(PANEL_KIND_TO_PANEL_TYPE)
.filter(([kind]) => !isStaticPanelKind(kind as never))
.map(([, panelType]) => panelType)
.filter((panelType) => !(panelType in panelTypeDataSourceFormValuesMap));
expect(uncovered).toStrictEqual([]);
});
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
sources[DataSource.TRACES].builder.queryData,
);
});
});
// The provider pushes onto the list it reads from this map, so two cells backed by
// one instance would leak fields into each other.
it('gives every cell its own array instance', () => {
const arrays = Object.values(panelTypeDataSourceFormValuesMap).flatMap(
(sources) =>
Object.values(sources).map((source) => source.builder.queryData),
);
expect(new Set(arrays).size).toBe(arrays.length);
});
// One consequence of composing: the aggregating types share a single field list, so
// an edit meant for charts reaches table and pie too.
it.each(AGGREGATING_TYPES)(
'gives %s the same non-metrics fields as a time series',
(panelType) => {
expect(fieldsFor(panelType, DataSource.LOGS)).toStrictEqual(seriesLogs);
},
);
it('adds both metrics aggregation steps for metrics', () => {
expect(added(seriesLogs, seriesMetrics)).toStrictEqual([
'spaceAggregation',
'timeAggregation',
]);
});
it.each(SCALAR_TYPES)('offers reduceTo to %s on metrics only', (panelType) => {
expect(
added(seriesMetrics, fieldsFor(panelType, DataSource.METRICS)),
).toStrictEqual(['reduceTo']);
expect(fieldsFor(panelType, DataSource.LOGS)).not.toContain('reduceTo');
});
it('drops grouping, paging and ordering for a single value', () => {
const value = fieldsFor(PANEL_TYPES.VALUE, DataSource.LOGS);
expect(added(value, seriesLogs)).toStrictEqual([
'groupBy',
'limit',
'orderBy',
]);
expect(value).toContain('reduceTo');
});
it('offers no aggregation fields to raw rows', () => {
const rows = fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS);
expect(rows).not.toContain('aggregateAttribute');
expect(rows).not.toContain('aggregateOperator');
expect(rows).not.toContain('groupBy');
expect(rows).not.toContain('having');
expect(rows).not.toContain('stepInterval');
});
it('drops paging and ordering for metrics rows', () => {
expect(
added(
fieldsFor(PANEL_TYPES.LIST, DataSource.METRICS),
fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS),
),
).toStrictEqual(['functions', 'limit', 'orderBy']);
});
});

View File

@@ -1,7 +1,7 @@
/**
* Panel query shaping shared by the dashboard panel editor and the query
* builder: the per-panel-type field allowlist, the panel-type switch, and the
* dirty-check used to decide whether a panel has unsaved query edits.
* builder: the panel-type switch and the dirty-check used to decide whether a
* panel has unsaved query edits.
*/
import {
initialQueryBuilderFormValuesMap,
@@ -9,7 +9,11 @@ import {
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
panelTypeDataSourceFormValuesMap,
PartialPanelTypes,
} from 'lib/query/panelTypeDataSourceFormValuesMap';
// Asks "would saving the current panel change the persisted widget spec?".
//
@@ -91,453 +95,6 @@ export const getIsQueryModified = (
);
};
export type PartialPanelTypes = {
[PANEL_TYPES.BAR]: 'bar';
[PANEL_TYPES.LIST]: 'list';
[PANEL_TYPES.TABLE]: 'table';
[PANEL_TYPES.TIME_SERIES]: 'graph';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
export const panelTypeDataSourceFormValuesMap: Record<
keyof PartialPanelTypes,
Record<DataSource, any>
> = {
[PANEL_TYPES.BAR]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.HISTOGRAM]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.PIE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.LIST]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
},
[PANEL_TYPES.VALUE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'having',
'reduceTo',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
},
};
export function handleQueryChange(
newPanelType: keyof PartialPanelTypes,
supersetQuery: Query,
@@ -555,7 +112,7 @@ export function handleQueryChange(
panelTypeDataSourceFormValuesMap[newPanelType][dataSource].builder
.queryData;
fieldsToSelect.forEach((field: keyof IBuilderQuery) => {
fieldsToSelect.forEach((field) => {
set(tempQuery, field, supersetQuery.builder.queryData[index][field]);
});

View File

@@ -0,0 +1,140 @@
/**
* Builder fields carried across a panel-type switch, per panel type and data source.
* Each shape is cut from the widest one by omission.
*/
import { PANEL_TYPES } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export type PartialPanelTypes = {
[PANEL_TYPES.BAR]: 'bar';
[PANEL_TYPES.LIST]: 'list';
[PANEL_TYPES.TABLE]: 'table';
[PANEL_TYPES.TIME_SERIES]: 'graph';
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
export type BuilderField = keyof IBuilderQuery;
export type PanelTypeFormValues = {
builder: { queryData: BuilderField[] };
};
/** A field added to `IBuilderQuery` fails to compile here until answered either way. */
const IS_CARRIED = {
queryName: true,
aggregateOperator: true,
aggregateAttribute: true,
aggregations: true,
timeAggregation: true,
spaceAggregation: true,
functions: true,
filter: true,
filters: true,
groupBy: true,
expression: true,
disabled: true,
having: true,
limit: true,
stepInterval: true,
orderBy: true,
reduceTo: true,
legend: true,
// `dataSource` is appended by the provider; the rest drive surfaces this switch
// does not reach.
dataSource: false,
temporality: false,
pageSize: false,
offset: false,
selectColumns: false,
source: false,
builderQueryType: false,
} satisfies Record<BuilderField, boolean>;
function omit(
fields: readonly BuilderField[],
...omitted: BuilderField[]
): BuilderField[] {
return fields.filter((field) => !omitted.includes(field));
}
const METRICS_AGGREGATION: readonly BuilderField[] = [
'timeAggregation',
'spaceAggregation',
];
const SCALAR_METRICS: readonly BuilderField[] = (
Object.entries(IS_CARRIED) as [BuilderField, boolean][]
)
.filter(([, carried]) => carried)
.map(([field]) => field);
// `reduceTo` is offered for metrics only, an asymmetry carried over from the old table.
const SERIES_METRICS: readonly BuilderField[] = omit(
SCALAR_METRICS,
'reduceTo',
);
const SERIES: readonly BuilderField[] = omit(
SERIES_METRICS,
...METRICS_AGGREGATION,
);
const SINGLE_VALUE_METRICS: readonly BuilderField[] = omit(
SCALAR_METRICS,
'groupBy',
'limit',
'orderBy',
);
const SINGLE_VALUE: readonly BuilderField[] = omit(
SINGLE_VALUE_METRICS,
...METRICS_AGGREGATION,
);
const RAW_ROWS: readonly BuilderField[] = omit(
SERIES,
'aggregateAttribute',
'aggregateOperator',
'groupBy',
'having',
'stepInterval',
'disabled',
'legend',
'expression',
);
const RAW_ROWS_METRICS: readonly BuilderField[] = omit(
RAW_ROWS,
'limit',
'orderBy',
'functions',
);
/** Each cell gets its own copy; a shared instance would let cells contaminate
* each other. */
function bySource(
logsAndTraces: readonly BuilderField[],
metrics: readonly BuilderField[],
): Record<DataSource, PanelTypeFormValues> {
return {
[DataSource.LOGS]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.TRACES]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.METRICS]: { builder: { queryData: [...metrics] } },
};
}
export const panelTypeDataSourceFormValuesMap: Record<
keyof PartialPanelTypes,
Record<DataSource, PanelTypeFormValues>
> = {
[PANEL_TYPES.TIME_SERIES]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.AREA]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -13,7 +13,7 @@ describe('Get Series Data', () => {
expect(seriesData).toHaveLength(5);
expect(seriesData[1].label).toBe('firstLegend');
expect(seriesData[1].show).toBe(true);
expect(seriesData[1].fill).toBe('#FF6F91');
expect(seriesData[1].fill).toBe('#83C2EB');
expect(seriesData[1].width).toBe(2);
});

View File

@@ -57,3 +57,12 @@
color: var(--l3-foreground);
text-align: center;
}
/* The bottom legend's box is only the rows reserved for it. */
.container:not(.isRight) .emptyState {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: 0;
}

View File

@@ -8,7 +8,7 @@ import { LegendAction, LegendPosition, LegendProps } from '../types';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
import { getVisibleSeriesState } from './utils';
import styles from './Legend.module.scss';
@@ -20,6 +20,7 @@ export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
showSearch = false,
focusedSeriesIndex,
onAction,
showCopy = true,
@@ -30,27 +31,22 @@ export default function Legend({
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
// The layout decides: it reserves the height.
const showToolbar = showSearch && items.length > 0;
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const effectiveQuery = showToolbar ? filterQuery : '';
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
const {
listedItems,
visibleCount,
onlyVisibleSeriesIndex,
areAllSeriesVisible,
} = useMemo(
() => getVisibleSeriesState(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
const isEmptyState = !!effectiveQuery.trim() && listedItems.length === 0;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
@@ -63,14 +59,20 @@ export default function Legend({
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isOneSeriesVisible={onlyVisibleSeriesIndex === item.seriesIndex}
areAllSeriesVisible={areAllSeriesVisible}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
[
onlyVisibleSeriesIndex,
areAllSeriesVisible,
focusedSeriesIndex,
showCopy,
onAction,
],
);
return (
@@ -87,7 +89,7 @@ export default function Legend({
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
position={position}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
@@ -101,7 +103,7 @@ export default function Legend({
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
data={visibleLegendItems}
data={listedItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>
)}

View File

@@ -13,9 +13,9 @@ import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
isOneSeriesVisible: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
areAllSeriesVisible: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
@@ -29,15 +29,15 @@ export interface LegendRowProps {
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isOneSeriesVisible,
areAllSeriesVisible,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const isShowAllAction = show && !areAllSeriesVisible;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
@@ -47,15 +47,15 @@ function LegendRow({
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
if (isOneSeriesVisible) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
type: areAllSeriesVisible ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
}, [isOneSeriesVisible, areAllSeriesVisible, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
@@ -126,7 +126,7 @@ function LegendRow({
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
disabled={isOneSeriesVisible}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}

View File

@@ -33,3 +33,32 @@
.searchIcon {
color: var(--l3-foreground);
}
/* Height + margin must match LEGEND_TOOLBAR_HEIGHT and LEGEND_TOOLBAR_GAP. */
.inlineToolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 24px;
margin-bottom: var(--spacing-2);
padding-right: var(--spacing-4);
}
.search {
flex: 0 0 auto;
width: 240px;
max-width: 100%;
}
.statusInline {
// Truncates rather than wrapping onto a row the legend has not reserved.
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.searchInputInline {
width: 100%;
height: 24px;
}

View File

@@ -1,14 +1,17 @@
import { ChangeEvent, useCallback } from 'react';
import cx from 'classnames';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import { LegendPosition } from '../types';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
/** Layout only: the column stacks, the bottom row does not. */
position: LegendPosition;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
@@ -17,7 +20,7 @@ export interface LegendToolbarProps {
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
position,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
@@ -27,30 +30,48 @@ export default function LegendToolbar({
[onFilterQueryChange],
);
return (
<>
{showFilter && (
const searchProps = {
allowClear: true,
prefix: <Search size={12} className={styles.searchIcon} />,
placeholder: 'Search...',
value: filterQuery,
onChange: handleFilterChange,
className: styles.searchInput,
'data-testid': 'legend-search-input',
};
const status = (
<span
className={cx(styles.status, {
[styles.statusInline]: position !== LegendPosition.RIGHT,
})}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
);
if (position === LegendPosition.RIGHT) {
return (
<>
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
<Input {...searchProps} />
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
<div className={styles.toolbar}>{status}</div>
</>
);
}
return (
<div className={styles.inlineToolbar}>
<div className={styles.search}>
<Input
{...searchProps}
className={cx(styles.searchInput, styles.searchInputInline)}
/>
</div>
</>
{status}
</div>
);
}

View File

@@ -16,6 +16,7 @@ export default function UPlotLegend({
position = LegendPosition.BOTTOM,
config,
averageLegendWidth,
showSearch,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
@@ -27,6 +28,7 @@ export default function UPlotLegend({
items={items}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onAction}
/>

View File

@@ -88,11 +88,15 @@ describe('UPlotLegend', () => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
const renderLegend = (
position?: LegendPosition,
showSearch = true,
): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
showSearch={showSearch}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
@@ -100,14 +104,38 @@ describe('UPlotLegend', () => {
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
'gives the legend a search box and a readout (%s)',
(position) => {
renderLegend(position);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
expect(screen.getByTestId('legend-status')).toBeInTheDocument();
},
);
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
'counts down the readout as the search narrows the list (%s)',
async (position) => {
const user = userEvent.setup();
renderLegend(position);
// B is hidden.
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
await user.type(screen.getByTestId('legend-search-input'), 'a');
// Only A matches, counted against all three.
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 1 of 3 series',
);
},
);
it('keeps a BOTTOM legend bare while every series is on screen', () => {
renderLegend(LegendPosition.BOTTOM, false);
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
@@ -116,6 +144,16 @@ describe('UPlotLegend', () => {
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('filters a BOTTOM legend from its search box', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.BOTTOM);
await user.type(screen.getByTestId('legend-search-input'), 'b');
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.queryByText('A')).not.toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);

View File

@@ -1,6 +1,6 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
import { filterLegendItems, getVisibleSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
@@ -10,26 +10,49 @@ const items = (shown: boolean[]): LegendItem[] =>
show,
}));
describe('getShownSeriesState', () => {
describe('getVisibleSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
const state = getVisibleSeriesState(items([true, false, true]), '');
expect(state.visibleCount).toBe(2);
expect(state.onlyVisibleSeriesIndex).toBeNull();
expect(state.areAllSeriesVisible).toBe(false);
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
const state = getVisibleSeriesState(items([false, true, false]), '');
expect(state.visibleCount).toBe(1);
expect(state.onlyVisibleSeriesIndex).toBe(2);
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
const state = getVisibleSeriesState(items([false, false]), '');
expect(state.visibleCount).toBe(0);
expect(state.onlyVisibleSeriesIndex).toBeNull();
});
it('reports every series shown', () => {
expect(
getVisibleSeriesState(items([true, true]), '').areAllSeriesVisible,
).toBe(true);
});
it('counts only the series the search listed', () => {
const state = getVisibleSeriesState(items([true, true, false]), 'series-1');
expect(state.listedItems.map((item) => item.label)).toStrictEqual([
'series-1',
]);
expect(state.visibleCount).toBe(1);
});
it('reads isolation off every series, not the listed ones', () => {
const state = getVisibleSeriesState(items([false, true, false]), 'series-2');
expect(state.listedItems).toHaveLength(1);
expect(state.onlyVisibleSeriesIndex).toBe(2);
});
});

View File

@@ -21,5 +21,9 @@ export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Must match `.inlineToolbar`'s height and margin-bottom, or it eats a row. */
export const LEGEND_TOOLBAR_HEIGHT = 24;
export const LEGEND_TOOLBAR_GAP = 4;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -1,22 +1,12 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
export interface LegendViewState {
listedItems: LegendItem[];
/** Listed items that are toggled on, against every series in the readout. */
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
/** The series index when exactly one series is toggled on, else null. */
onlyVisibleSeriesIndex: number | null;
areAllSeriesVisible: boolean;
}
export function filterLegendItems(
@@ -32,3 +22,23 @@ export function filterLegendItems(
item.label?.toLowerCase().includes(normalisedQuery),
);
}
/**
* Isolation is driven by what is actually shown, never a remembered one: hiding
* series one at a time down to a single one is the same state as "Only". It is
* read off the whole series set, not off what the search left listed.
*/
export function getVisibleSeriesState(
items: LegendItem[],
query: string,
): LegendViewState {
const visible = items.filter((item) => item.show);
const listedItems = filterLegendItems(items, query);
return {
listedItems,
visibleCount: listedItems.filter((item) => item.show).length,
onlyVisibleSeriesIndex: visible.length === 1 ? visible[0].seriesIndex : null,
areAllSeriesVisible: visible.length === items.length,
};
}

View File

@@ -73,9 +73,9 @@ function createTooltipContent(
};
}
function createUPlotInstance(cursorIdx: number | null): uPlot {
function createUPlotInstance(cursorIdx: number | null, timestamp = 1): uPlot {
return {
data: [[1], []],
data: [[timestamp], []],
cursor: { idx: cursorIdx },
// The rest of the uPlot fields are not used by Tooltip
} as unknown as uPlot;
@@ -122,6 +122,19 @@ describe('Tooltip', () => {
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('drops the date from the header title for a point on the current day', () => {
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
renderTooltip({ uPlotInstance });
const expectedTitle = dayjs(todayTimestamp * 1000)
.tz('UTC')
.format(DATE_TIME_FORMATS.TIME_SECONDS);
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('does not render header when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);

View File

@@ -1,11 +1,10 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
@@ -19,6 +18,7 @@ interface TooltipHeaderProps {
isPinned: boolean;
activeItem: TooltipContentItem | null;
headerRowClassName?: string;
/** Overrides the default, which drops the date part for points on the current day. */
dateFormat?: string;
}
@@ -29,7 +29,7 @@ export default function TooltipHeader({
isPinned,
activeItem,
headerRowClassName,
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
dateFormat,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
@@ -46,9 +46,11 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(dateFormat);
return formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
);
}, [
resolvedTimezone,
uPlotInstance.data,

View File

@@ -145,6 +145,8 @@ export interface LegendProps {
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Set by the chart layout, which reserves the height for it. */
showSearch?: boolean;
/** Series index highlighted by the chart cursor. */
focusedSeriesIndex: number | null;
onAction: OnLegendAction;
@@ -158,6 +160,7 @@ export interface UPlotLegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
averageLegendWidth?: number;
showSearch?: boolean;
}
export interface TooltipContentItem {

View File

@@ -265,7 +265,7 @@ function getPathBuilder({
drawStyle,
lineInterpolation,
barAlignment = BarAlignment.Center,
barWidthFactor = 0.6,
barWidthFactor = 0.85,
barMaxWidth = 200,
stepInterval,
}: {

View File

@@ -297,7 +297,7 @@ describe('UPlotSeriesBuilder', () => {
);
const config = builder.getConfig();
expect(config.stroke).toBe('#E64A3C');
expect(config.stroke).toBe('#AD42E0');
});
it('passes through pointsFilter when provided', () => {

View File

@@ -1,5 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'lib/visualization/layout/ChartLayout/ChartLayout';
import ChartLayout, {
LegendLayout,
} from 'lib/visualization/layout/ChartLayout/ChartLayout';
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
import {
LegendPosition,
@@ -58,7 +60,7 @@ export default function ChartWrapper({
);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
if (!showLegend) {
return null;
}
@@ -67,6 +69,7 @@ export default function ChartWrapper({
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
/>
);
},

View File

@@ -68,17 +68,23 @@ export default function Pie({
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
const {
width,
height,
legendWidth,
legendHeight,
averageLegendWidth,
showLegendSearch,
} = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
@@ -224,6 +230,7 @@ export default function Pie({
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>

View File

@@ -25,6 +25,7 @@ describe('calculateChartDimensions', () => {
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
showLegendSearch: false,
});
});
@@ -106,10 +107,10 @@ describe('calculateChartDimensions', () => {
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
// Two 28px rows + 2px gap + 12px padding, plus the 24px search row + 4px.
expect(dims.showLegendSearch).toBe(true);
expect(dims.legendHeight).toBe(98);
expect(dims.height).toBe(402);
});
it('BOTTOM: items one past a row still reserve two rows', () => {
@@ -123,6 +124,50 @@ describe('calculateChartDimensions', () => {
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: no search row while every item is already on screen', () => {
// 1000px fits 4 per row, so 8 items fill both reserved rows exactly.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(8),
});
expect(dims.showLegendSearch).toBe(false);
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: a search row once the grid overflows the reserved rows', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(9),
});
expect(dims.showLegendSearch).toBe(true);
expect(dims.legendHeight).toBe(98);
});
it('BOTTOM: no search row when it would push the legend past a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
expect(dims.showLegendSearch).toBe(false);
expect(dims.legendHeight).toBe(40);
});
it('RIGHT: always carries its chrome; the column has the height for it', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(40),
});
expect(dims.showLegendSearch).toBe(true);
});
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
// The item width alone suggests three fit on one row; the grid's per-item
// padding and column gap leave room for two.

View File

@@ -2,6 +2,8 @@ import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_COLUMN_GAP,
LEGEND_TOOLBAR_GAP,
LEGEND_TOOLBAR_HEIGHT,
LEGEND_ITEM_EXTRA_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
@@ -15,6 +17,8 @@ export interface ChartDimensions {
legendWidth: number;
legendHeight: number;
averageLegendWidth: number;
/** For a BOTTOM legend that row's height is inside `legendHeight`. */
showLegendSearch: boolean;
}
const AVG_CHAR_WIDTH = 8;
@@ -76,6 +80,8 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - A grid overflowing those rows also gets a search row, whose height is
* part of `legendHeight`.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -101,6 +107,7 @@ export function calculateChartDimensions({
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
showLegendSearch: false,
};
}
@@ -140,6 +147,7 @@ export function calculateChartDimensions({
legendHeight: containerHeight,
// Single vertical list on the right.
averageLegendWidth: rightLegendWidth,
showLegendSearch: legendItemCount > 0,
};
}
@@ -158,18 +166,25 @@ export function calculateChartDimensions({
),
);
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
// The wrapper's bottom padding and the search row are inside this height.
const heightForRows = (rowCount: number, withToolbar: boolean): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
LEGEND_PADDING +
(withToolbar ? LEGEND_TOOLBAR_HEIGHT + LEGEND_TOOLBAR_GAP : 0);
const shortPanelBudget = containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO;
const gridRowCount = Math.ceil(legendItemCount / legendItemsPerRow);
// Only once rows overflow — below that every series is already on screen —
// and only while the row it costs leaves the legend inside the panel's share.
const showLegendSearch =
gridRowCount > LEGEND_MAX_BOTTOM_ROWS &&
heightForRows(1, true) <= shortPanelBudget;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
Math.min(LEGEND_MAX_BOTTOM_ROWS, gridRowCount),
);
// Without this, short grid panels hand most of their area to the legend and
@@ -177,11 +192,11 @@ export function calculateChartDimensions({
// row's items are clipped rather than removed, so they are scroll-only here.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
heightForRows(neededRowCount, showLegendSearch) > shortPanelBudget
? 1
: neededRowCount;
const bottomLegendHeight = heightForRows(legendRowCount);
const bottomLegendHeight = heightForRows(legendRowCount, showLegendSearch);
return {
width: containerWidth,
@@ -189,5 +204,6 @@ export function calculateChartDimensions({
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
averageLegendWidth: legendItemWidth,
showLegendSearch,
};
}

View File

@@ -7,9 +7,14 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import 'lib/visualization/layout/ChartLayout/ChartLayout.styles.scss';
export interface LegendLayout {
averageLegendWidth: number;
showSearch: boolean;
}
export interface ChartLayoutProps {
showLegend?: boolean;
legendComponent: (legendPerSet: number) => React.ReactNode;
legendComponent: (layout: LegendLayout) => React.ReactNode;
children: (props: {
chartWidth: number;
chartHeight: number;
@@ -40,6 +45,7 @@ export default function ChartLayout({
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: MAX_LEGEND_WIDTH,
showLegendSearch: false,
};
}
const legendItemsMap = config.getLegendItems();
@@ -81,7 +87,10 @@ export default function ChartLayout({
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
{legendComponent({
averageLegendWidth: chartDimensions.averageLegendWidth,
showSearch: chartDimensions.showLegendSearch,
})}
</div>
)}
</div>

View File

@@ -51,3 +51,38 @@ export const opsGeniePriorityDefaultValue =
export const pagerDutySeverityTextDefaultValue =
'{{ (index .Alerts 0).Labels.severity }}';
export const notificationChannelsV2 = [
{
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
kind: 'slack',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
},
{
id: '4',
name: 'oncall-pagerduty',
displayName: 'Oncall PagerDuty',
kind: 'pagerduty',
createdAt: '2024-02-03T04:45:19.239344617Z',
updatedAt: '2024-06-28T11:37:14.841184399Z',
},
];
export const notificationChannelV2 = {
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/dummy',
channel: '#dummy_channel',
sendResolved: true,
},
},
};

View File

@@ -2,7 +2,11 @@ import { rest } from 'msw';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
import { allAlertChannels } from './__mockdata__/alerts';
import {
allAlertChannels,
notificationChannelsV2,
notificationChannelV2,
} from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
@@ -205,6 +209,32 @@ export const handlers = [
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
),
rest.get('http://localhost/api/v2/notification_channels', (req, res, ctx) => {
const query = req.url.searchParams.get('query')?.toLowerCase() ?? '';
const kind = req.url.searchParams.get('kind');
const channels = notificationChannelsV2.filter(
(channel) =>
(!query || channel.displayName.toLowerCase().includes(query)) &&
(!kind || channel.kind === kind),
);
return res(
ctx.status(200),
ctx.json({
data: { channels, total: channels.length },
status: 'success',
}),
);
}),
rest.get('http://localhost/api/v2/notification_channels/:id', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: notificationChannelV2, status: 'success' }),
),
),
rest.delete(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) => res(ctx.status(204)),
),
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -51,21 +51,21 @@ export const channelsEditMocks = defineStoryMocks({
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels/:id',
'http://localhost/api/v2/notification_channels/:id',
response.json((req) =>
channelResponse(String(req.params.id), values.channelType),
),
),
rest.put(
'http://localhost/api/v1/channels/:id',
'http://localhost/api/v2/notification_channels/:id',
values.saveOutcome === 'fails'
? rejectChannelAction
: resolveChannelActionSuccess,
),
rest.post(
'http://localhost/api/v1/testChannel',
'http://localhost/api/v2/notification_channels/test',
values.testOutcome === 'fails'
? rejectChannelAction
: resolveChannelActionSuccess,

View File

@@ -0,0 +1,144 @@
.container {
--control-height: 2rem;
display: flex;
flex-direction: column;
height: calc(100vh - 62px);
min-height: 400px;
--combobox-trigger-height: var(--control-height);
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
}
/* Title, count and filters sit on the page surface, with matching space above
and below before the table starts. */
.headerBand {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
dashboards list opens. */
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.title {
margin: 0;
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
/* Search and the type filter, the band's second row. */
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
/* Spans the row, the way the dashboards filter bar does. The input's own
wrapper is `width: 100%`, so the basis has to be 0 or `auto` resolves to the
full row and wraps the type filter onto the next line. */
.searchInput {
flex: 1 1 0;
min-width: 220px;
}
.kindSelect {
/* the trigger sizes itself to 100%, so the flex item has to be bounded */
flex: 0 0 180px;
width: 180px;
max-width: 180px;
}
/* The options panel is rendered inline (withPortal={false}) so it can be told to
match the trigger; Radix publishes that width at the popper layer. */
.filtersRow :global([data-slot='select-content']) {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.newChannelButton {
flex: 0 0 auto;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsHeader {
display: block;
text-align: right;
}
/* Left on the table's own defaults, the way Infrastructure Monitoring uses it:
transparent cells over the page background rather than a raised slab. */
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -0,0 +1,271 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import {
NotificationChannelCreatePermission,
NotificationChannelListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import DeleteChannelDialog from './components/DeleteChannelDialog/DeleteChannelDialog';
import {
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
SEARCH_KEY,
} from './constants';
import { useChannelDelete } from './hooks/useChannelDelete';
import { useChannelFormView } from './hooks/useChannelFormView';
import { useChannelsData } from './hooks/useChannelsData';
import styles from './NotificationChannels.module.scss';
import { getChannelColumns } from './table.config';
type Channel = AlertmanagertypesListedNotificationChannelDTO;
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const CREATE_CHECKS = [NotificationChannelCreatePermission];
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
function NotificationChannels(): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formView = useChannelFormView();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'notification-channels',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(SEARCH_KEY, { onDebouncedChange: resetPageOnSearch });
const [kind, setKind] = useQueryState(
KIND_KEY,
parseAsString.withDefault(KIND_FILTER_ALL),
);
const handleKindChange = useCallback(
(value: string | string[]): void => {
const next = value as string;
void setKind(next === KIND_FILTER_ALL ? null : next);
setPage(1);
},
[setKind, setPage],
);
const { channels, total, isFetching, isError, refetch } = useChannelsData({
search: debouncedSearch,
kind,
orderBy,
page,
limit,
});
const deletion = useChannelDelete();
const [jsonChannel, setJsonChannel] = useState<Channel | null>(null);
useEffect(() => {
void logEvent('Alert Channel: Channel list page visited', {});
}, []);
const handleEdit = useCallback(
(channel: Channel, options?: { newTab?: boolean }): void => {
formView.openEdit(channel.id, options);
},
[formView],
);
const handleRowClick = useCallback(
(channel: Channel): void => formView.openEdit(channel.id),
[formView],
);
const handleRowClickNewTab = useCallback(
(channel: Channel): void => formView.openEdit(channel.id, { newTab: true }),
[formView],
);
const columns = useMemo(
() => getChannelColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const columnsWithActions = useMemo(
() => [
...columns,
{
id: 'actions',
header: (): JSX.Element => (
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '72px', ignoreLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,
enableMove: false,
pin: 'right' as const,
cell: ({ row }: { row: Channel }): JSX.Element => (
<ChannelActionsMenu
channel={row}
onOpen={handleEdit}
onDelete={deletion.request}
onViewJson={setJsonChannel}
/>
),
},
],
[columns, handleEdit, deletion.request],
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
clearSearch();
void setKind(null);
}, [clearSearch, setKind]);
return (
<div className={styles.container}>
<header className={styles.headerBand}>
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
className={styles.newChannelButton}
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</AuthZButton>
</div>
<div className={styles.filtersRow}>
<Input
containerClassName={styles.searchInput}
placeholder="Search by channel name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="channels-search"
/>
<SelectSimple
className={styles.kindSelect}
items={KIND_FILTER_OPTIONS}
value={kind}
onChange={handleKindChange}
withPortal={false}
testId="channels-kind-filter"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState
title="Failed to load notification channels"
onRefresh={refetch}
/>
) : isEmpty && hasActiveFilters ? (
<NoResultsEmptyState
title="No matching channels"
subtitle="No notification channels match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : (
<TanStackTable<Channel>
data={channels}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id}
getItemKey={(row): string => row.id}
columnStorageKey="notification-channels-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={1}
/>
)}
</div>
{jsonChannel && (
<ChannelJsonDialog
channelId={jsonChannel.id}
channelName={jsonChannel.displayName}
onClose={(): void => setJsonChannel(null)}
/>
)}
<DeleteChannelDialog
open={!!deletion.channel}
channelName={deletion.channel?.displayName ?? ''}
isDeleting={deletion.isDeleting}
onConfirm={deletion.confirm}
onCancel={deletion.cancel}
/>
</div>
);
}
export default withAuthZContent(NotificationChannels, {
checks: [NotificationChannelListPermission],
});

View File

@@ -0,0 +1,32 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import ChannelForm from '../components/ChannelForm/ChannelForm';
const FIND = { timeout: 5000 };
describe('ChannelForm', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('prefills the channel it was opened on', async () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// the form must not mount before the channel lands, or antd captures
// empty initial values
const name = await screen.findByTestId('channel-name-textbox', {}, FIND);
expect(name).toHaveValue('Dummy-Channel');
expect(screen.getByTestId('webhook-url-textbox')).toHaveValue(
'https://hooks.slack.com/services/dummy',
);
}, 20000);
});

View File

@@ -0,0 +1,256 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import ChannelForm from '../components/ChannelForm/ChannelForm';
const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
/** [data-testid, expected value] for the fields that kind renders. */
fields: [string, string][];
/**
* Jira and JSM Ops keep their extra fields behind a collapsed panel. Tests
* run without translations, so the header shows the i18n key.
*/
expandAdvanced?: string;
}
/**
* Editing a channel must show what the API returned. A field whose form name
* drifts from its spec key renders blank, which is silent and easy to miss, so
* every kind is checked field by field.
*/
const CASES: PrefillCase[] = [
{
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#ops',
title: 'slack title',
titleLink: 'https://signoz.io',
text: 'slack body',
color: 'danger',
pretext: 'slack pretext',
fallback: 'slack fallback',
footer: 'slack footer',
},
fields: [
['webhook-url-textbox', 'https://hooks.slack.com/services/T/B/X'],
['slack-channel-textbox', '#ops'],
['title-textarea', 'slack title'],
['title-link-textbox', 'https://signoz.io'],
['description-textarea', 'slack body'],
['slack-color-textbox', 'danger'],
['slack-pretext-textbox', 'slack pretext'],
['slack-fallback-textbox', 'slack fallback'],
['slack-footer-textbox', 'slack footer'],
],
},
{
kind: 'webhook',
spec: {
url: 'https://example.com/hook',
username: 'hook-user',
password: 'hook-pass',
bearerToken: 'hook-token',
},
fields: [
['webhook-url-textbox', 'https://example.com/hook'],
['webhook-username-textbox', 'hook-user'],
['webhook-password-textbox', 'hook-pass'],
['webhook-bearer-token-textbox', 'hook-token'],
],
},
{
kind: 'email',
spec: { to: 'oncall@signoz.io', html: '<p>alert</p>' },
fields: [['email-to-textbox', 'oncall@signoz.io']],
},
{
kind: 'pagerduty',
spec: {
routingKey: 'pd-routing-key',
description: 'pd description',
severity: 'critical',
group: 'pd group',
class: 'pd class',
client: 'pd client',
clientUrl: 'https://pd.example.com',
},
fields: [
['pager-routing-key-textbox', 'pd-routing-key'],
['pager-description-textarea', 'pd description'],
['pager-severity-textbox', 'critical'],
['pager-group-textarea', 'pd group'],
['pager-class-textarea', 'pd class'],
['pager-client-textarea', 'pd client'],
['pager-client-url-textarea', 'https://pd.example.com'],
],
},
{
kind: 'opsgenie',
spec: {
apiKey: 'og-api-key',
message: 'og message',
description: 'og description',
priority: 'P1',
},
fields: [
['opsgenie-api-key-textbox', 'og-api-key'],
['opsgenie-message-textarea', 'og message'],
['opsgenie-description-textarea', 'og description'],
['opsgenie-priority-textarea', 'P1'],
],
},
{
kind: 'msteams',
spec: {
webhookUrl: 'https://teams.example.com/hook',
title: 'teams title',
text: 'teams body',
},
fields: [
['webhook-url-textbox', 'https://teams.example.com/hook'],
['title-textarea', 'teams title'],
['description-textarea', 'teams body'],
],
},
{
kind: 'googlechat',
spec: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
title: 'chat title',
text: 'chat body',
},
fields: [
['webhook-url-textbox', 'https://chat.googleapis.com/v1/spaces/A'],
['title-textarea', 'chat title'],
['description-textarea', 'chat body'],
],
},
{
kind: 'jira',
expandAdvanced: 'jira_advanced_section',
spec: {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'someone@acme.io',
apiToken: 'jira-token',
summary: 'jira summary',
description: 'jira description',
priority: 'High',
resolveTransition: 'Done',
reopenTransition: 'Reopen',
wontFixResolution: "Won't Fix",
reopenDuration: '72h',
},
fields: [
['jira-site-textbox', 'https://acme.atlassian.net'],
['jira-project-textbox', 'OPS'],
['jira-issue-type-textbox', 'Task'],
['jira-email-textbox', 'someone@acme.io'],
['jira-api-token-textbox', 'jira-token'],
['jira-summary-textarea', 'jira summary'],
['jira-description-textarea', 'jira description'],
['jira-priority-textbox', 'High'],
['jira-resolve-transition-textbox', 'Done'],
['jira-reopen-transition-textbox', 'Reopen'],
['jira-wont-fix-resolution-textbox', "Won't Fix"],
['jira-reopen-duration-textbox', '72h'],
],
},
{
kind: 'jsmops',
expandAdvanced: 'jsmops_advanced_section',
spec: {
apiKey: 'jsm-api-key',
message: 'jsm message',
description: 'jsm description',
priority: 'P2',
tags: 'prod,db',
},
fields: [
['jsmops-api-key-textbox', 'jsm-api-key'],
['jsmops-message-textarea', 'jsm message'],
['jsmops-description-textarea', 'jsm description'],
['jsmops-priority-textarea', 'P2'],
],
},
{
kind: 'incidentio',
spec: {
url: 'https://api.incident.io/v2/alert_events/http/abc',
token: 'incident-token',
title: 'incident title',
description: 'incident description',
},
fields: [
[
'incidentio-url-textbox',
'https://api.incident.io/v2/alert_events/http/abc',
],
['incidentio-token-textbox', 'incident-token'],
['incidentio-title-textarea', 'incident title'],
['incidentio-description-textarea', 'incident description'],
],
},
];
describe('ChannelForm prefill', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it.each(CASES)(
'prefills every $kind field from the API',
async ({ kind, spec, fields, expandAdvanced }) => {
server.use(
rest.get(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: '3',
name: `${kind}-channel`,
displayName: `${kind} channel`,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: { kind, spec: { ...spec, sendResolved: true } },
},
}),
),
),
);
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
await expect(
screen.findByTestId('channel-name-textbox', {}, FIND),
).resolves.toHaveValue(`${kind} channel`);
if (expandAdvanced) {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByText(expandAdvanced, {}, FIND));
}
fields.forEach(([testId, expected]) => {
expect(screen.getByTestId(testId)).toHaveValue(expected);
});
},
20000,
);
});

View File

@@ -0,0 +1,169 @@
import {
setupAuthzAdmin,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ChannelForm from '../components/ChannelForm/ChannelForm';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('ChannelForm save', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'creates with a generated name and the typed config',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
const onDone = jest.fn();
render(<ChannelForm onDone={onDone} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(
await screen.findByTestId('channel-name-textbox', {}, FIND),
'prod alerts',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(() => expect(save).toBeEnabled(), FIND);
await user.click(save);
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
expect(body).toMatchObject({
generateName: true,
displayName: 'prod alerts',
config: {
kind: 'slack',
spec: { apiUrl: 'https://hooks.slack.com/services/T/B/X' },
},
});
// the API rejects a name alongside generateName
expect(body).not.toHaveProperty('name');
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.put(
'http://localhost/api/v2/notification_channels/:id',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
},
),
);
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const channelName = await screen.findByTestId(
'channel-name-textbox',
{},
FIND,
);
expect(channelName).toBeDisabled();
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.clear(screen.getByTestId('slack-channel-textbox'));
await user.type(screen.getByTestId('slack-channel-textbox'), '#changed');
const save = screen.getByTestId('save-channel-button');
await waitFor(() => expect(save).toBeEnabled(), FIND);
await user.click(save);
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
expect(body).toStrictEqual({
config: {
kind: 'slack',
spec: expect.objectContaining({ channel: '#changed' }),
},
});
expect(body).not.toHaveProperty('displayName');
},
TEST_TIMEOUT,
);
it(
'opens read-only, with saving disabled rather than hidden, when update is denied',
async () => {
server.use(setupAuthzGrantByPrefix('read', 'list'));
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
await screen.findByTestId('channel-name-textbox', {}, FIND);
// the whole form goes disabled once the denial lands
await waitFor(
() => expect(screen.getByTestId('slack-channel-textbox')).toBeDisabled(),
FIND,
);
const save = screen.getByTestId('save-channel-button');
await waitFor(() => expect(save).toBeDisabled(), FIND);
expect(save).toHaveAttribute(
'data-denied-permissions',
expect.stringContaining('update'),
);
expect(screen.getByTestId('test-channel-button')).toBeDisabled();
},
TEST_TIMEOUT,
);
it(
'explains a channel the v2 API cannot model instead of showing a blank form',
async () => {
server.use(
rest.get(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: {
message: 'channel carries 2 notifier configurations',
},
}),
),
),
);
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
await expect(
screen.findByTestId('channel-load-error', {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('channel-name-textbox')).not.toBeInTheDocument();
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,95 @@
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
setupAuthzGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { renderChannels } from './renderChannels';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('Notification channels list - AuthZ', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'hides the list when list is denied',
async () => {
server.use(setupAuthzDenyAll());
renderChannels();
await expect(
screen.findByText(/not authorized/i, {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('channels-search')).not.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'disables creating when create is denied',
async () => {
server.use(setupAuthzDeny(NotificationChannelCreatePermission));
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
await waitFor(
() => expect(screen.getByTestId('channels-create')).toBeDisabled(),
FIND,
);
},
TEST_TIMEOUT,
);
it(
'offers Edit to a user who can update the channel',
async () => {
server.use(setupAuthzAdmin());
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
const user = userEvent.setup({ delay: null });
await user.click(screen.getByTestId('channel-actions-3'));
await expect(
screen.findByRole('menuitem', { name: 'Edit' }, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'offers View instead when the channel can only be read',
async () => {
server.use(setupAuthzGrantByPrefix('read', 'list'));
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
const user = userEvent.setup({ delay: null });
await user.click(screen.getByTestId('channel-actions-3'));
await expect(
screen.findByRole('menuitem', { name: 'View' }, FIND),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('menuitem', { name: 'Edit' }),
).not.toBeInTheDocument();
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,102 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { renderChannels } from './renderChannels';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('Notification channels list', () => {
beforeEach(() => {
// search, filter and page live in the url, so one test's filter would
// otherwise still be applied in the next
window.history.replaceState({}, '', '/');
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'heads the page with the channel count',
async () => {
renderChannels();
await expect(
screen.findByText('All notification channels', {}, FIND),
).resolves.toBeInTheDocument();
// the count comes from the API's total, not the loaded page
await expect(screen.findByText('2', {}, FIND)).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'lists every channel with its type',
async () => {
renderChannels();
await expect(
screen.findByText('Dummy-Channel', {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
// the type reads as plain text, no colour coding
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'filters server-side as the user types',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
await userEvent.type(screen.getByTestId('channels-search'), 'oncall');
await waitFor(() => {
expect(screen.queryByText('Dummy-Channel')).not.toBeInTheDocument();
}, FIND);
await expect(
screen.findByText('Oncall PagerDuty', {}, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'asks for confirmation before deleting, from the row menu',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
// the table paints a loading layer over the rows until the fetch settles
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(screen.getByTestId('channel-actions-3'));
// Radix renders the menu into a portal once the trigger is activated
await screen.findByRole('menu', {}, FIND);
// Delete stays disabled until the per-channel permission check resolves
const deleteItem = await screen.findByRole(
'menuitem',
{ name: 'Delete' },
FIND,
);
await waitFor(
() => expect(deleteItem).not.toHaveAttribute('aria-disabled', 'true'),
FIND,
);
await user.click(deleteItem);
await expect(
screen.findByTestId('channel-delete-confirm', {}, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,54 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import {
channelToJson,
hasSecrets,
SECRET_PLACEHOLDER,
} from '../utils/channelJson';
const slackChannel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/SECRET',
channel: '#alerts',
sendResolved: true,
},
},
} as AlertmanagertypesGettableNotificationChannelDTO;
describe('channelToJson', () => {
it('hides the credential the API returns in the clear', () => {
const json = JSON.parse(channelToJson(slackChannel, false));
expect(json.config.spec.apiUrl).toBe(SECRET_PLACEHOLDER);
expect(json.config.spec.channel).toBe('#alerts');
expect(channelToJson(slackChannel, false)).not.toContain('SECRET');
});
it('reveals it only when asked', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json.config.spec.apiUrl).toBe(
'https://hooks.slack.com/services/T/B/SECRET',
);
});
it('leaves the timestamps and id out, so the JSON can be reused', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json).not.toHaveProperty('id');
expect(json).not.toHaveProperty('createdAt');
expect(json.name).toBe('prod-alerts');
});
it('knows which kinds carry a credential', () => {
expect(hasSecrets('slack')).toBe(true);
expect(hasSecrets('email')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import NotificationChannels from '../NotificationChannels';
/** The list is virtualized, so jsdom needs a viewport for rows to mount. */
export function renderChannels(): ReturnType<typeof render> {
return render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
<NotificationChannels />
</VirtuosoMockContext.Provider>,
);
}

View File

@@ -0,0 +1,15 @@
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
}
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import {
Braces,
Ellipsis,
Eye,
PenLine,
SquareArrowOutUpRight,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { useNotificationChannelPermissions } from 'hooks/notificationChannels/useNotificationChannelPermissions';
import styles from './ChannelActionsMenu.module.scss';
interface ChannelActionsMenuProps {
channel: AlertmanagertypesListedNotificationChannelDTO;
onOpen: (
channel: AlertmanagertypesListedNotificationChannelDTO,
options?: { newTab?: boolean },
) => void;
onDelete: (channel: AlertmanagertypesListedNotificationChannelDTO) => void;
onViewJson: (channel: AlertmanagertypesListedNotificationChannelDTO) => void;
}
function ChannelActionsMenu({
channel,
onOpen,
onDelete,
onViewJson,
}: ChannelActionsMenuProps): JSX.Element {
const { canEdit, canDelete } = useNotificationChannelPermissions(channel.id);
// Without `update` the same screen is still readable, so the action opens it
// as a view rather than disappearing.
const openLabel = canEdit ? 'Edit' : 'View';
const menuItems = useMemo(
() => [
{
key: 'open',
label: openLabel,
icon: canEdit ? <PenLine size={14} /> : <Eye size={14} />,
onClick: (): void => onOpen(channel),
},
{
key: 'open-new-tab',
label: 'Open in New Tab',
icon: <SquareArrowOutUpRight size={14} />,
onClick: (): void => onOpen(channel, { newTab: true }),
},
{
key: 'view-json',
label: 'View JSON',
icon: <Braces size={14} />,
onClick: (): void => onViewJson(channel),
},
{ key: 'divider', type: 'divider' as const },
{
key: 'delete',
label: 'Delete',
icon: <Trash2 size={14} />,
disabled: !canDelete,
danger: true,
onClick: (): void => onDelete(channel),
},
],
[openLabel, canEdit, canDelete, channel, onOpen, onDelete, onViewJson],
);
return (
// the row itself opens the channel, so the actions keep their clicks
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.actionButton}
data-testid={`channel-actions-${channel.id}`}
aria-label={`Actions for ${channel.displayName}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
</div>
);
}
export default ChannelActionsMenu;

View File

@@ -0,0 +1,3 @@
.loadError {
margin: var(--spacing-5) 0;
}

View File

@@ -0,0 +1,99 @@
import { useTranslation } from 'react-i18next';
import { Callout } from '@signozhq/ui/callout';
import Spinner from 'components/Spinner';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotificationChannelPermissions } from 'hooks/notificationChannels/useNotificationChannelPermissions';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { useChannelFormState } from './useChannelFormState';
import styles from './ChannelForm.module.scss';
interface ChannelFormProps {
/** Absent when creating. */
channelId?: string;
onDone: () => void;
}
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
const { t } = useTranslation('channels');
const {
formInstance,
type,
values,
setValues,
onTypeChange,
onSave,
onTest,
isSaving,
isTesting,
isLoading,
loadError,
} = useChannelFormState({ channelId, onDone });
// A reader with `read` but no `update` still gets the screen, without saving.
// While the check is in flight the answer is unknown, so the form stays
// editable rather than flashing read-only; the API is the real gate.
const {
canEdit,
isLoading: isPermissionLoading,
editChecks,
} = useNotificationChannelPermissions(channelId ?? '', {
enabled: !!channelId,
});
const readOnly = !!channelId && !isPermissionLoading && !canEdit;
// Saving an existing channel needs read and update on it; creating one needs
// create on the collection, which is also what a test send is gated on.
const saveChecks = channelId
? editChecks
: [NotificationChannelCreatePermission];
// A channel written through the v1 API can carry a configuration this API
// does not model, several notifier configs on one channel for instance.
if (loadError) {
return (
<Callout
type="error"
showIcon
title={loadError}
className={styles.loadError}
testId="channel-load-error"
>
This channel carries a configuration this form cannot represent, because it
was written through the v1 API. Recreate it here, or keep editing it through
that API.
</Callout>
);
}
if (isLoading) {
return <Spinner tip={t('loading_channels_message')} />;
}
return (
<FormAlertChannels
formInstance={formInstance}
type={type}
setSelectedConfig={setValues}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
savingState={isSaving}
testingState={isTesting}
title={
// eslint-disable-next-line no-nested-ternary
channelId
? readOnly
? t('page_title_view')
: t('page_title_edit')
: t('page_title_create')
}
initialValue={{ type, ...values }}
editing={!!channelId}
readOnly={readOnly}
saveChecks={saveChecks}
testChecks={[NotificationChannelCreatePermission]}
/>
);
}
export default ChannelForm;

View File

@@ -0,0 +1,260 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { Form, FormInstance } from 'antd';
import logEvent from 'api/common/logEvent';
import {
invalidateGetNotificationChannel,
invalidateListNotificationChannels,
useCreateNotificationChannel,
useGetNotificationChannel,
useTestNotificationChannel,
useUpdateNotificationChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import {
toPostableChannel,
toTestableChannel,
toUpdatableChannel,
} from 'container/CreateAlertChannels/channelConfig';
import {
ChannelFormState,
toChannelFormState,
} from 'container/CreateAlertChannels/channelFormValues';
import {
ChannelFormValues,
ChannelKind,
} from 'container/CreateAlertChannels/types';
import { ChannelInitialConfig } from 'container/CreateAlertChannels/defaults';
import { validateChannel } from 'container/CreateAlertChannels/validation';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toAPIError } from 'utils/errorUtils';
export interface ChannelFormStateResult {
formInstance: FormInstance;
type: ChannelKind;
values: ChannelFormValues;
setValues: React.Dispatch<React.SetStateAction<ChannelFormValues>>;
onTypeChange: (value: string) => void;
onSave: (type: ChannelKind) => Promise<void>;
onTest: (type: ChannelKind) => Promise<void>;
isSaving: boolean;
isTesting: boolean;
isLoading: boolean;
/** A channel the v2 API cannot model, or one that no longer exists. */
loadError: string | null;
}
interface UseChannelFormStateArgs {
channelId?: string;
onDone: () => void;
}
export function useChannelFormState({
channelId,
onDone,
}: UseChannelFormStateArgs): ChannelFormStateResult {
const { t } = useTranslation('channels');
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const [formInstance] = Form.useForm();
const isEditing = !!channelId;
const { data, error: loadFailure } = useGetNotificationChannel(
{ id: channelId ?? '' },
{ query: { enabled: isEditing } },
);
/**
* antd reads `initialValues` once, when the Form mounts, so the loaded channel
* has to be in state before the form renders. Creating starts from the
* defaults; editing stays null until the fetch lands and the caller holds the
* form back.
*/
const [draft, setDraft] = useState<ChannelFormState | null>(() =>
channelId
? null
: {
kind: ChannelKind.slack,
values: {
sendResolved: true,
...ChannelInitialConfig[ChannelKind.slack],
},
},
);
useEffect(() => {
if (data?.data) {
setDraft((current) => current ?? toChannelFormState(data.data));
}
}, [data]);
const type = draft?.kind ?? ChannelKind.slack;
const values = useMemo(() => draft?.values ?? {}, [draft]);
const setType = useCallback((next: ChannelKind): void => {
setDraft((current) => (current ? { ...current, kind: next } : current));
}, []);
const setValues: Dispatch<SetStateAction<ChannelFormValues>> = useCallback(
(update) => {
setDraft((current) => {
if (!current) {
return current;
}
const next = typeof update === 'function' ? update(current.values) : update;
return { ...current, values: next };
});
},
[],
);
const { mutateAsync: createChannel, isLoading: isCreating } =
useCreateNotificationChannel();
const { mutateAsync: updateChannel, isLoading: isUpdating } =
useUpdateNotificationChannel();
const { mutateAsync: testChannel, isLoading: isTesting } =
useTestNotificationChannel();
const onTypeChange = useCallback(
(value: string) => {
const nextType = value as ChannelKind;
if (nextType === type) {
return;
}
setType(nextType);
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setValues((current) => ({ ...current, ...defaults }));
formInstance.setFieldsValue(defaults);
},
[type, formInstance, setType, setValues],
);
const onSave = useCallback(
async (channelType: ChannelKind): Promise<void> => {
const validationError = validateChannel(channelType, values, t);
if (validationError) {
notifications.error({ message: 'Error', description: validationError });
return;
}
try {
if (isEditing) {
await updateChannel({
pathParams: { id: channelId as string },
data: toUpdatableChannel(channelType, values),
});
await invalidateGetNotificationChannel(queryClient, {
id: channelId as string,
});
} else {
await createChannel({ data: toPostableChannel(channelType, values) });
}
await invalidateListNotificationChannels(queryClient);
notifications.success({
message: 'Success',
description: isEditing
? t('channel_edit_done')
: t('channel_creation_done'),
});
void logEvent('Alert Channel: Save channel', {
type: channelType,
sendResolvedAlert: values.sendResolved,
name: values.name,
new: isEditing ? 'false' : 'true',
status: 'success',
});
onDone();
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
void logEvent('Alert Channel: Save channel', {
type: channelType,
sendResolvedAlert: values.sendResolved,
name: values.name,
new: isEditing ? 'false' : 'true',
status: 'failed',
});
}
},
[
values,
t,
notifications,
isEditing,
updateChannel,
channelId,
queryClient,
createChannel,
onDone,
showErrorModal,
],
);
const onTest = useCallback(
async (channelType: ChannelKind): Promise<void> => {
const validationError = validateChannel(channelType, values, t);
if (validationError) {
notifications.error({ message: 'Error', description: validationError });
return;
}
try {
await testChannel({ data: toTestableChannel(channelType, values) });
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
void logEvent('Alert Channel: Test notification', {
type: channelType,
name: values.name,
status: 'Test success',
});
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
void logEvent('Alert Channel: Test notification', {
type: channelType,
name: values.name,
status: 'Test failed',
});
}
},
[values, t, notifications, testChannel, showErrorModal],
);
return {
formInstance,
type,
values,
setValues,
onTypeChange,
onSave,
onTest,
isSaving: isCreating || isUpdating,
isTesting,
// A failed fetch never produces a draft, so the spinner has to yield to the
// error rather than wait for one.
isLoading: isEditing && !draft && !loadFailure,
loadError: loadFailure
? toAPIError(
loadFailure as ErrorType<RenderErrorResponseDTO>,
).getErrorMessage()
: null,
};
}

View File

@@ -0,0 +1,26 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
height: 100%;
min-height: 420px;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.spacer {
flex: 1 1 auto;
}
.editor {
flex: 1 1 auto;
min-height: 360px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}

View File

@@ -0,0 +1,131 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Check, Copy, Eye, EyeOff } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import Spinner from 'components/Spinner';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
defineJsonTheme,
JSON_THEME_DARK,
READONLY_EDITOR_OPTIONS,
} from 'container/RolesSettings/monaco.config';
import { channelToJson, hasSecrets } from '../../utils/channelJson';
import styles from './ChannelJsonDialog.module.scss';
interface ChannelJsonDialogProps {
channelId: string;
channelName: string;
onClose: () => void;
}
function ChannelJsonDialog({
channelId,
channelName,
onClose,
}: ChannelJsonDialogProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [showSecrets, setShowSecrets] = useState(false);
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
const channel = data?.data;
const json = useMemo(
() => (channel ? channelToJson(channel, showSecrets) : ''),
[channel, showSecrets],
);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback(
(): void => copyToClipboard(json),
[copyToClipboard, json],
);
const kindHasSecrets = channel ? hasSecrets(channel.config.kind) : false;
return (
<DrawerWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={channelName}
subTitle="Channel JSON"
width="wide"
testId="channel-json-dialog"
>
{isLoading || !channel ? (
<Spinner tip="Loading channel..." />
) : (
<div className={styles.body}>
<div className={styles.toolbar}>
{kindHasSecrets && (
<Typography.Text color="muted" size="sm">
{showSecrets ? 'Credentials are visible.' : 'Credentials are hidden.'}
</Typography.Text>
)}
<span className={styles.spacer} />
{kindHasSecrets && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={showSecrets ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={(): void => setShowSecrets((value) => !value)}
testId="channel-json-toggle-secrets"
>
{showSecrets ? 'Hide credentials' : 'Show credentials'}
</Button>
)}
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={
copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy size={14} />
)
}
onClick={handleCopy}
testId="channel-json-copy"
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<div className={styles.editor}>
<MEditor
value={json}
language="json"
options={READONLY_EDITOR_OPTIONS}
height="100%"
theme={isDarkMode ? JSON_THEME_DARK : 'light'}
beforeMount={defineJsonTheme}
/>
</div>
</div>
)}
</DrawerWrapper>
);
}
export default ChannelJsonDialog;

View File

@@ -0,0 +1,63 @@
import { Trash2, X } from '@signozhq/icons';
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
interface DeleteChannelDialogProps {
open: boolean;
channelName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Alert rules and routing policies reference a channel by name, so a delete can
// silence them. The destructive step stays behind an explicit confirm.
function DeleteChannelDialog({
open,
channelName,
isDeleting,
onConfirm,
onCancel,
}: DeleteChannelDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete notification channel"
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="channel-delete-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="channel-delete-confirm"
>
Delete
</Button>
</>
}
>
Are you sure you want to delete <strong>{channelName}</strong>? Alerts routed
to it will stop being delivered, and this cannot be undone.
</AlertDialog>
);
}
export default DeleteChannelDialog;

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