From 5ca691e6d80eecbfca4f738c9426df572c0fce17 Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:43:21 -0700 Subject: [PATCH 1/4] feat(app): list all webhook template variables in the webhook form The form advertised seven variables while the Generic and incident.io transports render nineteen, so the ten enriched variables added in #3057 were undiscoverable from the UI. List every variable from buildWebhookTemplateVariables with a one-line description, in a two-column grid, and note the JSON-escaping and empty-string-fallback behaviour. --- .changeset/webhook-form-template-variables.md | 10 +++ .../components/TeamSettings/WebhookForm.tsx | 83 ++++++++++++++++--- .../__tests__/WebhookForm.test.tsx | 73 ++++++++++++++++ 3 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 .changeset/webhook-form-template-variables.md create mode 100644 packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx diff --git a/.changeset/webhook-form-template-variables.md b/.changeset/webhook-form-template-variables.md new file mode 100644 index 0000000000..0f90e71506 --- /dev/null +++ b/.changeset/webhook-form-template-variables.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': patch +--- + +List every supported template variable in the webhook form, including the +enriched set added to Generic and incident.io bodies (`{{alertId}}`, +`{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`, `{{value}}`, +`{{groupKey}}`, `{{sourceQuery}}`, `{{teamId}}`, `{{note}}` and ISO-8601 +`{{startTimeISO}}` / `{{endTimeISO}}`). Each variable now carries a one-line +description, so a webhook body can be written without leaving the form. diff --git a/packages/app/src/components/TeamSettings/WebhookForm.tsx b/packages/app/src/components/TeamSettings/WebhookForm.tsx index 4bc4d3886c..b136beb7f5 100644 --- a/packages/app/src/components/TeamSettings/WebhookForm.tsx +++ b/packages/app/src/components/TeamSettings/WebhookForm.tsx @@ -13,8 +13,10 @@ import { isValidSlackUrl } from '@hyperdx/common-utils/dist/validation'; import { Alert, Button, + Code, Group, Radio, + SimpleGrid, Stack, Text, TextInput, @@ -42,6 +44,51 @@ const DEFAULT_GENERIC_WEBHOOK_BODY = [ const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE = DEFAULT_GENERIC_WEBHOOK_BODY.join(' | '); +// Mirrors buildWebhookTemplateVariables in +// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when +// variables are added or removed there. +export const getWebhookTemplateVariables = ( + brandName: string, +): { name: string; description: string }[] => [ + { name: '{{title}}', description: 'Alert title' }, + { name: '{{body}}', description: 'Rendered message body (markdown)' }, + { name: '{{link}}', description: `Deep link back into ${brandName}` }, + { name: '{{state}}', description: 'Raw internal alert state' }, + { + name: '{{status}}', + description: 'firing, resolved, no_data, pending or error', + }, + { name: '{{eventId}}', description: 'Unique id for this firing' }, + { + name: '{{alertId}}', + description: 'Stable id of the alert — the key to dedupe on', + }, + { + name: '{{alertType}}', + description: 'search, dashboard_chart or inline_query', + }, + { + name: '{{comparator}}', + description: '>=, >, <, <=, =, !=, between or outside', + }, + { name: '{{threshold}}', description: 'The configured threshold (number)' }, + { + name: '{{value}}', + description: 'Value that triggered or resolved the alert (number)', + }, + { name: '{{groupKey}}', description: 'The breaching group, if grouped' }, + { + name: '{{sourceQuery}}', + description: 'Search expression or SQL behind the alert', + }, + { name: '{{startTime}}', description: 'Window start, Unix ms (number)' }, + { name: '{{endTime}}', description: 'Window end, Unix ms (number)' }, + { name: '{{startTimeISO}}', description: 'Window start, ISO-8601' }, + { name: '{{endTimeISO}}', description: 'Window end, ISO-8601' }, + { name: '{{teamId}}', description: 'Team the alert belongs to' }, + { name: '{{note}}', description: "Alert's note, commonly a runbook link" }, +]; + const jsonLinterWithEmptyCheck = () => (editorView: EditorView) => { const text = editorView.state.doc.toString().trim(); if (text === '') return []; @@ -277,6 +324,7 @@ export function WebhookForm({ }; const service = useWatch({ control: form.control, name: 'service' }); + const templateVariables = getWebhookTemplateVariables(brandName); const headersText = useWatch({ control: form.control, name: 'headers' }); const hasMaskedHeaders = isEditing && !!headersText?.includes('****'); @@ -401,19 +449,30 @@ export function WebhookForm({ className="mb-4" color="gray" > - - Currently the body supports the following message template - variables: - -
- - {DEFAULT_GENERIC_WEBHOOK_BODY.map((body, index) => ( - - {body} - {index < DEFAULT_GENERIC_WEBHOOK_BODY.length - 1 && ', '} - + + The body supports the following template variables: + + + {templateVariables.map(({ name, description }) => ( + + {name} + + {description} + + ))} - + + + Strings are JSON-escaped, so they are safe inside quotes. Numbers + are emitted raw for unquoted slots. A variable the alert + doesn't carry renders as an empty string. + , ]} diff --git a/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx b/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx new file mode 100644 index 0000000000..dedd97335d --- /dev/null +++ b/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx @@ -0,0 +1,73 @@ +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + getWebhookTemplateVariables, + WebhookForm, +} from '@/components/TeamSettings/WebhookForm'; + +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useSaveWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }), + useUpdateWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }), + useTestWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }), + }, +})); + +// CodeMirror needs layout APIs jsdom doesn't provide. +jest.mock('@uiw/react-codemirror', () => ({ + __esModule: true, + default: ({ + value, + onChange, + }: { + value?: string; + onChange?: (value: string) => void; + }) => ( +