diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts index 2ff3fe3f7527..017fe642a5a3 100644 --- a/frontend/common/services/useWarehouseConnection.ts +++ b/frontend/common/services/useWarehouseConnection.ts @@ -36,6 +36,16 @@ export const warehouseConnectionService = service url: `environments/${environmentId}/warehouse-connections/`, }), }), + testWarehouseConnection: builder.mutation< + Res['warehouseConnections'][number], + Req['testWarehouseConnection'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId, id }) => ({ + method: 'POST', + url: `environments/${environmentId}/warehouse-connections/${id}/test-warehouse-connection/`, + }), + }), updateWarehouseConnection: builder.mutation< Res['warehouseConnections'][number], Req['updateWarehouseConnection'] @@ -54,5 +64,6 @@ export const { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } = warehouseConnectionService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 09ce610f3111..3f5537ec621b 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -998,6 +998,7 @@ export type Req = { config?: Record } deleteWarehouseConnection: { environmentId: string; id: number } + testWarehouseConnection: { environmentId: string; id: number } updateWarehouseConnection: { environmentId: string id: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 713cf47fac47..a5d5d4fa5742 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1146,6 +1146,8 @@ export type WarehouseConnection = { name: string config: SnowflakeConfig | Record created_at: string + total_events_received: number | null + unique_events_count: number | null } export type Res = { diff --git a/frontend/env/project_dev.js b/frontend/env/project_dev.js index d69548f70853..9c4ae4c79740 100644 --- a/frontend/env/project_dev.js +++ b/frontend/env/project_dev.js @@ -14,10 +14,15 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.bullet-train-staging.win/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/', // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'startup-annual-v2', monthly: 'startup-v2' }, }, useSecureCookies: true, diff --git a/frontend/env/project_local.js b/frontend/env/project_local.js index cf36706e946d..ac26c4b63b71 100644 --- a/frontend/env/project_local.js +++ b/frontend/env/project_local.js @@ -14,10 +14,15 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.api.flagsmith.com/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/', // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'startup-annual-v2', monthly: 'startup-v2' }, }, useSecureCookies: false, diff --git a/frontend/env/project_prod.js b/frontend/env/project_prod.js index c1e3306f8103..6bb01f353994 100644 --- a/frontend/env/project_prod.js +++ b/frontend/env/project_prod.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @dword-design/import-alias/prefer-alias import { E2E_CHANGE_MAIL, E2E_SIGN_UP_USER, E2E_USER } from '../e2e/config' const _globalThis = typeof window === 'undefined' ? global : window @@ -21,13 +22,18 @@ const Project = { flagsmithClientEdgeAPI: 'https://edge.api.flagsmith.com/api/v1/', + flagsmithClientEventsAPI: 'https://events.api.flagsmith.com/', + hubspot: '//js-eu1.hs-scripts.com/143451822.js', linkedinConversionId: 16798338, // This is used for Sentry tracking maintenance: false, plans: { - scaleUp: { annual: 'Scale-Up-v4-USD-Yearly', monthly: 'Scale-Up-v4-USD-Monthly' }, + scaleUp: { + annual: 'Scale-Up-v4-USD-Yearly', + monthly: 'Scale-Up-v4-USD-Monthly', + }, startup: { annual: 'start-up-12-months-v2', monthly: 'startup-v2' }, }, useSecureCookies: true, diff --git a/frontend/env/project_staging.js b/frontend/env/project_staging.js index 2b63a9173dcf..9c4ae4c79740 100644 --- a/frontend/env/project_staging.js +++ b/frontend/env/project_staging.js @@ -14,6 +14,8 @@ const Project = { flagsmithClientAPI: 'https://edge.api.flagsmith.com/api/v1/', flagsmithClientEdgeAPI: 'https://edge.bullet-train-staging.win/api/v1/', + + flagsmithClientEventsAPI: 'https://events.bullet-train-staging.win/', // This is used for Sentry tracking maintenance: false, plans: { diff --git a/frontend/web/components/CodeHelp.tsx b/frontend/web/components/CodeHelp.tsx index c0ebc979e679..65fb0db586ad 100644 --- a/frontend/web/components/CodeHelp.tsx +++ b/frontend/web/components/CodeHelp.tsx @@ -8,6 +8,7 @@ import Icon from './icons/Icon' type Snippets = Record type CodeHelpProps = { + hideDocs?: boolean hideHeader?: boolean showInitially?: boolean snippets: Snippets @@ -22,6 +23,7 @@ type LanguageOption = { type SnippetItemProps = { code: string + hideDocs?: boolean isVisible: boolean language: string languageKey: string @@ -108,6 +110,7 @@ const getDocsLink = (key: string): string | null => { const SnippetItem: FC = ({ code, + hideDocs, isVisible, language, languageKey, @@ -115,8 +118,8 @@ const SnippetItem: FC = ({ onCopy, onLanguageChange, }) => { - const docs = getDocsLink(languageKey) - const github = getGithubLink(languageKey) + const docs = hideDocs ? null : getDocsLink(languageKey) + const github = hideDocs ? null : getGithubLink(languageKey) return (
@@ -185,6 +188,7 @@ const SnippetItem: FC = ({ } const CodeHelp: FC = ({ + hideDocs, hideHeader, showInitially, snippets, @@ -251,6 +255,7 @@ const CodeHelp: FC = ({ void onEdit?: () => void + onSendTestEvent: () => void + isSendingTestEvent: boolean } const STATUS_COLOUR: Record = { @@ -38,14 +40,20 @@ const TYPE_LABEL: Partial> = { const WarehouseConnectionCard: FC = ({ connection, + isSendingTestEvent, onDelete, onEdit, + onSendTestEvent, }) => { const typeLabel = connection.warehouse_type !== 'flagsmith' ? TYPE_LABEL[connection.warehouse_type] ?? connection.warehouse_type : null + const isFlagsmith = connection.warehouse_type === 'flagsmith' + const isPending = connection.status === 'pending_connection' + const isConnected = connection.status === 'connected' + const handleDelete = () => { openConfirm({ body: 'Are you sure you want to remove this warehouse connection?', @@ -106,21 +114,36 @@ const WarehouseConnectionCard: FC = ({
+ {connection.status === 'pending_connection' && ( +
+ + + Your test event is on its way. It can take up to a few hours to + process the first event. + +
+ )}
- {connection.warehouse_type === 'flagsmith' ? ( - - ) : ( + {!isFlagsmith && ( )} + {isFlagsmith && !isPending && !isConnected && ( + + )}
) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseEventCodeHelp.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseEventCodeHelp.tsx index 5c206d501f4b..e2abbfb04666 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseEventCodeHelp.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseEventCodeHelp.tsx @@ -126,6 +126,7 @@ const WarehouseEventCodeHelp: FC = () => ( snippets={enabledSnippets} showInitially hideHeader + hideDocs /> ) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetupSkeleton.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetupSkeleton.tsx new file mode 100644 index 000000000000..3436b05a4961 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseSetupSkeleton.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react' +import Skeleton from 'components/Skeleton' +import './WarehouseSetup.scss' +import './SelectableCard.scss' + +// Mirrors the WarehouseSetup layout (warehouse-type selector + Flagsmith enable +// card) so the loading state has the same shape as the empty state it resolves +// to, avoiding a layout shift when the connections query settles. +const TYPE_CARD_COUNT = 4 + +const WarehouseSetupSkeleton: FC = () => ( +
+
+
+ {Array.from({ length: TYPE_CARD_COUNT }).map((_, index) => ( +
+
+
+
+ +
+ + +
+
+
+ ))} +
+
+ +
+ + +
+
+) + +WarehouseSetupSkeleton.displayName = 'WarehouseSetupSkeleton' +export default WarehouseSetupSkeleton diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseStats.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseStats.tsx index 798f85add889..3c5d206840c6 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseStats.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseStats.tsx @@ -4,10 +4,13 @@ import Icon from 'components/icons/Icon' type WarehouseStatsProps = { errored: boolean lastEventReceived: string - totalEventsReceived: number - uniqueEventsCount: number + totalEventsReceived: number | null + uniqueEventsCount: number | null } +const formatCount = (value: number | null): string => + value !== null ? value.toLocaleString() : '-' + const WarehouseStats: FC = ({ errored, lastEventReceived, @@ -30,13 +33,13 @@ const WarehouseStats: FC = ({
Total events received: - {totalEventsReceived.toLocaleString()} + {formatCount(totalEventsReceived)}
Number of unique events: - {uniqueEventsCount.toLocaleString()} + {formatCount(uniqueEventsCount)}
diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts new file mode 100644 index 000000000000..0bd6b728a355 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/sendWarehouseTestEvent.test.ts @@ -0,0 +1,49 @@ +import sendWarehouseTestEvent from 'components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent' + +const init = jest.fn().mockResolvedValue(undefined) +const trackEvent = jest.fn() +const flushEvents = jest.fn().mockResolvedValue(undefined) + +jest.mock('@flagsmith/flagsmith/isomorphic', () => ({ + createFlagsmithInstance: () => ({ flushEvents, init, trackEvent }), +})) + +jest.mock('common/project', () => ({ + __esModule: true, + default: { api: 'http://localhost:8000/api/v1/' }, +})) + +describe('sendWarehouseTestEvent', () => { + beforeEach(() => { + init.mockClear() + trackEvent.mockClear() + flushEvents.mockClear() + }) + + it('inits a per-environment instance with events enabled and no flag fetch', async () => { + await sendWarehouseTestEvent('env-key-123') + + expect(init).toHaveBeenCalledWith( + expect.objectContaining({ + defaultFlags: {}, + enableEvents: true, + environmentID: 'env-key-123', + preventFetch: true, + }), + ) + }) + + it('tracks the test_custom_event after init', async () => { + await sendWarehouseTestEvent('env-key-123') + + expect(trackEvent).toHaveBeenCalledWith('test_custom_event') + expect(init).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledTimes(1) + }) + + it('flushes events so the tracked event is sent immediately', async () => { + await sendWarehouseTestEvent('env-key-123') + + expect(flushEvents).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts new file mode 100644 index 000000000000..9d6c81c0dd97 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/__tests__/warehousePolling.test.ts @@ -0,0 +1,23 @@ +import { getWarehousePollingInterval } from 'components/pages/environment-settings/tabs/warehouse-tab/warehousePolling' + +describe('getWarehousePollingInterval', () => { + it('polls every minute while pending_connection', () => { + expect(getWarehousePollingInterval('pending_connection')).toBe(60000) + }) + + it('does not poll for connected', () => { + expect(getWarehousePollingInterval('connected')).toBe(0) + }) + + it('does not poll for created', () => { + expect(getWarehousePollingInterval('created')).toBe(0) + }) + + it('does not poll for errored', () => { + expect(getWarehousePollingInterval('errored')).toBe(0) + }) + + it('does not poll when status is undefined', () => { + expect(getWarehousePollingInterval(undefined)).toBe(0) + }) +}) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx index fd32a646c812..1d8d5f71836b 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -1,15 +1,18 @@ -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useCreateWarehouseConnectionMutation, useDeleteWarehouseConnectionMutation, useGetWarehouseConnectionsQuery, + useTestWarehouseConnectionMutation, useUpdateWarehouseConnectionMutation, } from 'common/services/useWarehouseConnection' import { SnowflakeConfig } from 'common/types/responses' -import Loader from 'components/Loader' import WarehouseConnectionCard from './WarehouseConnectionCard' import WarehouseSetup from './WarehouseSetup' +import WarehouseSetupSkeleton from './WarehouseSetupSkeleton' import ConfigForm from './ConfigForm' +import sendWarehouseTestEvent from './sendWarehouseTestEvent' +import { getWarehousePollingInterval } from './warehousePolling' type WarehouseTabProps = { environmentId: string @@ -32,6 +35,21 @@ const WarehouseTab: FC = ({ environmentId }) => { const [updateConnection] = useUpdateWarehouseConnectionMutation() const connection = connections?.[0] + const connectionId = connection?.id + const connectionStatus = connection?.status + + const [testConnection, { isLoading: isSendingTestEvent }] = + useTestWarehouseConnectionMutation() + + useEffect(() => { + const interval = getWarehousePollingInterval(connectionStatus) + if (!interval || connectionId === undefined) return + testConnection({ environmentId, id: connectionId }) + const timer = setInterval(() => { + testConnection({ environmentId, id: connectionId }) + }, interval) + return () => clearInterval(timer) + }, [connectionStatus, connectionId, environmentId, testConnection]) const handleEnableFlagsmith = () => { openConfirm({ @@ -88,10 +106,22 @@ const WarehouseTab: FC = ({ environmentId }) => { .catch(() => toast('Failed to remove warehouse connection', 'danger')) } + const handleSendTestEvent = () => { + if (!connection) return + sendWarehouseTestEvent(environmentId) + .then(() => testConnection({ environmentId, id: connection.id }).unwrap()) + .then(() => toast('Test event sent')) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('[warehouse] send test event failed:', error) + toast('Failed to send test event', 'danger') + }) + } + if (isLoading) { return (
- +
) } @@ -142,6 +172,8 @@ const WarehouseTab: FC = ({ environmentId }) => { ? () => setEditing(true) : undefined } + onSendTestEvent={handleSendTestEvent} + isSendingTestEvent={isSendingTestEvent} /> ) diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts new file mode 100644 index 000000000000..2d670b117f3c --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/sendWarehouseTestEvent.ts @@ -0,0 +1,34 @@ +import { createFlagsmithInstance } from '@flagsmith/flagsmith/isomorphic' +import Project from 'common/project' + +// Emits a throwaway "test_custom_event" tagged with the target environment's +// client-side key, so the user can verify their warehouse connection. A +// dedicated instance is used because the dashboard's global Flagsmith client is +// keyed to Flagsmith's own environment, not the customer's. +// +// We only want to emit an event, never evaluate flags. `preventFetch` with an +// empty `defaultFlags` skips fetching the customer's flag configuration +// entirely (so we never pull their config into the dashboard), while +// `enableEvents` still starts the event pipeline. `api` targets the backend +// this environment lives on, and `eventsApiUrl` (when configured) points the +// event pipeline at the matching ingestor; otherwise the SDK default is used. +const sendWarehouseTestEvent = async (environmentId: string): Promise => { + const instance = createFlagsmithInstance() + await instance.init({ + api: Project.api, + defaultFlags: {}, + enableEvents: true, + environmentID: environmentId, + fetch: globalThis.fetch.bind(globalThis), + preventFetch: true, + ...(Project.flagsmithClientEventsAPI && { + eventProcessorConfig: { + eventsApiUrl: Project.flagsmithClientEventsAPI, + }, + }), + }) + instance.trackEvent('test_custom_event') + await instance.flushEvents() +} + +export default sendWarehouseTestEvent diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts new file mode 100644 index 000000000000..3c028ef51db6 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/warehousePolling.ts @@ -0,0 +1,9 @@ +import { WarehouseConnectionStatus } from 'common/types/responses' + +export const WAREHOUSE_POLL_INTERVAL_MS = 60000 + +// RTK Query treats a pollingInterval of 0 as "do not poll". We only poll while +// the backend is waiting for the first event to land in the warehouse. +export const getWarehousePollingInterval = ( + status: WarehouseConnectionStatus | undefined, +): number => (status === 'pending_connection' ? WAREHOUSE_POLL_INTERVAL_MS : 0)