Compare commits

...

6 Commits

25 changed files with 718 additions and 140 deletions

View File

@@ -17,11 +17,9 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"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",
"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.",
"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",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"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",
@@ -82,7 +80,6 @@
"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",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"field_incidentio_metadata": "Additional metadata",
"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",
@@ -92,8 +89,8 @@
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -112,13 +109,10 @@
"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",
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"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",

View File

@@ -25,11 +25,9 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"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",
"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.",
"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",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"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",
@@ -90,7 +88,8 @@
"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",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"incidentio_token_bearer_prefix": "Paste the token only, without the Bearer prefix",
"incidentio_token_whitespace": "Remove the spaces around the token",
"field_incidentio_metadata": "Additional metadata",
"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",
@@ -116,6 +115,7 @@
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"slack_field_value_required": "Enter a value for this field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"label_slack_action_text": "Text",
@@ -133,6 +133,7 @@
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"slack_action_url_or_name_required": "Enter a URL or a name for this button",
"remove_slack_action": "Remove action",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
@@ -143,9 +144,15 @@
"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_webhook_bearer_token": "Bearer token (optional)",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_webhook_bearer_token": "Bearer token",
"field_webhook_auth": "Authentication",
"option_webhook_auth_none": "None",
"option_webhook_auth_basic": "Basic auth",
"option_webhook_auth_bearer": "Bearer token",
"webhook_basic_auth_required": "Enter a username or password",
"webhook_bearer_token_required": "Enter a bearer token",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -168,9 +175,7 @@
"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_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header.",
"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",
@@ -178,7 +183,6 @@
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"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",

View File

