diff --git a/frontend/common/hooks/useCollapsibleHeight.ts b/frontend/common/hooks/useCollapsibleHeight.ts index a568443e1448..db20df27dbad 100644 --- a/frontend/common/hooks/useCollapsibleHeight.ts +++ b/frontend/common/hooks/useCollapsibleHeight.ts @@ -33,7 +33,7 @@ export default function useCollapsibleHeight(open: boolean) { const style = { height: height !== undefined ? `${height}px` : 'auto', - overflow: 'hidden' as const, + overflow: (height === undefined ? 'visible' : 'hidden') as const, transition: 'height 0.3s ease', } diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts new file mode 100644 index 000000000000..78c2f4f22a90 --- /dev/null +++ b/frontend/common/services/useWarehouseConnection.ts @@ -0,0 +1,46 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const warehouseConnectionService = service + .enhanceEndpoints({ addTagTypes: ['WarehouseConnection'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createWarehouseConnection: builder.mutation< + Res['warehouseConnections'][number], + Req['createWarehouseConnection'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId, ...body }) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/warehouse-connections/`, + }), + }), + deleteWarehouseConnection: builder.mutation< + void, + Req['deleteWarehouseConnection'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId, uuid }) => ({ + method: 'DELETE', + url: `environments/${environmentId}/warehouse-connections/${uuid}/`, + }), + }), + getWarehouseConnections: builder.query< + Res['warehouseConnections'], + Req['getWarehouseConnections'] + >({ + providesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + query: ({ environmentId }) => ({ + url: `environments/${environmentId}/warehouse-connections/`, + }), + }), + }), + }) + +export const { + useCreateWarehouseConnectionMutation, + useDeleteWarehouseConnectionMutation, + useGetWarehouseConnectionsQuery, +} = warehouseConnectionService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 09cc47986326..ed99ef754b81 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -966,5 +966,8 @@ export type Req = { project_id: number gitlab_project_id: number }> + getWarehouseConnections: { environmentId: string } + createWarehouseConnection: { environmentId: string; warehouse_type: string } + deleteWarehouseConnection: { environmentId: string; uuid: string } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b765b948b4a1..c73c48b13927 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1087,6 +1087,21 @@ export type ExperimentResults = { statistics: ExperimentStatistics } +export type WarehouseConnectionStatus = + | 'pending_connection' + | 'connected' + | 'errored' + +export type WarehouseType = 'flagsmith' | 'snowflake' | 'clickhouse' + +export type WarehouseConnection = { + uuid: string + warehouse_type: WarehouseType + status: WarehouseConnectionStatus + name: string + created_at: string +} + export type Res = { segments: PagedResponse segment: Segment @@ -1309,5 +1324,6 @@ export type Res = { gitlabProjects: PagedResponse gitlabIssues: PagedResponse gitlabMergeRequests: PagedResponse + warehouseConnections: WarehouseConnection[] // END OF TYPES } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 7e6df844397a..4ff3a748ddc2 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -54,6 +54,7 @@ export type PaidFeature = | 'CREATE_ADDITIONAL_PROJECT' | '2FA' | 'RELEASE_PIPELINES' + | 'WAREHOUSE' export type AppFeature = PaidFeature | 'FEATURE_HEALTH' @@ -221,7 +222,11 @@ const Utils = Object.assign({}, BaseUtils, { flagsmithFeatureExists(flag: string) { return Object.prototype.hasOwnProperty.call(flagsmith.getAllFlags(), flag) }, - getContentType(contentTypes: ContentType[] | undefined, model: string, type: string) { + getContentType( + contentTypes: ContentType[] | undefined, + model: string, + type: string, + ) { return contentTypes?.find((c: ContentType) => c[model] === type) || null }, getCreateProjectPermission(organisation: Organisation) { @@ -533,6 +538,17 @@ const Utils = Object.assign({}, BaseUtils, { plan = 'enterprise' break } + case 'WAREHOUSE': { + const remotePlans: string[] = Utils.getFlagsmithJSONValue( + 'experimentation_warehouse_connection', + [], + ) + const allowedPlans = [...remotePlans, 'enterprise'] + const planHierarchy: Plan[] = ['start-up', 'scale-up', 'enterprise'] + plan = + planHierarchy.find((p) => allowedPlans.includes(p)) || 'enterprise' + break + } case 'SCHEDULE_FLAGS': case 'CREATE_ADDITIONAL_PROJECT': diff --git a/frontend/web/components/PlanBasedAccess.tsx b/frontend/web/components/PlanBasedAccess.tsx index 4e96d4d59401..5faf7b7e8948 100644 --- a/frontend/web/components/PlanBasedAccess.tsx +++ b/frontend/web/components/PlanBasedAccess.tsx @@ -102,6 +102,11 @@ export const featureDescriptions: Record = { description: 'Access all of your feature versions.', title: 'Version History', }, + 'WAREHOUSE': { + description: + 'Connect a data warehouse to collect experimentation and analytics data from your environments.', + title: 'Warehouse Connections', + }, } const PlanBasedBanner: FC = ({ children, ...props }) => { diff --git a/frontend/web/components/pages/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx similarity index 98% rename from frontend/web/components/pages/EnvironmentSettingsPage.tsx rename to frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index c7a465892b48..4eedd8c4d3b7 100644 --- a/frontend/web/components/pages/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -46,6 +46,8 @@ import { useGetEnvironmentQuery } from 'common/services/useEnvironment' import { useRouteContext } from 'components/providers/RouteContext' import SettingTitle from 'components/SettingTitle' import ChangeRequestsSetting from 'components/ChangeRequestsSetting' +import PlanBasedBanner from 'components/PlanBasedAccess' +import WarehouseTab from './tabs/warehouse-tab' const showDisabledFlagOptions: { label: string; value: boolean | null }[] = [ { label: 'Inherit from Project', value: null }, @@ -900,6 +902,19 @@ const EnvironmentSettingsPage: React.FC = () => { )} + + +
+ +
+
+
{metadataEnable && environmentContentType?.id && ( diff --git a/frontend/web/components/pages/environment-settings/index.ts b/frontend/web/components/pages/environment-settings/index.ts new file mode 100644 index 000000000000..1382adb3c229 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/index.ts @@ -0,0 +1 @@ +export { default } from './EnvironmentSettingsPage' diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx new file mode 100644 index 000000000000..bc43aea2db62 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseConnectionCard.tsx @@ -0,0 +1,106 @@ +import React, { FC, useState } from 'react' +import { + WarehouseConnection, + WarehouseConnectionStatus, +} from 'common/types/responses' +import ColorSwatch from 'components/ColorSwatch' +import Tooltip from 'components/Tooltip' +import Icon from 'components/icons/Icon' +import Button from 'components/base/forms/Button' +import WarehouseEventCodeHelp from './WarehouseEventCodeHelp' +import WarehouseStats from './WarehouseStats' +import useCollapsibleHeight from 'common/hooks/useCollapsibleHeight' + +type WarehouseConnectionCardProps = { + connection: WarehouseConnection + onDelete: () => void +} + +const STATUS_COLOUR: Record = { + connected: '#27AE60', + errored: '#EB5757', + pending_connection: '#F2C94C', +} + +const STATUS_LABEL: Record = { + connected: 'Connected', + errored: 'Errored', + pending_connection: 'Pending Connection', +} + +const WarehouseConnectionCard: FC = ({ + connection, + onDelete, +}) => { + const [open, setOpen] = useState(false) + const { contentRef, style: collapsibleStyle } = useCollapsibleHeight(open) + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation() + openConfirm({ + body: 'Are you sure you want to remove this warehouse connection?', + onYes: onDelete, + title: 'Remove Warehouse Connection', + }) + } + + return ( +
+
setOpen(!open)} + > +
+ {connection.name} + + } + place='top' + > + {STATUS_LABEL[connection.status]} + +
+
+ + + + +
+
+
+
+ {connection.status === 'pending_connection' ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} + +WarehouseConnectionCard.displayName = 'WarehouseConnectionCard' +export default WarehouseConnectionCard 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 new file mode 100644 index 000000000000..096eea47f787 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseEventCodeHelp.tsx @@ -0,0 +1,116 @@ +import React, { FC } from 'react' +import CodeHelp from 'components/CodeHelp' + +type SnippetEntry = { + code: string + enabled: boolean +} + +const helloWorldSnippets: Record = { + '.NET': { + code: `using Flagsmith; + +var client = new FlagsmithClient("YOUR_ENVIRONMENT_KEY"); +Console.WriteLine("Hello, Flagsmith Warehouse!");`, + enabled: false, + }, + 'Flutter': { + code: `import 'package:flagsmith/flagsmith.dart'; + +final flagsmith = FlagsmithClient(apiKey: 'YOUR_ENVIRONMENT_KEY'); +print('Hello, Flagsmith Warehouse!');`, + enabled: false, + }, + 'Go': { + code: `import ( + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" +) + +client := flagsmith.NewClient("YOUR_ENVIRONMENT_KEY") +fmt.Println("Hello, Flagsmith Warehouse!")`, + enabled: false, + }, + 'Java': { + code: `import com.flagsmith.FlagsmithClient; + +FlagsmithClient client = FlagsmithClient + .newBuilder() + .setApiKey("YOUR_ENVIRONMENT_KEY") + .build(); +System.out.println("Hello, Flagsmith Warehouse!");`, + enabled: false, + }, + 'JavaScript': { + code: `import flagsmith from 'flagsmith'; + +flagsmith.init({ environmentID: 'YOUR_ENVIRONMENT_KEY' }); +console.log('Hello, Flagsmith Warehouse!');`, + enabled: true, + }, + 'Node JS': { + code: `import Flagsmith from 'flagsmith-nodejs'; + +const flagsmith = new Flagsmith({ environmentKey: 'YOUR_ENVIRONMENT_KEY' }); +console.log('Hello, Flagsmith Warehouse!');`, + enabled: false, + }, + 'PHP': { + code: `use Flagsmith\\Flagsmith; + +$flagsmith = new Flagsmith('YOUR_ENVIRONMENT_KEY'); +echo "Hello, Flagsmith Warehouse!";`, + enabled: false, + }, + 'Python': { + code: `from flagsmith import Flagsmith + +flagsmith = Flagsmith(environment_key="YOUR_ENVIRONMENT_KEY") +print("Hello, Flagsmith Warehouse!")`, + enabled: true, + }, + 'Ruby': { + code: `require "flagsmith" + +flagsmith = Flagsmith::Client.new(environment_key: "YOUR_ENVIRONMENT_KEY") +puts "Hello, Flagsmith Warehouse!"`, + enabled: false, + }, + 'Rust': { + code: `use flagsmith::Flagsmith; + +let flagsmith = Flagsmith::new("YOUR_ENVIRONMENT_KEY".to_string()); +println!("Hello, Flagsmith Warehouse!");`, + enabled: false, + }, + 'iOS': { + code: `import FlagsmithClient + +Flagsmith.shared.apiKey = "YOUR_ENVIRONMENT_KEY" +print("Hello, Flagsmith Warehouse!")`, + enabled: false, + }, +} + +const enabledSnippets = Object.fromEntries( + Object.entries(helloWorldSnippets) + .filter(([, entry]) => entry.enabled) + .map(([name, entry]) => [name, entry.code]), +) + +const WarehouseEventCodeHelp: FC = () => ( +
+

+ Verify your connection by sending your first custom event using one of our + SDKs +

+ +
+) + +WarehouseEventCodeHelp.displayName = 'WarehouseEventCodeHelp' +export default WarehouseEventCodeHelp 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 new file mode 100644 index 000000000000..798f85add889 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/WarehouseStats.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' + +type WarehouseStatsProps = { + errored: boolean + lastEventReceived: string + totalEventsReceived: number + uniqueEventsCount: number +} + +const WarehouseStats: FC = ({ + errored, + lastEventReceived, + totalEventsReceived, + uniqueEventsCount, +}) => ( +
+ {errored && ( +
+ + + The connection is currently in error, please contact our team + +
+ )} +
+ Last event received: + {lastEventReceived} +
+
+ Total events received: + + {totalEventsReceived.toLocaleString()} + +
+
+ Number of unique events: + + {uniqueEventsCount.toLocaleString()} + +
+
+) + +WarehouseStats.displayName = 'WarehouseStats' +export default WarehouseStats 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 new file mode 100644 index 000000000000..2df1b32ce1da --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/index.tsx @@ -0,0 +1,80 @@ +import React, { FC } from 'react' +import { + useCreateWarehouseConnectionMutation, + useDeleteWarehouseConnectionMutation, + useGetWarehouseConnectionsQuery, +} from 'common/services/useWarehouseConnection' +import Setting from 'components/Setting' +import WarehouseConnectionCard from './WarehouseConnectionCard' + +type WarehouseTabProps = { + environmentId: string +} + +const WarehouseTab: FC = ({ environmentId }) => { + const { data: connections, isLoading } = useGetWarehouseConnectionsQuery( + { environmentId }, + { skip: !environmentId }, + ) + const [createConnection, { isLoading: isCreating }] = + useCreateWarehouseConnectionMutation() + const [deleteConnection] = useDeleteWarehouseConnectionMutation() + + const hasNoConnection = + !isLoading && (!connections || connections.length === 0) + + if (hasNoConnection) { + return ( +
+ + openConfirm({ + body: 'This will enable a Flagsmith Warehouse connection for this environment. Are you sure you want to proceed?', + onYes: () => + createConnection({ + environmentId, + warehouse_type: 'flagsmith', + }) + .unwrap() + .then(() => toast('Warehouse connection created')) + .catch(() => + toast('Failed to create warehouse connection', 'danger'), + ), + title: 'Connect Flagsmith Warehouse', + }) + } + /> +
+ ) + } + + if (!connections || connections.length === 0) { + return null + } + + return ( +
+ {connections.map((connection) => ( + + deleteConnection({ environmentId, uuid: connection.uuid }) + .unwrap() + .then(() => toast('Warehouse connection removed')) + .catch(() => + toast('Failed to remove warehouse connection', 'danger'), + ) + } + /> + ))} +
+ ) +} + +WarehouseTab.displayName = 'WarehouseTab' +export default WarehouseTab diff --git a/frontend/web/routes.js b/frontend/web/routes.js index 3c526e7e9342..cc9802e07536 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -17,7 +17,7 @@ import AccountSettingsPage from './components/pages/AccountSettingsPage' import NotFoundErrorPage from './components/pages/NotFoundErrorPage' import ProjectSettingsPage from './components/pages/project-settings' import PasswordResetPage from './components/pages/PasswordResetPage' -import EnvironmentSettingsPage from './components/pages/EnvironmentSettingsPage' +import EnvironmentSettingsPage from './components/pages/environment-settings' import InvitePage from './components/pages/InvitePage' import NotFoundPage from './components/pages/NotFoundPage' import ChangeRequestsPage from './components/pages/ChangeRequestsPage'