Compare commits

..

5 Commits

Author SHA1 Message Date
aks07
4efc9f64fc style(bottom-strip): let the strip text take its size from the components 2026-09-25 16:52:11 +05:30
aks07
b764450445 style(bottom-strip): use spacing tokens and drop the inherited font rules 2026-09-25 09:57:45 +05:30
aks07
ef3e09c0b1 feat(bottom-strip): add the layout shell behind a feature flag
Mounts a 24px strip at the bottom of the app under the same gate as the side
nav, behind the SAVED_VIEW_ENABLED localStorage flag shared with saved views.
Shows the build version on the left for now; the per-page left slot and the
right side actions come in later tickets.

To give the strip a stable box, `.app-content` becomes a column flex and
`LayoutContent` takes the height left over instead of `height: 100%`. Fixed
bottom elements read `--bottom-strip-height`, which only exists while the strip
is mounted, so with the flag off every offset falls back to where it is today.

Hides nothing. Each later ticket hides the piece it replaces.
2026-09-24 06:13:04 +00:00
Aditya Singh
720810d424 fix(quick-filters): filter sidebar scrolls the whole page (#12915)
#### Description

- Quick filters sidebar scrolls the whole page instead of scrolling
itself, so the top nav, module tabs and the table scroll away with it.
Happens on traces, llm observability, api monitoring, exceptions and
meter.. logs is the only page behaving today.
- Cause is antd Tabs.. it never passes height down to the tab pane, and
every module page wrapped `RouteTab` in a plain div, so the page inside
was never bounded. Pages that wanted their own scroll each kept a
private copy of the same `.ant-tabs` override, traces and meter never
had one.
- `RouteTab` now owns the height chain and scrolls each pane's content,
so the tab bar stays put on every tabbed page. Module pages pass their
class to `RouteTab` instead of wrapping it, and the six copied overrides
are gone.
- New `QuickFiltersLayout` gives the explorer pages a bounded two pane
layout.. 280px sidebar that scrolls itself, content that scrolls itself.
Traces, llm, api monitoring, exceptions and meter are on it now.
- Logs and infra are unchanged here, both move to the shared layout in
follow ups.
- Also drops the per page viewport heights on the quick filter settings
drawer.. with the pane bounded, `height: 100%` is enough, and the
save/discard footer stays reachable with the banners on.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/6088
Closes https://github.com/SigNoz/engineering-pod/issues/6104
Part of https://github.com/SigNoz/engineering-pod/issues/5946


#### Screenshots/ recordings



Banner fix [BEFORE]


https://github.com/user-attachments/assets/44bc98f8-7ce9-45a7-9c45-e86648c40f69

Banner fix [AFTER]



https://github.com/user-attachments/assets/c1140a2a-f00e-4685-87e4-4a0f5d72623a



Quick Filter Whole Page scroll Fix
[BEFORE]



https://github.com/user-attachments/assets/07bd42a0-db80-4d14-bf8a-bcea01fb70b1




[AFTER]




https://github.com/user-attachments/assets/7bd14951-2595-43cd-8eab-dcb57cdce011





#### Additional Information

- The `RouteTab` change touches every tabbed page, not just the quick
filter ones. Checked traces, logs, exceptions, api monitoring, meter,
infra hosts, metrics summary, funnels, saved views, pipelines, mq, logs
settings, settings (org + members) and alert details, with the trial
banner on and off. llm observability is feature gated on my instance so
it is not checked in the browser.
- Behaviour change to call out: top nav and tab bar are now fixed on all
tabbed pages. Pages that mount `RouteTab` inside a block wrapper (alert
details, the exceptions inner tabs) are unaffected, the scroller is
inert there.
- Sidebar is 280px everywhere now, was a 260/280 mix.
- Pane content that needs a bounded box should size with `height:
100%`.. `flex: 1` does nothing inside the scroller viewport (documented
on `RouteTab`).
- cc. @H4ad
2026-09-24 06:01:23 +00:00
Ashwin Bhatkal
7424885a14 chore(codeowners): add alert code ownership to pulse-frontend (#12977)
#### Description

Paths that belong to alerts and notification channels added to
CODEOWNERS

- `container/FormAlertChannels/`
- `hooks/notificationChannels/`
- `container/RoutingPolicies/`
- `components/AlertBreadcrumb/`
- `container/EditRules/`
- `components/AlertDetailsFilters/`
- `components/Alerts/`
- `hooks/routingPolicies/`
- `types/api/alerts/`
- `providers/Alert.tsx`
- `constants/alerts.ts`

All go to `@SigNoz/pulse-frontend`, matching the rest of those blocks.
2026-09-24 04:29:31 +00:00
180 changed files with 6299 additions and 4556 deletions

11
.github/CODEOWNERS vendored
View File

@@ -200,6 +200,15 @@ go.mod @therealpandey
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -207,6 +216,8 @@ go.mod @therealpandey
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv

View File

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

View File

@@ -15,104 +15,14 @@
"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).",
"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_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/).",
"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/).",
"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_email_to": "Enter email addresses separated by commas.",
"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",
@@ -168,14 +78,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 → Alert routing → Sources) and paste its URL and token below.",
"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_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 — incident.io ignores content updates on repeat events.",
"help_incidentio_title": "Template for the alert title. Kept stable while the alert fires \u2014 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",
@@ -184,5 +94,62 @@
"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"
}
"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"
}