@@ -2,7 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
@@ -56,6 +56,22 @@ describe('toChannelConfig', () => {
});
});
it.each([
[WebhookAuth.none, {}],
[WebhookAuth.basic, { username: 'u', password: 'p' }],
[WebhookAuth.bearer, { bearerToken: 't' }],
])('sends only the %s webhook credentials', (webhookAuth, credentials) => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
webhookAuth,
username: 'u',
password: 'p',
bearerToken: 't',
}).spec,
).toStrictEqual({ url: 'https://example.com/hook', ...credentials });
});
it('drops an attachment list the user emptied instead of sending []', () => {
expect(
toChannelConfig(ChannelKind.slack, {

View File

@@ -18,8 +18,17 @@ import {
import { omit, pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration, filledRows } from './utils';
import {
ChannelFormValues,
ChannelKind,
ChannelSpecFormValues,
WebhookAuth,
} from './types';
import {
canonicalJiraReopenDuration,
filledRows,
webhookAuthOf,
} from './utils';
const SPEC_FIELDS = {
[ChannelKind.slack]: [
@@ -210,6 +219,17 @@ export function toChannelConfig(
);
}
if (kind === ChannelKind.webhook) {
const auth = webhookAuthOf(values);
if (auth !== WebhookAuth.basic) {
delete picked.username;
delete picked.password;
}
if (auth !== WebhookAuth.bearer) {
delete picked.bearerToken;
}
}
const spec: Record<string, unknown> = omitBlank(picked);
// the API answers a read with the duration prometheus would print, and

View File

@@ -1,6 +1,7 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
import { webhookAuthOf } from './utils';
export interface ChannelFormState {
kind: ChannelKind;
@@ -22,11 +23,15 @@ function toChannelKind(kind: string): ChannelKind {
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: toChannelKind(channel.config.kind),
values: {
...channel.config.spec,
name: channel.displayName,
},
const kind = toChannelKind(channel.config.kind);
const values: ChannelFormValues = {
...channel.config.spec,
name: channel.displayName,
};
if (kind === ChannelKind.webhook) {
values.webhookAuth = webhookAuthOf(values);
}
return { kind, values };
}

View File

@@ -1,4 +1,5 @@
import { TFunction } from 'i18next';
import { get, set } from 'lodash-es';
import type { FieldErrors, Resolver } from 'react-hook-form';
import type { $ZodIssue } from 'zod/v4/core';
@@ -10,12 +11,12 @@ const toFieldErrors = (
issues: $ZodIssue[],
t: TFunction,
): FieldErrors<ChannelFormValues> => {
const errors: Record<string, { type: string; message: string }> = {};
const errors = {};
issues.forEach((issue) => {
const field = issue.path.join('.') || 'name';
if (!errors[field]) {
errors[field] = { type: 'validation', message: t(issue.message) };
const path = issue.path.length ? issue.path : ['name'];
if (!get(errors, path)) {
set(errors, path, { type: 'validation', message: t(issue.message) });
}
});

View File

@@ -23,7 +23,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {

View File

@@ -1,11 +1,7 @@
import { z } from 'zod';
import { isValidGoogleChatWebhookURL } from '../utils';
import { requiredString } from './shared';
export const googleChatSchema = z.object({
webhookUrl: requiredString('webhook_url_required').refine(
isValidGoogleChatWebhookURL,
{ message: 'google_chat_webhook_url_invalid' },
),
webhookUrl: requiredString('webhook_url_required'),
});

View File

@@ -1,13 +1,19 @@
import { z } from 'zod';
import { isValidIncidentIOURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('incidentio_required_fields');
export const incidentIOSchema = z.object({
url: required.refine(isValidIncidentIOURL, {
message: 'incidentio_url_invalid',
}),
token: required,
url: required,
token: z
.string({ error: 'incidentio_required_fields' })
.refine((token) => token === token.trim(), {
message: 'incidentio_token_whitespace',
})
.pipe(
required.refine((token) => !/^bearer( |$)/i.test(token), {
message: 'incidentio_token_bearer_prefix',
}),
),
});

View File

@@ -1,12 +1,12 @@
import { z } from 'zod';
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
import { isValidJiraReopenDuration } from '../utils';
import { requiredString } from './shared';
const required = requiredString('jira_required_fields');
export const jiraSchema = z.object({
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
site: required,
email: required,
apiToken: required,
project: required,

View File

@@ -2,6 +2,30 @@ import { z } from 'zod';
import { requiredString } from './shared';
// rows with a blank title or text are dropped on save, so only the rest are checked
const fieldRow = z
.object({ title: z.string().optional(), value: z.string().optional() })
.refine((row) => !row.title?.trim() || !!row.value?.trim(), {
message: 'slack_field_value_required',
path: ['value'],
});
const actionRow = z
.object({
text: z.string().optional(),
url: z.string().optional(),
name: z.string().optional(),
})
.refine(
(row) => !row.text?.trim() || !!row.url?.trim() || !!row.name?.trim(),
{
message: 'slack_action_url_or_name_required',
path: ['url'],
},
);
export const slackSchema = z.object({
apiUrl: requiredString('webhook_url_required'),
fields: z.array(fieldRow).nullish(),
actions: z.array(actionRow).nullish(),
});

View File

@@ -1,16 +1,26 @@
import { z } from 'zod';
import { WebhookAuth } from '../types';
import { webhookAuthOf } from '../utils';
import { requiredString } from './shared';
export const webhookSchema = z
.object({
url: requiredString('webhook_url_required'),
webhookAuth: z.enum(WebhookAuth).optional(),
username: z.string().optional(),
password: z.string().optional(),
bearerToken: z.string().optional(),
})
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
.refine((v) => !v.username || !!v.password, {
message: 'username_no_password',
path: ['password'],
});
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.basic ||
!!values.username?.trim() ||
!!values.password?.trim(),
{ message: 'webhook_basic_auth_required', path: ['username'] },
)
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.bearer || !!values.bearerToken?.trim(),
{ message: 'webhook_bearer_token_required', path: ['bearerToken'] },
);

View File

@@ -31,6 +31,15 @@ export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelTelegramConfigDTO
>;
export const WebhookAuth = {
none: 'none',
basic: 'basic',
bearer: 'bearer',
} as const;
export type WebhookAuth = (typeof WebhookAuth)[keyof typeof WebhookAuth];
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
/** Never sent: the API reads the mode from which credentials are set. */
webhookAuth?: WebhookAuth;
};

View File

@@ -1,29 +1,4 @@
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' &&
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
);
} catch {
return false;
}
};
import { ChannelFormValues, WebhookAuth } from './types';
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
@@ -94,21 +69,21 @@ export const canonicalJiraReopenDuration = (value: string): string => {
}, '');
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
// the backend enforces the same rule, this is only for a nicer error experience
export const isValidIncidentIOURL = (url: string): boolean => {
try {
const { protocol, pathname } = new URL(url);
const idx = pathname.indexOf(INCIDENTIO_EVENTS_PATH_PREFIX);
return (
protocol === 'https:' &&
idx !== -1 &&
pathname.length > idx + INCIDENTIO_EVENTS_PATH_PREFIX.length
);
} catch {
return false;
export const webhookAuthOf = (
values: Pick<
ChannelFormValues,
'webhookAuth' | 'username' | 'password' | 'bearerToken'
>,
): WebhookAuth => {
if (values.webhookAuth) {
return values.webhookAuth;
}
if (values.bearerToken) {
return WebhookAuth.bearer;
}
return values.username || values.password
? WebhookAuth.basic
: WebhookAuth.none;
};
export const filledRows = <T>(

View File

@@ -0,0 +1,93 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import { validateChannel } from './validation';
const t = ((key: string) => key) as TFunction;
describe('validateChannel', () => {
it.each<[string, ChannelKind, ChannelFormValues, string | null]>([
[
'a google chat webhook behind a proxy',
ChannelKind.googlechat,
{ webhookUrl: 'http://relay.internal/chat' },
null,
],
[
'a jira site that is not on atlassian.net',
ChannelKind.jira,
{
site: 'https://jira-proxy.acme.com/',
email: 'a@acme.com',
apiToken: 't',
project: 'OPS',
issueType: 'Bug',
},
null,
],
[
'any incident.io url',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok' },
null,
],
[
'an incident.io token pasted with its Bearer prefix',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'Bearer tok' },
'incidentio_token_bearer_prefix',
],
[
'an incident.io token with a trailing space',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok ' },
'incidentio_token_whitespace',
],
[
'a webhook username without a password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic, username: 'u' },
null,
],
[
'basic auth with neither username nor password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic },
'webhook_basic_auth_required',
],
[
'bearer auth without a token',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.bearer, username: 'u' },
'webhook_bearer_token_required',
],
[
'a slack field without a value',
ChannelKind.slack,
{ apiUrl: 'https://hooks', fields: [{ title: 'env', value: '' }] },
'slack_field_value_required',
],
[
'a slack button with neither url nor name',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Runbook', url: '' }],
},
'slack_action_url_or_name_required',
],
[
'a slack button with a name only',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Ack', name: 'ack' }],
},
null,
],
])('%s', (_, kind, values, expected) => {
expect(validateChannel(kind, { name: 'channel', ...values }, t)).toBe(
expected,
);
});
});

View File

@@ -26,7 +26,11 @@ const COLUMNS = [
function SlackActions(): JSX.Element {
const { t } = useTranslation('channels');
const { control, register } = useFormContext<ChannelSpecFormValues>();
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
@@ -51,6 +55,9 @@ function SlackActions(): JSX.Element {
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
error={
column === 'url' ? errors.actions?.[index]?.url?.message : undefined
}
>
<Input
{...register(`actions.${index}.${column}` as const)}

View File

@@ -14,7 +14,11 @@ import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
function SlackFields(): JSX.Element {
const { t } = useTranslation('channels');
const { control, register } = useFormContext<ChannelSpecFormValues>();
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
@@ -46,6 +50,7 @@ function SlackFields(): JSX.Element {
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
error={errors.fields?.[index]?.value?.message}
>
<Input
{...register(`fields.${index}.value`)}

View File

@@ -0,0 +1,3 @@
.auth {
--radio-group-display: flex;
}

View File

@@ -1,17 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import {
ChannelFormValues,
WebhookAuth,
} from '../../CreateAlertChannels/types';
import { webhookAuthOf } from '../../CreateAlertChannels/utils';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './Webhook.module.scss';
function WebhookSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
} = useFormContext<ChannelFormValues>();
const [webhookAuth, username, password, bearerToken] = useWatch({
control,
name: ['webhookAuth', 'username', 'password', 'bearerToken'],
});
const auth = webhookAuthOf({ webhookAuth, username, password, bearerToken });
return (
<>
@@ -28,35 +41,75 @@ function WebhookSettings(): JSX.Element {
data-testid="webhook-url-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_username')}
help={t('help_webhook_username')}
error={errors.username?.message}
>
<Input {...register('username')} data-testid="webhook-username-textbox" />
</FormField>
<FormField
label="Password (optional)"
help={t('help_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
>
<Input
{...register('bearerToken')}
type="password"
data-testid="webhook-bearer-token-textbox"
<FormField label={t('field_webhook_auth')}>
<Controller
control={control}
name="webhookAuth"
render={({ field }): JSX.Element => (
<div className={styles.auth}>
<RadioGroup
color="primary"
aria-label={t('field_webhook_auth')}
value={auth}
onChange={field.onChange}
testId="webhook-auth-radio"
items={[
{
value: WebhookAuth.none,
label: t('option_webhook_auth_none'),
},
{
value: WebhookAuth.basic,
label: t('option_webhook_auth_basic'),
},
{
value: WebhookAuth.bearer,
label: t('option_webhook_auth_bearer'),
},
]}
/>
</div>
)}
/>
</FormField>
{auth === WebhookAuth.basic && (
<>
<FormField
label={t('field_webhook_username')}
error={errors.username?.message}
>
<Input
{...register('username')}
data-testid="webhook-username-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
</>
)}
{auth === WebhookAuth.bearer && (
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
required
>
<Input
{...register('bearerToken')}
type="password"
aria-required
data-testid="webhook-bearer-token-textbox"
/>
</FormField>
)}
</FormCard>
</>
);

View File

@@ -16,6 +16,8 @@ import {
notificationChannelResponse,
type ChannelActionOutcome,
type ChannelType,
WEBHOOK_AUTHS,
type WebhookAuthChoice,
} from '../../stories/__story_mockdata__/alerts';
const STORY_CHANNEL_ID = '1';
@@ -38,6 +40,13 @@ export const channelsEditMocks = defineStoryMocks({
options: CHANNEL_TYPES,
value: 'slack',
}),
webhookAuth: choiceControl<WebhookAuthChoice>('Webhook credentials', {
group: CHANNEL,
description:
'What the saved webhook authenticates with, which picks the auth option the form opens on. Only read when the channel type is webhook.',
options: WEBHOOK_AUTHS,
value: 'basic',
}),
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
@@ -53,7 +62,11 @@ export const channelsEditMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v2/notification_channels/:id',
response.json((req) =>
notificationChannelResponse(String(req.params.id), values.channelType),
notificationChannelResponse(
String(req.params.id),
values.channelType,
values.webhookAuth,
),
),
),

View File

@@ -117,6 +117,16 @@ export const Webhook: Story = {
args: { channelType: 'webhook' },
};
/** The webhook channel saved with a bearer token: only the token field shows. */
export const WebhookBearerToken: Story = {
args: { channelType: 'webhook', webhookAuth: 'bearer' },
};
/** The webhook channel saved without credentials: the auth option is None. */
export const WebhookWithoutAuth: Story = {
args: { channelType: 'webhook', webhookAuth: 'none' },
};
/**
* The Opsgenie channel, whose form carries the integration API key and the
* priority the alert is raised at.

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -42,6 +42,24 @@ const selectChannelType = async (
await within(trigger).findByText(label);
};
const fillName = async (
canvasElement: HTMLElement,
name: string,
): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByTestId('channel-name-textbox'),
name,
);
};
const sendTest = async (canvasElement: HTMLElement): Promise<void> => {
const test = within(canvasElement).getByTestId('test-channel-button');
await waitFor(() => expect(test).toHaveAttribute('aria-disabled', 'false'));
await userEvent.click(test);
await screen.findByText('An alert has been sent to this channel');
};
/** A new notification channel, on the Slack form the page opens with. */
export const Default: Story = {};
@@ -123,6 +141,79 @@ export const Webhook: Story = {
},
};
/**
* Basic auth picked on the webhook form: the username and password show, and
* saving with both empty asks for one of them.
*/
export const WebhookBasicAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-basic'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a username or password');
await expect(
canvas.queryByTestId('webhook-bearer-token-textbox'),
).not.toBeInTheDocument();
},
};
/**
* Bearer auth picked on the webhook form: only the token shows, and saving
* without one asks for it.
*/
export const WebhookBearerAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-bearer'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a bearer token');
await expect(
canvas.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
},
};
/**
* A Slack field with a title but no value, and a button with text but neither
* a URL nor a name. Each is flagged under its row rather than sent for the API
* to reject.
*/
export const SlackRowErrors: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await fillName(canvasElement, 'alerts-slack');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('slack-field-add'));
await userEvent.type(canvas.getByTestId('slack-field-title-0'), 'Region');
await userEvent.click(canvas.getByTestId('slack-action-add'));
await userEvent.type(canvas.getByTestId('slack-action-text-0'), 'Dashboard');
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a value for this field');
await canvas.findByText('Enter a URL or a name for this button');
},
};
/** The PagerDuty form: routing key, severity and the incident details. */
export const PagerDuty: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -151,16 +242,30 @@ export const MicrosoftTeams: Story = {
},
};
/**
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
* on `chat.googleapis.com`.
*/
/** The Google Chat form: the space's incoming webhook and the message text. */
export const GoogleChat: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
},
};
/**
* The Google Chat form pointed at a relay rather than `chat.googleapis.com`.
* The API accepts any webhook URL, so the test send goes through.
*/
export const GoogleChatRelayURL: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
await fillName(canvasElement, 'alerts-google-chat');
await userEvent.type(
within(canvasElement).getByTestId('webhook-url-textbox'),
'http://chat-relay.internal/v1/spaces/OPS/messages',
);
await sendTest(canvasElement);
},
};
/**
* The Jira form: where the issue is filed, the transitions that close and
* reopen it, and the Atlassian account the API token belongs to.
@@ -171,6 +276,29 @@ export const Jira: Story = {
},
};
/**
* The Jira form pointed at a proxy rather than an `atlassian.net` site. The API
* accepts any site URL, so the test send goes through.
*/
export const JiraProxySite: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Jira$/);
await fillName(canvasElement, 'alerts-jira');
await userEvent.type(
canvas.getByTestId('jira-site-textbox'),
'https://jira-proxy.acme.internal',
);
await userEvent.type(canvas.getByTestId('jira-email-textbox'), 'ops@acme.io');
await userEvent.type(canvas.getByTestId('jira-api-token-textbox'), 'token');
await userEvent.type(canvas.getByTestId('jira-project-textbox'), 'OPS');
await userEvent.type(canvas.getByTestId('jira-issue-type-textbox'), 'Bug');
await sendTest(canvasElement);
},
};
/** The Jira Service Management Ops form: the API key, priority and tags. */
export const JiraServiceManagementOps: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -185,6 +313,31 @@ export const IncidentIO: Story = {
},
};
/**
* The incident.io form pointed at a relay, which the API accepts, with the token
* pasted as the setup page shows it. The `Bearer ` prefix is flagged, since it
* would be sent twice.
*/
export const IncidentIOBearerToken: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^incident\.io$/);
await fillName(canvasElement, 'alerts-incidentio');
await userEvent.type(
canvas.getByTestId('incidentio-url-textbox'),
'https://incidentio-relay.internal/alerts',
);
await userEvent.type(
canvas.getByTestId('incidentio-token-textbox'),
'Bearer story-token',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Paste the token only, without the Bearer prefix');
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -45,15 +45,18 @@ const CASES: PrefillCase[] = [
url: 'https://example.com/hook',
username: 'hook-user',
password: 'hook-pass',
bearerToken: 'hook-token',
},
fields: [
['webhook-url-textbox', 'https://example.com/hook'],
['webhook-username-textbox', 'hook-user'],
['webhook-password-textbox', 'hook-pass'],
['webhook-bearer-token-textbox', 'hook-token'],
],
},
{
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'hook-token' },
fields: [['webhook-bearer-token-textbox', 'hook-token']],
},
{
kind: 'email',
spec: { to: 'oncall@signoz.io', html: '<p>alert</p>' },

View File

@@ -118,6 +118,104 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends only the credentials of the picked webhook auth',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Webhook$/ }));
await user.type(screen.getByTestId('channel-name-textbox'), 'hook alerts');
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await user.click(screen.getByTestId('webhook-auth-radio-item-basic'));
await user.type(screen.getByTestId('webhook-username-textbox'), 'hook-user');
await user.click(screen.getByTestId('webhook-auth-radio-item-bearer'));
expect(
screen.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'hook-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
expect(
(body as { config: { spec: Record<string, unknown> } }).config.spec,
).toStrictEqual({
url: 'https://example.com/hook',
bearerToken: 'hook-token',
sendResolved: true,
});
},
TEST_TIMEOUT,
);
it(
'flags a slack field without a value instead of sending it',
async () => {
const onCreate = jest.fn();
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
(_, res, ctx) => {
onCreate();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(
await screen.findByTestId('channel-name-textbox', {}, FIND),
'slack alerts',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await user.click(screen.getByTestId('slack-field-add'));
await user.type(screen.getByTestId('slack-field-title-0'), 'env');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await screen.findByText('slack_field_value_required', {}, FIND);
expect(onCreate).not.toHaveBeenCalled();
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -165,6 +263,74 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps bearer auth while a saved token is cleared and replaced',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.get(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: '3',
name: 'webhook-channel',
displayName: 'webhook channel',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'old-token' },
},
},
}),
),
),
rest.put(
'http://localhost/api/v2/notification_channels/:id',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
},
),
);
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const token = await screen.findByTestId(
'webhook-bearer-token-textbox',
{},
FIND,
);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.clear(token);
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'new-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
expect(body).toStrictEqual({
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'new-token' },
},
});
},
TEST_TIMEOUT,
);
it(
'opens read-only, with saving disabled rather than hidden, when update is denied',
async () => {

View File

@@ -781,6 +781,22 @@ export const channelNames = (count: number): string[] =>
(_unused, index) => CHANNEL_SEEDS[index % CHANNEL_SEEDS.length].name,
);
export const WEBHOOK_AUTHS = ['basic', 'bearer', 'none'] as const;
export type WebhookAuthChoice = (typeof WEBHOOK_AUTHS)[number];
const WEBHOOK_CREDENTIALS: Record<WebhookAuthChoice, Record<string, string>> = {
basic: { username: 'story-user' },
bearer: { bearerToken: 'story-bearer-token' },
none: {},
};
const webhookSpec = (auth: WebhookAuthChoice): Record<string, unknown> => ({
url: 'https://example.com/story-hook',
...WEBHOOK_CREDENTIALS[auth],
sendResolved: true,
});
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
@@ -809,11 +825,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
],
sendResolved: true,
},
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
webhook: webhookSpec('basic'),
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
@@ -877,6 +889,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
export const notificationChannelResponse = (
id: string,
type: ChannelType,
webhookAuth: WebhookAuthChoice = 'basic',
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
@@ -897,7 +910,7 @@ export const notificationChannelResponse = (
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: CHANNEL_SPECS[type],
spec: type === 'webhook' ? webhookSpec(webhookAuth) : CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};