diff --git a/frontend/public/locales/en-GB/channels.json b/frontend/public/locales/en-GB/channels.json index 8553d8c88d6..9043f73bb5a 100644 --- a/frontend/public/locales/en-GB/channels.json +++ b/frontend/public/locales/en-GB/channels.json @@ -26,6 +26,55 @@ "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", + "field_jira_site": "Site URL", + "tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.", + "jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain", + "jira_required_fields": "Site URL, email, API token, project and issue type are required", + "jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.", + "jira_service_account_tip_link": "Learn how", + "field_jira_email": "Email", + "help_jira_email": "The Atlassian account email used for authentication.", + "field_jira_api_token": "API token", + "help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.", + "field_jira_project": "Project key", + "field_jira_issue_type": "Issue type", + "help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.", + "field_jira_summary": "Summary (issue title)", + "help_jira_summary": "Template for the Jira issue title.", + "field_jira_description": "Description", + "help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.", + "jira_advanced_section": "Advanced Options", + "field_jira_priority": "Priority", + "placeholder_jira_priority": "Leave empty to use the project default", + "help_jira_priority": "Must match a priority in the project's scheme, e.g. High.", + "field_jira_labels": "Labels", + "placeholder_jira_labels": "Type a label and press Enter", + "help_jira_labels": "signoz and a deduplication label are added automatically.", + "field_jira_resolve_transition": "Resolve transition", + "field_jira_reopen_transition": "Reopen transition", + "help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.", + "help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).", + "placeholder_jira_resolve_transition": "Auto-detected, e.g. Done", + "placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do", + "field_jira_reopen_duration": "Reopen window", + "placeholder_jira_reopen_duration": "e.g. 72h", + "help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.", + "tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.", + "jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)", + "jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.", + "jsmops_tip_link": "Learn how", + "field_jsmops_api_key": "API key", + "help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.", + "field_jsmops_message": "Message (alert title)", + "help_jsmops_message": "Template for the alert title. Truncated to 130 characters.", + "field_jsmops_description": "Description", + "help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.", + "jsmops_advanced_section": "Advanced Options", + "field_jsmops_priority": "Priority", + "help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.", + "field_jsmops_tags": "Tags", + "placeholder_jsmops_tags": "Type a tag and press Enter", + "help_jsmops_tags": "Tags added to every alert.", "field_slack_recipient": "Recipient", "field_slack_title": "Title", diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 26786a32b96..915d8770904 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -26,6 +26,55 @@ "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", + "field_jira_site": "Site URL", + "tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.", + "jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain", + "jira_required_fields": "Site URL, email, API token, project and issue type are required", + "jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.", + "jira_service_account_tip_link": "Learn how", + "field_jira_email": "Email", + "help_jira_email": "The Atlassian account email used for authentication.", + "field_jira_api_token": "API token", + "help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.", + "field_jira_project": "Project key", + "field_jira_issue_type": "Issue type", + "help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.", + "field_jira_summary": "Summary (issue title)", + "help_jira_summary": "Template for the Jira issue title.", + "field_jira_description": "Description", + "help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.", + "jira_advanced_section": "Advanced Options", + "field_jira_priority": "Priority", + "placeholder_jira_priority": "Leave empty to use the project default", + "help_jira_priority": "Must match a priority in the project's scheme, e.g. High.", + "field_jira_labels": "Labels", + "placeholder_jira_labels": "Type a label and press Enter", + "help_jira_labels": "signoz and a deduplication label are added automatically.", + "field_jira_resolve_transition": "Resolve transition", + "field_jira_reopen_transition": "Reopen transition", + "help_jira_resolve_transition": "When the alert resolves, SigNoz moves the Jira issue to a \"Done\" status via a workflow transition. This is auto-detected — leave it empty unless your project has more than one \"Done\" transition (e.g. Done vs. Won't Do) and you want to force a specific one by name.", + "help_jira_reopen_transition": "When a resolved alert fires again (within the reopen window), SigNoz moves the issue back out of \"Done\" to an active status via a workflow transition. This is auto-detected — leave it empty unless you want to force a specific one by name (e.g. To Do or Reopen).", + "placeholder_jira_resolve_transition": "Auto-detected, e.g. Done", + "placeholder_jira_reopen_transition": "Auto-detected, e.g. To Do", + "field_jira_reopen_duration": "Reopen window", + "placeholder_jira_reopen_duration": "e.g. 72h", + "help_jira_reopen_duration": "If a resolved alert fires again within this window, the same ticket is reopened; after the window, a re-fire opens a new ticket instead. Default: 3d.", + "tooltip_jira_reopen_duration": "Accepted units: m (minutes), h (hours), d (days), w (weeks), y (years) — e.g. 30m, 72h or 3d. Minimum 1m.", + "jira_reopen_duration_invalid": "Reopen window must be a duration like 30m, 72h or 3d (minimum 1m)", + "jsmops_tip": "Create an API integration on your JSM team's Operations page and paste its key below.", + "jsmops_tip_link": "Learn how", + "field_jsmops_api_key": "API key", + "help_jsmops_api_key": "The JSM Ops integration API key, from your team's Operations → Integrations → API. Make sure the integration is turned on.", + "field_jsmops_message": "Message (alert title)", + "help_jsmops_message": "Template for the alert title. Truncated to 130 characters.", + "field_jsmops_description": "Description", + "help_jsmops_description": "Template for the alert description. Rendered as rich text; kept under 15,000 characters.", + "jsmops_advanced_section": "Advanced Options", + "field_jsmops_priority": "Priority", + "help_jsmops_priority": "Template resolving to one of P1–P5. Leave as-is to map from alert severity.", + "field_jsmops_tags": "Tags", + "placeholder_jsmops_tags": "Type a tag and press Enter", + "help_jsmops_tags": "Tags added to every alert.", "field_slack_recipient": "Recipient", "field_slack_title": "Title", "field_slack_description": "Description", diff --git a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx index 101c6eda784..3c57da96205 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx @@ -1,6 +1,10 @@ import CreateAlertChannels from 'container/CreateAlertChannels'; import { ChannelType } from 'container/CreateAlertChannels/config'; -import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults'; +import { + GoogleChatInitialConfig, + JiraInitialConfig, + JsmOpsInitialConfig, +} from 'container/CreateAlertChannels/defaults'; import { googleChatDescriptionDefaultValue, googleChatTitleDefaultValue, @@ -526,6 +530,213 @@ describe('Create Alert Channel', () => { }); }); }); + describe('Jira', () => { + const validSite = 'https://acme.atlassian.net'; + + const fillRequired = async ( + user: ReturnType, + site: string, + ): Promise => { + 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(); + }); + + 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', + }), + ); + }); + + 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.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, + 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(); + }); + + 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('Changing the channel type', () => { async function selectType( user: ReturnType, diff --git a/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannelSave.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannelSave.test.tsx index cb56bce9a61..fe4326ea059 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannelSave.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannelSave.test.tsx @@ -58,6 +58,38 @@ describe('EditAlertChannels save', () => { 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( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByTestId('save-channel-button')); + expect(edit.calls).toHaveLength(0); + unmount(); + + render( + , + ); + await user.click(screen.getByTestId('save-channel-button')); + await waitFor(() => expect(edit.calls).toHaveLength(1)); + }); + it('persists send_resolved toggle in the edit request', async () => { const edit = mockEditChannel(); render( diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index 59ca50ac0bc..ce8a443f1b9 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -105,6 +105,8 @@ export enum ChannelType { Opsgenie = 'opsgenie', MsTeams = 'msteams', GoogleChat = 'googlechat', + Jira = 'jira', + JsmOps = 'jsmops', } // LabelFilterStatement will be used for preparing filter conditions / matchers @@ -134,3 +136,39 @@ export interface GoogleChatChannel extends Channel { 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; + // duration string, e.g. 72h or 3d + reopen_duration?: 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[]; +} diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts index 3214babfd6c..e15fe36e1e4 100644 --- a/frontend/src/container/CreateAlertChannels/defaults.ts +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -2,6 +2,8 @@ import { ChannelType, EmailChannel, GoogleChatChannel, + JiraChannel, + JsmOpsChannel, MsTeamsChannel, OpsgenieChannel, PagerChannel, @@ -47,6 +49,23 @@ export const GoogleChatInitialConfig: Partial = { {{ 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 = { + 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 }} +{{ end }}{{ if .Annotations.description }} +**Description:** {{ .Annotations.description }} +{{ end }} +{{ end }}`, +}; + export const PagerInitialConfig: Partial = { description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} {{- if gt (len .CommonLabels) (len .GroupLabels) -}} @@ -98,6 +117,33 @@ export const OpsgenieInitialConfig: Partial = { '{{ 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 = { + 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 }} + +{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }} + +{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }}) + +{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }}) + +{{ 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'], +}; + export const EmailInitialConfig: Partial = { send_resolved: true, html: `