View File

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

View File

@@ -1,28 +0,0 @@
import ROUTES from 'constants/routes';
import routes from '../routes';
/**
* The channel form renders inside the alerts page, which owns the tab strip.
* Pointing these routes at the standalone pages tears that page down on every
* open and rebuilds it on the way back, which reads as a full reload.
*/
describe('channel routes', () => {
const findRoute = (key: string): (typeof routes)[number] | undefined =>
routes.find((route) => route.key === key);
it('mounts the same component as the alerts list', () => {
const list = findRoute('LIST_ALL_ALERT');
const create = findRoute('CHANNELS_NEW');
const edit = findRoute('CHANNELS_EDIT');
expect(list?.component).toBeDefined();
expect(create?.component).toBe(list?.component);
expect(edit?.component).toBe(list?.component);
});
it('keeps the channel paths under /alerts', () => {
expect(ROUTES.CHANNELS_NEW.startsWith('/alerts')).toBe(true);
expect(ROUTES.CHANNELS_EDIT.startsWith('/alerts')).toBe(true);
});
});

View File

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

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,43 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,48 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,59 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,30 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/delete';
/**
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteChannel = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default deleteChannel;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editEmail';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editEmail;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editMsTeams;

View File

@@ -0,0 +1,44 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
opsgenie_configs: [
{
send_resolved: props.send_resolved,
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editOpsgenie;

View File

@@ -0,0 +1,48 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editPager';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editPager;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editSlack';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editSlack;

View File

@@ -0,0 +1,59 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editWebhook;

View File

@@ -0,0 +1,29 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/get';
import { Channels } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
try {
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default get;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Channels, PayloadProps } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
try {
const response = await axios.get<PayloadProps>('/channels');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getAll;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
const testEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
email_configs: [
{
send_resolved: true,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testEmail;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
const testMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: true,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testMsTeams;

View File

@@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const testOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testOpsgenie;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
const testPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
pagerduty_configs: [
{
send_resolved: true,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testPager;

View File

@@ -0,0 +1,34 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
const testSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
slack_configs: [
{
send_resolved: true,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testSlack;

View File

@@ -0,0 +1,52 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
const testWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
webhook_configs: [
{
send_resolved: true,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testWebhook;

View File

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

View File

@@ -2,6 +2,8 @@
display: flex;
flex-direction: row;
position: relative;
flex: 1;
min-height: 0;
.quick-filters-settings-container {
flex: 0 0 0;

View File

@@ -0,0 +1,33 @@
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
// only hand height down; each pane below owns its own scroll.
.layout {
display: flex;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
overflow: visible;
z-index: 2;
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}

View File

@@ -0,0 +1,54 @@
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuickFilters from '../QuickFilters';
import styles from './QuickFiltersLayout.module.scss';
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
typeof QuickFilters,
ComponentProps<typeof QuickFilters>
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
testId?: string;
children: ReactNode;
}
function QuickFiltersLayout({
quickFilterProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
<aside
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
className={cx(styles.content, contentClassName)}
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>
);
}
export default QuickFiltersLayout;

View File

@@ -0,0 +1,79 @@
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
content: 'content',
},
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
describe('QuickFiltersLayout', () => {
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
QuickFiltersSource.TRACES_EXPLORER,
);
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
);
});
});

View File

@@ -6,27 +6,12 @@
left: 0;
z-index: 999;
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
&.qf-logs-explorer {
height: calc(100vh - 45px);
}
&.qf-exceptions {
height: 100vh;
}
&.qf-api-monitoring {
height: calc(100vh - 45px);
}
&.qf-traces-explorer {
height: calc(100vh - 45px);
}
&.hidden {
width: 0;
}

View File

@@ -0,0 +1,38 @@
// Hands the parent's height down to the active pane and lets the pane scroll
// its own content, so TopNav and the tab bar stay put. Child combinators only
// (nested Tabs must not be caught).
.routeTab {
flex: 1;
min-height: 0;
}
.routeTab > :global(.ant-tabs-content-holder) {
display: flex;
flex-direction: column;
}
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active)
> :global(.overlay-scrollbar) {
flex: 1;
min-height: 0;
}

View File

@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
import RouteTab from './index';
import { RouteTabProps } from './types';
jest.mock('./RouteTab.module.scss', () => ({
__esModule: true,
default: { routeTab: 'routeTab' },
}));
function DummyComponent1(): JSX.Element {
return <div>Dummy Component 1</div>;
}
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
expect(history.location.pathname).toBe('/tab2');
});
it('applies the layout class alongside a custom className', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab
history={history}
routes={testRoutes}
activeKey="Tab1"
className="custom-tabs"
/>
</Router>,
);
expect(container.querySelector('.ant-tabs')).toHaveClass(
'routeTab',
'custom-tabs',
);
});
it('renders the active tab content inside an overlay scrollbar', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
).toHaveTextContent('Dummy Component 1');
});
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();

View File

@@ -5,20 +5,32 @@ import {
useParams,
} from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { RouteTabProps } from './types';
import styles from './RouteTab.module.scss';
interface Params {
[key: string]: string;
}
/**
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
* a plain block wrapper the scroller is inert and the page scrolls as usual.
* Pane content that needs a bounded box must size itself with `height: 100%`
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
*/
function RouteTab({
routes,
activeKey,
onChangeHandler,
history,
showRightSection,
className,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
@@ -50,11 +62,16 @@ function RouteTab({
label: name,
key,
tabKey: route,
children: <Component />,
children: (
<OverlayScrollbar>
<Component />
</OverlayScrollbar>
),
}));
return (
<Tabs
className={cx(styles.routeTab, className)}
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}

View File

@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { Button } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import Delete from './Delete';
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { t } = useTranslation(['channels']);
const { notifications } = useNotifications();
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
);
}, []);
const columns: ColumnsType<Channels> = [
{
title: t('column_channel_name'),
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: t('column_channel_type'),
dataIndex: 'type',
key: 'type',
width: 80,
},
];
if (action) {
columns.push({
title: t('column_channel_action'),
dataIndex: 'id',
key: 'action',
align: 'center',
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />
</>
),
});
}
return (
<ResizeTable
columns={columns}
dataSource={allChannels}
rowKey="id"
bordered
/>
);
}
interface AlertChannelsProps {
allChannels: Channels[];
}
export default AlertChannels;

