From 621a7a432bbb1904fe999ba677e2d523e45ee1d1 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 24 Jun 2026 12:09:45 -0700 Subject: [PATCH] Add Datadog PAT connect flow to monitoring providers UI --- .../components/datadogPatConnectModal.tsx | 144 ++++++++++ .../components/monitoringProviders.spec.tsx | 249 ++++++++++++++++++ .../components/monitoringProviders.tsx | 21 ++ 3 files changed, 414 insertions(+) create mode 100644 static/gsApp/views/seerAutomation/components/datadogPatConnectModal.tsx diff --git a/static/gsApp/views/seerAutomation/components/datadogPatConnectModal.tsx b/static/gsApp/views/seerAutomation/components/datadogPatConnectModal.tsx new file mode 100644 index 00000000000000..a5c3747100430b --- /dev/null +++ b/static/gsApp/views/seerAutomation/components/datadogPatConnectModal.tsx @@ -0,0 +1,144 @@ +import {useState} from 'react'; +import styled from '@emotion/styled'; +import {useMutation} from '@tanstack/react-query'; + +import {Button} from '@sentry/scraps/button'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Input} from '@sentry/scraps/input'; +import {Flex} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Text} from '@sentry/scraps/text'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import type {ModalRenderProps} from 'sentry/actionCreators/modal'; +import {t} from 'sentry/locale'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; + +const DATADOG_SITES = [ + {value: 'datadoghq.com', label: 'datadoghq.com (US1)'}, + {value: 'us3.datadoghq.com', label: 'us3.datadoghq.com (US3)'}, + {value: 'us5.datadoghq.com', label: 'us5.datadoghq.com (US5)'}, + {value: 'datadoghq.eu', label: 'datadoghq.eu (EU)'}, + {value: 'ddog-gov.com', label: 'ddog-gov.com (US1-FED)'}, + {value: 'us2.ddog-gov.com', label: 'us2.ddog-gov.com (US2-FED)'}, + {value: 'ap1.datadoghq.com', label: 'ap1.datadoghq.com (AP1)'}, + {value: 'ap2.datadoghq.com', label: 'ap2.datadoghq.com (AP2)'}, +]; + +interface DatadogPatConnectModalProps extends ModalRenderProps { + onSuccess: () => void; + orgSlug: string; +} + +export function DatadogPatConnectModal({ + Header, + Body, + Footer, + closeModal, + onSuccess, + orgSlug, +}: DatadogPatConnectModalProps) { + const [accessToken, setAccessToken] = useState(''); + const [site, setSite] = useState('datadoghq.com'); + const [formError, setFormError] = useState(null); + + const connectMutation = useMutation({ + mutationFn: () => + fetchMutation({ + method: 'POST', + url: getApiUrl( + '/organizations/$organizationIdOrSlug/monitoring-providers/$providerKey/', + { + path: { + organizationIdOrSlug: orgSlug, + providerKey: 'datadog_pat', + }, + } + ), + data: {access_token: accessToken, site}, + }), + onSuccess: () => { + closeModal(); + onSuccess(); + }, + onError: (error: Error) => { + if (error instanceof RequestError && error.responseJSON?.detail) { + const detail = error.responseJSON.detail; + setFormError(typeof detail === 'string' ? detail : (detail.message ?? '')); + } else { + addErrorMessage(t('Failed to connect provider.')); + } + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + connectMutation.mutate(); + } + + return ( +
+
+

{t('Connect Datadog (Personal Access Token)')}

+
+ + + + + {t('Access Token')} + + setAccessToken(e.target.value)} + placeholder={t('Enter your Datadog personal access token')} + aria-label={t('Access Token')} + /> + + + {t('Datadog Site')} + setSite(String(option.value))} + trigger={triggerProps => ( + + )} + /> + + {formError ? {formError} : null} + + +
+ + + + +
+
+ ); +} + +const StyledCompactSelect = styled(CompactSelect)` + width: 100%; + + > button { + width: 100%; + } +`; + +const ErrorText = styled('div')` + color: ${p => p.theme.tokens.content.danger}; + margin-top: ${p => p.theme.space.sm}; +`; diff --git a/static/gsApp/views/seerAutomation/components/monitoringProviders.spec.tsx b/static/gsApp/views/seerAutomation/components/monitoringProviders.spec.tsx index cc10a694ad2760..7b5beae2845e96 100644 --- a/static/gsApp/views/seerAutomation/components/monitoringProviders.spec.tsx +++ b/static/gsApp/views/seerAutomation/components/monitoringProviders.spec.tsx @@ -6,6 +6,7 @@ import { screen, userEvent, waitFor, + within, } from 'sentry-test/reactTestingLibrary'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; @@ -146,3 +147,251 @@ describe('MonitoringProvidersSection', () => { ).toBeInTheDocument(); }); }); + +describe('Datadog PAT flow', () => { + const organization = OrganizationFixture({ + features: ['seer-infra-telemetry'], + }); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('renders PAT provider in list', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + render(, {organization}); + + expect( + await screen.findByText('Datadog (Personal Access Token)') + ).toBeInTheDocument(); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('connect on PAT provider opens modal instead of redirecting', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Connect'})); + + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByText('Connect Datadog (Personal Access Token)') + ).toBeInTheDocument(); + expect(within(dialog).getByLabelText('Access Token')).toBeInTheDocument(); + expect(within(dialog).getByText('Datadog Site')).toBeInTheDocument(); + expect(testableWindowLocation.assign).not.toHaveBeenCalled(); + }); + + it('PAT modal submits token and shows success', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + const connectMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/datadog_pat/`, + method: 'POST', + statusCode: 204, + match: [ + MockApiClient.matchData({access_token: 'my-pat-token', site: 'datadoghq.com'}), + ], + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Connect'})); + + const dialog = await screen.findByRole('dialog'); + await userEvent.type(within(dialog).getByLabelText('Access Token'), 'my-pat-token'); + + // Override GET mock before submit so refetch returns updated state + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: true, + }, + ], + }, + }); + + await userEvent.click(within(dialog).getByRole('button', {name: 'Connect'})); + + await waitFor(() => expect(connectMock).toHaveBeenCalled()); + expect(await screen.findByText('Connected')).toBeInTheDocument(); + }); + + it('PAT modal shows validation error', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/datadog_pat/`, + method: 'POST', + statusCode: 400, + body: {detail: 'Failed to verify token with provider.'}, + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Connect'})); + + const dialog = await screen.findByRole('dialog'); + await userEvent.type(within(dialog).getByLabelText('Access Token'), 'bad-token'); + await userEvent.click(within(dialog).getByRole('button', {name: 'Connect'})); + + expect( + await screen.findByText('Failed to verify token with provider.') + ).toBeInTheDocument(); + }); + + it('PAT modal shows conflict error', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/datadog_pat/`, + method: 'POST', + statusCode: 409, + body: {detail: 'This account is already connected.'}, + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Connect'})); + + const dialog = await screen.findByRole('dialog'); + await userEvent.type(within(dialog).getByLabelText('Access Token'), 'my-token'); + await userEvent.click(within(dialog).getByRole('button', {name: 'Connect'})); + + expect( + await screen.findByText('This account is already connected.') + ).toBeInTheDocument(); + }); + + it('disconnect works for PAT provider', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: true, + }, + ], + }, + }); + + const deleteMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/datadog_pat/`, + method: 'DELETE', + statusCode: 204, + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Disconnect'})); + await userEvent.click(await screen.findByRole('button', {name: 'Confirm'})); + + await waitFor(() => expect(deleteMock).toHaveBeenCalled()); + }); + + it('cancel closes modal without submitting', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/`, + body: { + providers: [ + { + provider: 'datadog_pat', + name: 'Datadog (Personal Access Token)', + connected: false, + }, + ], + }, + }); + + const connectMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/monitoring-providers/datadog_pat/`, + method: 'POST', + statusCode: 204, + }); + + render(, {organization}); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Connect'})); + expect( + await screen.findByText('Connect Datadog (Personal Access Token)') + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + await waitFor(() => { + expect( + screen.queryByText('Connect Datadog (Personal Access Token)') + ).not.toBeInTheDocument(); + }); + expect(connectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/static/gsApp/views/seerAutomation/components/monitoringProviders.tsx b/static/gsApp/views/seerAutomation/components/monitoringProviders.tsx index 3e42dad6558e93..13cd0f6125608d 100644 --- a/static/gsApp/views/seerAutomation/components/monitoringProviders.tsx +++ b/static/gsApp/views/seerAutomation/components/monitoringProviders.tsx @@ -5,6 +5,7 @@ import {Container, Flex} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {openModal} from 'sentry/actionCreators/modal'; import {openConfirmModal} from 'sentry/components/confirm'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -15,6 +16,8 @@ import {fetchMutation} from 'sentry/utils/queryClient'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {DatadogPatConnectModal} from 'getsentry/views/seerAutomation/components/datadogPatConnectModal'; + type MonitoringProvider = { connected: boolean; name: string; @@ -25,6 +28,8 @@ type MonitoringProvidersResponse = { providers: MonitoringProvider[]; }; +const PAT_PROVIDERS = new Set(['datadog_pat']); + function monitoringProvidersQueryOptions(orgSlug: string) { return apiOptions.as()( '/organizations/$organizationIdOrSlug/monitoring-providers/', @@ -92,6 +97,22 @@ export function MonitoringProvidersSection() { }); function handleConnect(provider: MonitoringProvider) { + if (PAT_PROVIDERS.has(provider.provider)) { + openModal(modalProps => ( + { + queryClient.invalidateQueries({ + queryKey: monitoringProvidersQueryOptions(organization.slug).queryKey, + }); + addSuccessMessage(t('Provider connected.')); + }} + /> + )); + return; + } + const params: {provider: string; site?: string} = {provider: provider.provider}; if (provider.provider === 'datadog') { // TODO(CW-1501): v0 only supports datadoghq.com; add site selection when per-site connections are supported