View File

@@ -0,0 +1,4 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
const { t } = useTranslation(['channels']);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const onClickHandler = async (): Promise<void> => {
try {
setLoading(true);
await deleteChannel({
id,
});
notifications.success({
message: 'Success',
description: t('channel_delete_success'),
});
// Invalidate and refetch
queryClient.invalidateQueries(['getChannels']);
setLoading(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setLoading(false);
}
};
return (
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
);
}
interface DeleteProps {
notifications: NotificationInstance;
id: string;
}
export default Delete;

View File

@@ -0,0 +1,84 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
});
it('Should check if "New Alert Channel" Button is visble', () => {
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.getByText('column_channel_action')).toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument();
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
});
it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => {
const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0];
expect(deleteButton).toBeInTheDocument();
act(() => {
fireEvent.click(deleteButton);
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_delete_success',
});
});
});
});
});

View File

@@ -0,0 +1,78 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('hooks/useComponentPermission', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => [false]),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
it('Should check if "New Alert Channel" Button is visble and disabled', async () => {
const newAlertButton = screen.getByRole('button', {
name: /button_new_channel/i,
});
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
expect(newAlertButton).toBeDisabled();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', async () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', async () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Tooltip, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import AlertChannelsComponent from './AlertChannels';
import { Button, ButtonContainer, RightActionContainer } from './styles';
import './AllAlertChannels.styles.scss';
const { Text } = Typography;
function AlertChannels(): JSX.Element {
const { t } = useTranslation(['channels']);
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
}, []);
const { isLoading, data, error } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
useEffect(() => {
if (!isUndefined(data?.data)) {
logEvent('Alert Channel: Channel list page visited', {
number: data?.data?.length,
});
}
}, [data?.data]);
if (error) {
return <Typography>{error.getErrorMessage()}</Typography>;
}
if (isLoading || isUndefined(data?.data)) {
return <Spinner tip={t('loading_channels_message')} height="90vh" />;
}
return (
<div className="alert-channels-container">
<ButtonContainer>
<Text truncate={1} color="muted">
{t('sending_channels_note')}
</Text>
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create alert channel'
: undefined
}
>
<Button onClick={onToggleHandler} disabled={!addNewChannelPermission}>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> {t('button_new_channel')}
</Flex>
</Button>
</Tooltip>
</RightActionContainer>
</ButtonContainer>
<AlertChannelsComponent allChannels={data?.data || []} />
</div>
);
}
export default AlertChannels;

View File

@@ -0,0 +1,26 @@
import { Button as ButtonComponent } from 'antd';
import styled from 'styled-components';
export const RightActionContainer = styled.div`
&&& {
display: flex;
align-items: center;
}
`;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
margin-bottom: 1rem;
padding-right: 1rem;
}
`;
export const Button = styled(ButtonComponent)`
&&& {
margin-left: 1rem;
}
`;

View File

@@ -1,23 +1,15 @@
.api-monitoring-page {
display: flex;
height: 100%;
.api-monitoring-explorer {
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
.api-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 18px;
}
font-size: 14px;
line-height: 18px;
}
.api-module-right-section {
@@ -161,16 +153,6 @@
}
}
}
&.filter-visible {
.api-quick-filter-left-section {
width: 260px;
}
.api-module-right-section {
width: calc(100% - 260px);
}
}
}
.no-filtered-domains-message-container {

View File

@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
signal={SignalType.API_MONITORING}
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
useFieldApis={quickFilterFieldApis}
/>
</section>
<QuickFiltersLayout
className="api-monitoring-explorer"
showFilters
quickFilterProps={{
className: 'qf-api-monitoring',
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
showFilterCollapse: false,
showQueryName: false,
handleFilterVisibilityChange: (): void => {},
useFieldApis: quickFilterFieldApis,
}}
>
<DomainList />
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -53,6 +53,10 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -70,7 +74,9 @@
.chat-support-gateway {
position: fixed;
bottom: 20px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;

View File

@@ -43,6 +43,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -51,6 +52,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (

View File

@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
height: 100%;
flex: 1;
min-height: 0;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -0,0 +1,36 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: var(--spacing-6);
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,49 @@
import { render } from 'tests/test-utils';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
describe('BottomStrip', () => {
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}
export default BottomStrip;

View File

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

View File

@@ -1,121 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
};
describe('toChannelConfig', () => {
it('sends every field the slack spec models, new ones included', () => {
expect(toChannelConfig(ChannelKind.slack, slackValues)).toStrictEqual({
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
});
});
it('drops 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

@@ -1,162 +0,0 @@
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

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

View File

@@ -1,137 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelKind } from './types';
/**
* Loading a channel and saving it again without touching anything must send the
* API exactly what it returned. A field the form model drops silently loses a
* user's configuration on the next save, because the update replaces the whole
* config.
*/
const SPECS: Record<string, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#ops',
title: 'title',
titleLink: 'https://signoz.io',
text: 'body',
pretext: 'pre',
fallback: 'fall',
footer: 'foot',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
webhook: {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {
to: 'oncall@signoz.io',
html: '<p>a</p>',
headers: { 'X-Team': 'ops' },
sendResolved: true,
},
pagerduty: {
routingKey: 'k',
client: 'c',
clientUrl: 'https://c',
description: 'd',
severity: 'critical',
component: 'comp',
group: 'grp',
class: 'cls',
url: 'https://events.pagerduty.com',
details: { firing: '{{ .Alerts.Firing }}' },
sendResolved: true,
},
opsgenie: {
apiKey: 'k',
apiUrl: 'https://api.opsgenie.com',
message: 'm',
description: 'd',
source: 's',
priority: 'P1',
details: { env: 'prod' },
sendResolved: true,
},
msteams: {
webhookUrl: 'https://teams',
title: 't',
text: 'b',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
title: 't',
text: 'b',
sendResolved: true,
},
jira: {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
summary: 's',
description: 'd',
priority: 'High',
labels: ['one', 'two'],
resolveTransition: 'Done',
reopenTransition: 'Reopen',
wontFixResolution: 'WontFix',
reopenDuration: '72h',
customFields: { customfield_1: 'v' },
sendResolved: true,
},
jsmops: {
apiKey: 'k',
message: 'm',
description: 'd',
priority: 'P2',
tags: 'prod,db',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/abc',
token: 'tok',
title: 't',
description: 'd',
metadata: { team: 'ops' },
sendResolved: true,
},
};
describe('load then save round trip', () => {
it.each(Object.keys(SPECS))('keeps every %s field', (kind) => {
const channel = {
id: '1',
name: `${kind}-channel`,
displayName: `${kind} channel`,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: { kind, spec: SPECS[kind] },
} as unknown as AlertmanagertypesGettableNotificationChannelDTO;
const { kind: loadedKind, values } = toChannelFormState(channel);
const saved = toUpdatableChannel(loadedKind, values);
expect(loadedKind).toBe(kind as ChannelKind);
expect(saved.config).toStrictEqual(channel.config);
});
it('never sends the display name inside the spec', () => {
const config = toChannelConfig(ChannelKind.slack, {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/x',
});
expect(config.spec).not.toHaveProperty('name');
});
});

View File

@@ -0,0 +1,194 @@
export interface Channel {
send_resolved?: boolean;
name: string;
filter?: Partial<Array<LabelFilterStatement>>;
}
export interface SlackChannel extends Channel {
api_url?: string;
channel?: string;
title?: string;
text?: string;
}
export interface WebhookChannel extends Channel {
api_url?: string;
// basic auth
username?: string;
password?: string;
}
// PagerChannel configures alert manager to send
// events to pagerduty
export interface PagerChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config
routing_key?: string;
// displays source of the event in pager duty
client?: string;
client_url?: string;
// A description of the incident
description?: string;
// Severity of the incident
severity?: string;
// The part or component of the affected system that is broken
component?: string;
// A cluster or grouping of sources
group?: string;
// The class/type of the event.
class?: string;
details?: string;
detailsArray?: Record<string, string>;
}
// OpsgenieChannel configures alert manager to send
// events to opsgenie
export interface OpsgenieChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
api_key: string;
message?: string;
// A description of the incident
description?: string;
// A backlink to the sender of the notification.
source?: string;
// A set of arbitrary key/value pairs that provide further detail
// about the alert.
details?: string;
detailsArray?: Record<string, string>;
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
priority?: string;
}
export interface EmailChannel extends Channel {
// comma separated list of email addresses to send alerts to
to: string;
// HTML body of the email notification.
html: string;
// Further headers email header key/value pairs.
// [ headers: { <string>: <tmpl_string>, ... } ]
headers: Record<string, string>;
}
export const ValidatePagerChannel = (p: PagerChannel): string => {
if (!p) {
return 'Received unexpected input for this channel, please contact your administrator ';
}
if (!p.name || p.name === '') {
return 'Name is mandatory for creating a channel';
}
if (!p.routing_key || p.routing_key === '') {
return 'Routing Key is mandatory for creating pagerduty channel';
}
// validate details json
try {
JSON.parse(p.details || '{}');
} catch (e) {
return 'failed to parse additional information, please enter a valid json';
}
return '';
};
export enum ChannelType {
Slack = 'slack',
Email = 'email',
Webhook = 'webhook',
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
IncidentIO = 'incidentio',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
export interface LabelFilterStatement {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher
// label name
name: string;
// comparators supported by promql are =, !=, =~, or !~. =
comparator: string;
// filter value
value: string;
}
export interface MsTeamsChannel extends Channel {
webhook_url?: string;
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// issues resolved with this resolution are never reopened; a refire
// creates a new issue instead
wont_fix_resolution?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// IncidentIOChannel configures the incident.io alert channel, backed by an
// incident.io HTTP alert source (Alert Events V2 API).
export interface IncidentIOChannel extends Channel {
// per-source alert events URL, e.g.
// https://api.incident.io/v2/alert_events/http/<source_config_id>
url: string;
// the alert source's secret token
token: string;
// alert title template
title?: string;
// alert body template (markdown, rendered natively by incident.io)
description?: string;
// extra metadata pairs merged over the alert's labels (channel wins on clash)
metadata?: Record<string, string>;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,105 +0,0 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind } from './types';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from './utils';
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
const requireWebhookUrl: Validator = (values, t) =>
values.webhookUrl ? null : t('webhook_url_required');
const requireApiKey: Validator = (values, t) =>
values.apiKey ? null : t('api_key_required');
const validateSlack: Validator = (values, t) =>
values.apiUrl ? null : t('webhook_url_required');
const validateWebhook: Validator = (values, t) => {
if (!values.url) {
return t('webhook_url_required');
}
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
return values.username && !values.password ? t('username_no_password') : null;
};
const validatePagerduty: Validator = (values, t) =>
values.routingKey ? null : t('routing_key_required');
const validateEmail: Validator = (values, t) =>
values.to ? null : t('to_required');
const validateGoogleChat: Validator = (values, t) => {
if (!values.webhookUrl) {
return t('webhook_url_required');
}
return isValidGoogleChatWebhookURL(values.webhookUrl)
? null
: t('google_chat_webhook_url_invalid');
};
const validateJira: Validator = (values, t) => {
if (
!values.site ||
!values.email ||
!values.apiToken ||
!values.project ||
!values.issueType
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(values.site)) {
return t('jira_site_invalid');
}
if (
values.reopenDuration &&
!isValidJiraReopenDuration(values.reopenDuration)
) {
return t('jira_reopen_duration_invalid');
}
return null;
};
const validateIncidentIO: Validator = (values, t) => {
if (!values.url || !values.token) {
return t('incidentio_required_fields');
}
return isValidIncidentIOURL(values.url) ? null : t('incidentio_url_invalid');
};
const VALIDATORS: Record<ChannelKind, Validator> = {
[ChannelKind.slack]: validateSlack,
[ChannelKind.webhook]: validateWebhook,
[ChannelKind.pagerduty]: validatePagerduty,
[ChannelKind.opsgenie]: requireApiKey,
[ChannelKind.jsmops]: requireApiKey,
[ChannelKind.email]: validateEmail,
[ChannelKind.msteams]: requireWebhookUrl,
[ChannelKind.googlechat]: validateGoogleChat,
[ChannelKind.jira]: validateJira,
[ChannelKind.incidentio]: validateIncidentIO,
};
/**
* Client-side validation for the fields the API rejects outright, so a save
* round trip is not spent on an obviously incomplete form. 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,8 +1,12 @@
import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -17,11 +21,14 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const {
data: channels,
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useChannelOptions();
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
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 { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: ChannelOption[] = [];
const mockChannels: Channels[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,17 +85,16 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
jest.mock('api/channels/getAll', () => ({
__esModule: true,
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
}));
// Mock alert format categories

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 { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: ChannelOption[] = [
const mockChannels: Channels[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,

View File

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

View File

@@ -1,6 +1,8 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

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

View File

@@ -1,6 +1,8 @@
.explorer-options-container {
position: fixed;
bottom: 0px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,6 +1,8 @@
.explorer-option-droppable-container {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: -webkit-fill-available;
height: 24px;
display: flex;

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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { EmailChannel } from '../../CreateAlertChannels/config';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -43,7 +43,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
}
export default EmailForm;

View File

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

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 { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
interface MetadataRow {
key: string;
@@ -23,7 +23,7 @@ function IncidentIOSettings({
})),
);
const update = (patch: Partial<ChannelSpecFormValues>): void =>
const update = (patch: Partial<IncidentIOChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
@@ -161,7 +161,7 @@ function IncidentIOSettings({
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
initialMetadata?: Record<string, string>;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,123 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface SlackActionsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
}
const emptyAction: AlertmanagertypesChannelSlackActionDTO = {
type: 'button',
text: '',
url: '',
};
// Buttons Slack renders under the attachment. `type` and `text` are required by
// the API; a `button` carrying a url is the link-out case, the rest drive a
// Slack app's own callbacks.
function SlackActions({
setSelectedConfig,
initialActions,
}: SlackActionsProps): JSX.Element {
const { t } = useTranslation('channels');
const [rows, setRows] = useState<AlertmanagertypesChannelSlackActionDTO[]>(
() => initialActions ?? [],
);
const sync = (next: AlertmanagertypesChannelSlackActionDTO[]): void => {
setRows(next);
setSelectedConfig((value) => ({
...value,
actions: next.filter((row) => row.text.trim() !== ''),
}));
};
const updateRow = (
index: number,
patch: Partial<AlertmanagertypesChannelSlackActionDTO>,
): void =>
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<Form.Item label={t('field_slack_actions')} help={t('help_slack_actions')}>
{rows.map((row, index) => (
// the rows have no stable id, and reordering is not offered
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="slack-actions-row">
<Input
value={row.text}
placeholder={t('placeholder_slack_action_text')}
onChange={(event): void => updateRow(index, { text: event.target.value })}
data-testid={`slack-action-text-${index}`}
/>
<Input
value={row.url}
placeholder={t('placeholder_slack_action_url')}
onChange={(event): void => updateRow(index, { url: event.target.value })}
data-testid={`slack-action-url-${index}`}
/>
<Input
value={row.type}
placeholder={t('placeholder_slack_action_type')}
onChange={(event): void => updateRow(index, { type: event.target.value })}
data-testid={`slack-action-type-${index}`}
/>
<Input
value={row.name ?? ''}
placeholder={t('placeholder_slack_action_name')}
onChange={(event): void => updateRow(index, { name: event.target.value })}
data-testid={`slack-action-name-${index}`}
/>
<Input
value={row.value ?? ''}
placeholder={t('placeholder_slack_action_value')}
onChange={(event): void =>
updateRow(index, { value: event.target.value })
}
data-testid={`slack-action-value-${index}`}
/>
<Input
value={row.style ?? ''}
placeholder={t('placeholder_slack_action_style')}
onChange={(event): void =>
updateRow(index, { style: event.target.value })
}
data-testid={`slack-action-style-${index}`}
/>
<Input
value={row.confirm?.text ?? ''}
placeholder={t('placeholder_slack_action_confirm')}
onChange={(event): void =>
updateRow(index, {
confirm: event.target.value ? { text: event.target.value } : undefined,
})
}
data-testid={`slack-action-confirm-${index}`}
/>
<Button
type="text"
icon={<Minus size={14} />}
aria-label={t('remove_slack_action')}
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
data-testid={`slack-action-remove-${index}`}
/>
</div>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void => sync([...rows, { ...emptyAction }])}
data-testid="slack-action-add"
>
{t('add_slack_action')}
</Button>
</Form.Item>
);
}
export default SlackActions;

View File

@@ -1,95 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Checkbox, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface SlackFieldsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
}
// Slack renders these as the attachment's table of short or full-width entries.
function SlackFields({
setSelectedConfig,
initialFields,
}: SlackFieldsProps): JSX.Element {
const { t } = useTranslation('channels');
const [rows, setRows] = useState<AlertmanagertypesChannelSlackFieldDTO[]>(
() => initialFields ?? [],
);
const sync = (next: AlertmanagertypesChannelSlackFieldDTO[]): void => {
setRows(next);
setSelectedConfig((value) => ({
...value,
fields: next.filter((row) => row.title.trim() !== ''),
}));
};
const updateRow = (
index: number,
patch: Partial<AlertmanagertypesChannelSlackFieldDTO>,
): void =>
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<Form.Item label={t('field_slack_fields')} help={t('help_slack_fields')}>
{rows.map((row, index) => (
// the rows have no stable id, and reordering is not offered
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="slack-fields-row">
<Input
value={row.title}
placeholder={t('placeholder_slack_field_title')}
onChange={(event): void =>
updateRow(index, { title: event.target.value })
}
data-testid={`slack-field-title-${index}`}
/>
<Input
value={row.value}
placeholder={t('placeholder_slack_field_value')}
onChange={(event): void =>
updateRow(index, { value: event.target.value })
}
data-testid={`slack-field-value-${index}`}
/>
<Checkbox
checked={!!row.short}
onChange={(event): void =>
updateRow(index, { short: event.target.checked })
}
data-testid={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
<Button
type="text"
icon={<Minus size={14} />}
aria-label={t('remove_slack_field')}
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
data-testid={`slack-field-remove-${index}`}
/>
</div>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void =>
sync([...rows, { title: '', value: '', short: false }])
}
data-testid="slack-field-add"
>
{t('add_slack_field')}
</Button>
</Form.Item>
);
}
export default SlackFields;

View File

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

View File

@@ -7,11 +7,17 @@ import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';
import {
ChannelKind,
ChannelSpecFormValues,
} from 'container/CreateAlertChannels/types';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import history from 'lib/history';
import EmailSettings from './Settings/Email';
@@ -38,49 +44,35 @@ function FormAlertChannels({
title,
initialValue,
editing = false,
readOnly = false,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
const { t } = useTranslation('channels');
const renderSettings = (): ReactElement | null => {
switch (type) {
case ChannelKind.slack:
return (
<SlackSettings
setSelectedConfig={setSelectedConfig}
initialFields={initialValue?.fields as ChannelSpecFormValues['fields']}
initialActions={initialValue?.actions as ChannelSpecFormValues['actions']}
/>
);
case ChannelKind.webhook:
case ChannelType.Slack:
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Webhook:
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.pagerduty:
return (
<PagerSettings
setSelectedConfig={setSelectedConfig}
initialDetails={initialValue?.details as Record<string, string>}
/>
);
case ChannelKind.msteams:
case ChannelType.Pagerduty:
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.googlechat:
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.jira:
case ChannelType.Jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.jsmops:
case ChannelType.JsmOps:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.incidentio:
case ChannelType.IncidentIO:
return (
<IncidentIOSettings
setSelectedConfig={setSelectedConfig}
initialMetadata={initialValue?.metadata as Record<string, string>}
/>
);
case ChannelKind.opsgenie:
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.email:
case ChannelType.Email:
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
default:
return null;
@@ -93,18 +85,8 @@ function FormAlertChannels({
{title}
</Typography.Title>
<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}
>
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
<Input
data-testid="channel-name-textbox"
disabled={editing}
@@ -120,15 +102,15 @@ function FormAlertChannels({
<Form.Item
label={t('field_send_resolved')}
labelAlign="left"
name="sendResolved"
name="send_resolved"
>
<Switch
defaultValue={initialValue?.sendResolved}
defaultValue={initialValue?.send_resolved}
testId="field-send-resolved-checkbox"
onChange={(value): void => {
setSelectedConfig((state) => ({
...state,
sendResolved: value,
send_resolved: value,
}));
}}
/>
@@ -198,30 +180,25 @@ function FormAlertChannels({
<Form.Item>{renderSettings()}</Form.Item>
<Form.Item>
<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="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
disabled={false}
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
@@ -236,30 +213,35 @@ function FormAlertChannels({
interface FormAlertChannelsProps {
formInstance: FormInstance;
type: ChannelKind;
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: (props: ChannelKind) => void;
onTestHandler: (props: ChannelKind) => void;
type: ChannelType;
setSelectedConfig: Dispatch<
SetStateAction<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>
>;
onTypeChangeHandler: (value: ChannelType) => void;
onSaveHandler: (props: ChannelType) => void;
onTestHandler: (props: ChannelType) => void;
testingState: boolean;
savingState: boolean;
title: string;
initialValue: Store;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the 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,15 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
@@ -43,16 +47,18 @@ function BasicInfo({
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { isLoading, data, error, isError, refetch } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
useState(false);
@@ -75,7 +81,7 @@ function BasicInfo({
});
};
const noChannels = channels.length === 0;
const noChannels = data?.data?.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
@@ -90,7 +96,7 @@ function BasicInfo({
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: channels.length,
numberOfChannels: data?.data?.length,
});
hasLoggedEvent.current = true;
}
@@ -226,7 +232,7 @@ function BasicInfo({
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={channels}
channels={data?.data || []}
isLoading={isLoading}
hasError={isError}
error={error as APIError}

View File

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

View File

@@ -65,8 +65,6 @@
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
@@ -75,32 +73,8 @@
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
<QuickFiltersLayout
className="trace-explorer-page"
data-testid="llm-observability-explorer"
testId="llm-observability-explorer"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.AI_OBSERVABILITY,
signal: SignalType.AI_OBSERVABILITY,
useFieldApis: quickFiltersFieldApis,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
}}
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
)}
</div>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -1,18 +1,7 @@
.meter-explorer-container {
display: flex;
flex-direction: row;
.meter-explorer-quick-filters-section {
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
}
.meter-explorer-content-section {
width: 100%;
// Clearance for the fixed ExplorerOptions bar.
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
@@ -83,14 +72,6 @@
}
}
}
&.quick-filters-open {
.meter-explorer-content-section {
width: calc(100% - 280px);
}
}
padding-bottom: 80px;
}
.dashboards-and-alerts-popover-container {

View File

@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className={cx('meter-explorer-container', {
'quick-filters-open': showQuickFilters,
})}
<QuickFiltersLayout
className="meter-explorer-container"
showFilters={showQuickFilters}
quickFilterProps={{
className: 'qf-meter-explorer',
source: QuickFiltersSource.METER_EXPLORER,
signal: SignalType.METER_EXPLORER,
showFilterCollapse: true,
showQueryName: false,
handleFilterVisibilityChange: (): void => {
setShowQuickFilters(!showQuickFilters);
},
useFieldApis: quickFilterFieldApis,
}}
>
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
>
<QuickFilters
className="qf-meter-explorer"
source={QuickFiltersSource.METER_EXPLORER}
signal={SignalType.METER_EXPLORER}
showFilterCollapse
showQueryName={false}
handleFilterVisibilityChange={(): void => {
setShowQuickFilters(!showQuickFilters);
}}
useFieldApis={quickFilterFieldApis}
/>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">
<div className="explore-header">
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
splitedQueries={splitedQueries}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -60,9 +60,6 @@
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
@@ -184,7 +181,9 @@
.ant-pagination {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: calc(100% - 54px);
background: var(--l1-background);
padding: 16px;

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(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -337,7 +337,6 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'serviceaccount',
@@ -423,7 +422,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -431,7 +430,6 @@ describe('createEmptyRolePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'serviceaccount',

View File

@@ -1,6 +1,5 @@
import {
Bot,
Cable,
ChartLine,
DraftingCompass,
FileKey,
@@ -96,15 +95,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
'notification-channel': {
label: 'Notification Channels',
description:
'Destinations for alert notifications, such as Slack, PagerDuty, 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 { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -28,13 +28,21 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: ChannelOption = {
export const MOCK_CHANNEL_1: Channels = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: ChannelOption = {
export const MOCK_CHANNEL_2: Channels = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(

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