diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page.png
new file mode 100644
index 0000000000000..7e878b1607d78
Binary files /dev/null and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page.png differ
diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-landing-page.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-landing-page.png
index 19d7b6acffdee..1e5dc7f4a24bc 100644
Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-landing-page.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-landing-page.png differ
diff --git a/frontend/src/scenes/pipeline/Destinations.tsx b/frontend/src/scenes/pipeline/Destinations.tsx
new file mode 100644
index 0000000000000..7cfe7da38ba21
--- /dev/null
+++ b/frontend/src/scenes/pipeline/Destinations.tsx
@@ -0,0 +1,245 @@
+import {
+ LemonButton,
+ LemonDivider,
+ LemonTable,
+ LemonTableColumn,
+ LemonTag,
+ LemonTagType,
+ Link,
+ Tooltip,
+} from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
+import { More } from 'lib/lemon-ui/LemonButton/More'
+import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown/LemonMarkdown'
+import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
+import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
+import { urls } from 'scenes/urls'
+
+import { PipelineAppTabs, PipelineTabs, PluginConfigTypeNew, ProductKey } from '~/types'
+
+import { pipelineDestinationsLogic } from './destinationsLogic'
+import { NewButton } from './NewButton'
+import { RenderApp } from './utils'
+
+export function Destinations(): JSX.Element {
+ const { enabledPluginConfigs, disabledPluginConfigs, shouldShowProductIntroduction } =
+ useValues(pipelineDestinationsLogic)
+
+ const shouldShowEmptyState = enabledPluginConfigs.length === 0 && disabledPluginConfigs.length === 0
+
+ return (
+ <>
+ {(shouldShowEmptyState || shouldShowProductIntroduction) && (
+ }
+ isEmpty={true}
+ />
+ )}
+
+
+ >
+ )
+}
+
+function BatchExportsTable(): JSX.Element {
+ return (
+ <>
+
Batch exports
+
+ Backfills
+ >
+ )
+}
+
+function AppsTable(): JSX.Element {
+ const { loading, enabledPluginConfigs, disabledPluginConfigs, plugins, canConfigurePlugins } =
+ useValues(pipelineDestinationsLogic)
+ const { toggleEnabled, loadPluginConfigs } = useActions(pipelineDestinationsLogic)
+
+ if (enabledPluginConfigs.length === 0 && disabledPluginConfigs.length === 0) {
+ return <>>
+ }
+
+ return (
+ <>
+ Webhooks
+
+
+
+ {pluginConfig.name}
+
+
+ {pluginConfig.description && (
+
+ {pluginConfig.description}
+
+ )}
+ >
+ )
+ },
+ },
+ {
+ title: 'App',
+ render: function RenderAppInfo(_, pluginConfig) {
+ return
+ },
+ },
+ {
+ title: '24h',
+ render: function Render24hDeliveryRate(_, pluginConfig) {
+ let tooltip = 'No events exported in the past 24 hours'
+ let value = '-'
+ let tagType: LemonTagType = 'muted'
+ if (
+ pluginConfig.delivery_rate_24h !== null &&
+ pluginConfig.delivery_rate_24h !== undefined
+ ) {
+ const deliveryRate = pluginConfig.delivery_rate_24h
+ value = `${Math.floor(deliveryRate * 100)}%`
+ tooltip = 'Success rate for past 24 hours'
+ if (deliveryRate >= 0.99) {
+ tagType = 'success'
+ } else if (deliveryRate >= 0.75) {
+ tagType = 'warning'
+ } else {
+ tagType = 'danger'
+ }
+ }
+ return (
+
+
+ {value}
+
+
+ )
+ },
+ },
+ updatedAtColumn() as LemonTableColumn,
+ {
+ title: 'Status',
+ render: function RenderStatus(_, pluginConfig) {
+ return (
+ <>
+ {pluginConfig.enabled ? (
+
+ Enabled
+
+ ) : (
+
+ Disabled
+
+ )}
+ >
+ )
+ },
+ },
+ {
+ width: 0,
+ render: function Render(_, pluginConfig) {
+ return (
+
+ {
+ toggleEnabled({
+ enabled: !pluginConfig.enabled,
+ id: pluginConfig.id,
+ })
+ }}
+ id={`app-${pluginConfig.id}-enable-switch`}
+ disabledReason={
+ canConfigurePlugins
+ ? undefined
+ : 'You do not have permission to enable/disable apps.'
+ }
+ fullWidth
+ >
+ {pluginConfig.enabled ? 'Disable' : 'Enable'} app
+
+
+ {canConfigurePlugins ? 'Edit' : 'View'} app configuration
+
+
+ View app metrics
+
+
+ View app logs
+
+ {plugins[pluginConfig.plugin].url && (
+
+ View app source code
+
+ )}
+
+ {
+ void deleteWithUndo({
+ endpoint: `plugin_config`,
+ object: {
+ id: pluginConfig.id,
+ name: pluginConfig.name,
+ },
+ callback: loadPluginConfigs,
+ })
+ }}
+ id={`app-reorder`}
+ disabledReason={
+ canConfigurePlugins
+ ? undefined
+ : 'You do not have permission to delete apps.'
+ }
+ fullWidth
+ >
+ Delete app
+
+ >
+ }
+ />
+ )
+ },
+ },
+ ]}
+ />
+ >
+ )
+}
diff --git a/frontend/src/scenes/pipeline/Pipeline.stories.tsx b/frontend/src/scenes/pipeline/Pipeline.stories.tsx
index c3667f13f3c94..360d2ee592d26 100644
--- a/frontend/src/scenes/pipeline/Pipeline.stories.tsx
+++ b/frontend/src/scenes/pipeline/Pipeline.stories.tsx
@@ -63,6 +63,19 @@ export function PipelineTransformationsPage(): JSX.Element {
}, [])
return
}
+export function PipelineDestinationsPage(): JSX.Element {
+ useStorybookMocks({
+ get: {
+ 'api/organizations/@current/pipeline_destinations/': require('./__mocks__/plugins.json'),
+ 'api/projects/:team_id/pipeline_destinations_configs/': require('./__mocks__/transformationPluginConfigs.json'),
+ },
+ })
+ useEffect(() => {
+ router.actions.push(urls.pipeline(PipelineTabs.Destinations))
+ pipelineLogic.mount()
+ }, [])
+ return
+}
export function PipelineAppConfiguration(): JSX.Element {
useEffect(() => {
diff --git a/frontend/src/scenes/pipeline/Pipeline.tsx b/frontend/src/scenes/pipeline/Pipeline.tsx
index b3cb91508a6b7..3af8f2cf419cf 100644
--- a/frontend/src/scenes/pipeline/Pipeline.tsx
+++ b/frontend/src/scenes/pipeline/Pipeline.tsx
@@ -8,6 +8,7 @@ import { urls } from 'scenes/urls'
import { PipelineTabs } from '~/types'
import { AppsManagement } from './AppsManagement'
+import { Destinations } from './Destinations'
import { NewButton } from './NewButton'
import { humanFriendlyTabName, pipelineLogic } from './pipelineLogic'
import { Transformations } from './Transformations'
@@ -18,7 +19,7 @@ export function Pipeline(): JSX.Element {
const tab_to_content: Record = {
[PipelineTabs.Filters]: Coming soon
,
[PipelineTabs.Transformations]: ,
- [PipelineTabs.Destinations]: Coming soon
,
+ [PipelineTabs.Destinations]: ,
[PipelineTabs.AppsManagement]: ,
}
diff --git a/frontend/src/scenes/pipeline/Transformations.tsx b/frontend/src/scenes/pipeline/Transformations.tsx
index ccf1c0b7ae7d6..f32d29cededd9 100644
--- a/frontend/src/scenes/pipeline/Transformations.tsx
+++ b/frontend/src/scenes/pipeline/Transformations.tsx
@@ -15,11 +15,9 @@ import {
} from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
-import { dayjs } from 'lib/dayjs'
import { More } from 'lib/lemon-ui/LemonButton/More'
import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown/LemonMarkdown'
import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
-import { humanFriendlyDetailedTime } from 'lib/utils'
import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
import { PluginImage } from 'scenes/plugins/plugin/PluginImage'
import { urls } from 'scenes/urls'
@@ -132,11 +130,6 @@ export function Transformations(): JSX.Element {
{
title: 'Status',
render: function RenderStatus(_, pluginConfig) {
- // We're not very good at cleaning up the errors, so let's not show it if more than 7 days have passed
- const days_since_error = pluginConfig.error
- ? dayjs().diff(dayjs(pluginConfig.error.time), 'day')
- : null
- const show_error: boolean = !(days_since_error && days_since_error < 7)
return (
<>
{pluginConfig.enabled ? (
@@ -148,30 +141,6 @@ export function Transformations(): JSX.Element {
Disabled
)}
- {pluginConfig.error && show_error && (
- <>
-
-
- Click to see logs.
-
- {humanFriendlyDetailedTime(
- pluginConfig.error.time
- )}: {pluginConfig.error.message}
- >
- }
- >
-
-
- Error
-
-
-
- >
- )}
>
)
},
diff --git a/frontend/src/scenes/pipeline/__mocks__/transformationPluginConfigs.json b/frontend/src/scenes/pipeline/__mocks__/transformationPluginConfigs.json
index 2349086386226..eb04dec88090f 100644
--- a/frontend/src/scenes/pipeline/__mocks__/transformationPluginConfigs.json
+++ b/frontend/src/scenes/pipeline/__mocks__/transformationPluginConfigs.json
@@ -12,7 +12,7 @@
"error": null,
"team_id": 1,
"plugin_info": null,
- "delivery_rate_24h": 1.0,
+ "delivery_rate_24h": 0.3333333,
"created_at": "2021-04-07T13:37:36.250380Z",
"updated_at": "2023-02-15T17:40:45.167743Z",
"name": null,
@@ -28,7 +28,7 @@
"error": null,
"team_id": 1,
"plugin_info": null,
- "delivery_rate_24h": 1.0,
+ "delivery_rate_24h": 0.99,
"created_at": "2021-04-07T13:37:36.250380Z",
"updated_at": "2023-02-15T17:40:45.167743Z",
"name": "custom name",
@@ -52,7 +52,7 @@
},
"team_id": 1,
"plugin_info": null,
- "delivery_rate_24h": 1.0,
+ "delivery_rate_24h": null,
"created_at": "2021-04-07T13:37:36.250380Z",
"updated_at": "2023-02-15T17:40:45.167743Z",
"name": "another of plugin 2",
@@ -68,7 +68,7 @@
"error": null,
"team_id": 1,
"plugin_info": null,
- "delivery_rate_24h": 1.0,
+ "delivery_rate_24h": 0.88,
"created_at": "2021-04-07T13:37:36.250380Z",
"updated_at": "2023-02-15T17:40:45.167743Z",
"name": "disabled",
@@ -84,7 +84,7 @@
"error": null,
"team_id": 1,
"plugin_info": null,
- "delivery_rate_24h": 1.0,
+ "delivery_rate_24h": 0,
"created_at": "2022-07-05T11:18:22.059158Z",
"updated_at": "2023-01-03T17:37:41.553035Z",
"name": "another disabled one",
diff --git a/frontend/src/scenes/pipeline/destinationsLogic.tsx b/frontend/src/scenes/pipeline/destinationsLogic.tsx
new file mode 100644
index 0000000000000..d3f9bc83138ad
--- /dev/null
+++ b/frontend/src/scenes/pipeline/destinationsLogic.tsx
@@ -0,0 +1,102 @@
+import { actions, afterMount, connect, kea, path, selectors } from 'kea'
+import { loaders } from 'kea-loaders'
+import api from 'lib/api'
+import { canConfigurePlugins } from 'scenes/plugins/access'
+import { teamLogic } from 'scenes/teamLogic'
+import { userLogic } from 'scenes/userLogic'
+
+import { PluginConfigTypeNew, PluginType, ProductKey } from '~/types'
+
+import type { pipelineDestinationsLogicType } from './destinationsLogicType'
+import { capturePluginEvent } from './utils'
+
+export const pipelineDestinationsLogic = kea([
+ path(['scenes', 'pipeline', 'destinationsLogic']),
+ connect({
+ values: [teamLogic, ['currentTeamId'], userLogic, ['user']],
+ }),
+ actions({
+ loadPluginConfigs: true,
+ }),
+ loaders(({ values }) => ({
+ plugins: [
+ {} as Record,
+ {
+ loadPlugins: async () => {
+ const results: PluginType[] = await api.loadPaginatedResults(
+ `api/organizations/@current/pipeline_destinations`
+ )
+ const plugins: Record = {}
+ for (const plugin of results) {
+ plugins[plugin.id] = plugin
+ }
+ return plugins
+ },
+ },
+ ],
+ pluginConfigs: [
+ {} as Record,
+ {
+ loadPluginConfigs: async () => {
+ const pluginConfigs: Record = {}
+ const results = await api.loadPaginatedResults(
+ `api/projects/${values.currentTeamId}/pipeline_destinations_configs`
+ )
+
+ for (const pluginConfig of results) {
+ pluginConfigs[pluginConfig.id] = {
+ ...pluginConfig,
+ // If this pluginConfig doesn't have a name of desciption, use the plugin's
+ // note that this will get saved to the db on certain actions and that's fine
+ name: pluginConfig.name || values.plugins[pluginConfig.plugin]?.name || 'Unknown app',
+ description: pluginConfig.description || values.plugins[pluginConfig.plugin]?.description,
+ }
+ }
+ return pluginConfigs
+ },
+ toggleEnabled: async ({ id, enabled }) => {
+ if (!values.canConfigurePlugins) {
+ return values.pluginConfigs
+ }
+ const { pluginConfigs, plugins } = values
+ const pluginConfig = pluginConfigs[id]
+ const plugin = plugins[pluginConfig.plugin]
+ capturePluginEvent(`plugin ${enabled ? 'enabled' : 'disabled'}`, plugin, pluginConfig)
+ const response = await api.update(`api/plugin_config/${id}`, {
+ enabled,
+ })
+ return { ...pluginConfigs, [id]: response }
+ },
+ },
+ ],
+ })),
+ selectors({
+ loading: [
+ (s) => [s.pluginsLoading, s.pluginConfigsLoading],
+ (pluginsLoading, pluginConfigsLoading) => pluginsLoading || pluginConfigsLoading,
+ ],
+ enabledPluginConfigs: [
+ (s) => [s.pluginConfigs],
+ (pluginConfigs) => {
+ return Object.values(pluginConfigs).filter((pc) => pc.enabled)
+ },
+ ],
+ disabledPluginConfigs: [
+ (s) => [s.pluginConfigs],
+ (pluginConfigs) => Object.values(pluginConfigs).filter((pc) => !pc.enabled),
+ ],
+ // This is currently an organization level setting but might in the future be user level
+ // it's better to add the permission checks everywhere now
+ canConfigurePlugins: [(s) => [s.user], (user) => canConfigurePlugins(user?.organization)],
+ shouldShowProductIntroduction: [
+ (s) => [s.user],
+ (user): boolean => {
+ return !user?.has_seen_product_intro_for?.[ProductKey.PIPELINE_DESTINATIONS]
+ },
+ ],
+ }),
+ afterMount(({ actions }) => {
+ actions.loadPlugins()
+ actions.loadPluginConfigs()
+ }),
+])
diff --git a/frontend/src/scenes/pipeline/transformationsLogic.tsx b/frontend/src/scenes/pipeline/transformationsLogic.tsx
index 5bb16e830253b..1e3f0ee304b45 100644
--- a/frontend/src/scenes/pipeline/transformationsLogic.tsx
+++ b/frontend/src/scenes/pipeline/transformationsLogic.tsx
@@ -1,7 +1,6 @@
import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
-import posthog from 'posthog-js'
import { canConfigurePlugins } from 'scenes/plugins/access'
import { teamLogic } from 'scenes/teamLogic'
import { userLogic } from 'scenes/userLogic'
@@ -9,14 +8,7 @@ import { userLogic } from 'scenes/userLogic'
import { PluginConfigTypeNew, PluginType, ProductKey } from '~/types'
import type { pipelineTransformationsLogicType } from './transformationsLogicType'
-
-function capturePluginEvent(event: string, plugin: PluginType, pluginConfig: PluginConfigTypeNew): void {
- posthog.capture(event, {
- plugin_id: plugin.id,
- plugin_name: plugin.name,
- plugin_config_id: pluginConfig.id,
- })
-}
+import { capturePluginEvent } from './utils'
export const pipelineTransformationsLogic = kea([
path(['scenes', 'pipeline', 'transformationsLogic']),
diff --git a/frontend/src/scenes/pipeline/utils.tsx b/frontend/src/scenes/pipeline/utils.tsx
index 08b92e2ea9638..2e71867f523b0 100644
--- a/frontend/src/scenes/pipeline/utils.tsx
+++ b/frontend/src/scenes/pipeline/utils.tsx
@@ -1,9 +1,18 @@
import api from 'lib/api'
import { Link } from 'lib/lemon-ui/Link'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
+import posthog from 'posthog-js'
import { PluginImage } from 'scenes/plugins/plugin/PluginImage'
-import { PluginType } from '~/types'
+import { PluginConfigTypeNew, PluginType } from '~/types'
+
+export function capturePluginEvent(event: string, plugin: PluginType, pluginConfig: PluginConfigTypeNew): void {
+ posthog.capture(event, {
+ plugin_id: plugin.id,
+ plugin_name: plugin.name,
+ plugin_config_id: pluginConfig.id,
+ })
+}
const PAGINATION_DEFAULT_MAX_PAGES = 10
export async function loadPaginatedResults(
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ce89ac46a0e56..3888e395b1996 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -109,6 +109,7 @@ export enum ProductKey {
EARLY_ACCESS_FEATURES = 'early_access_features',
PRODUCT_ANALYTICS = 'product_analytics',
PIPELINE_TRANSFORMATIONS = 'pipeline_transformations',
+ PIPELINE_DESTINATIONS = 'pipeline_destinations',
}
export enum LicensePlan {
@@ -1576,10 +1577,10 @@ export interface PluginConfigTypeNew {
team_id: number
enabled: boolean
order: number
- error?: PluginErrorType
name: string
description?: string
updated_at: string
+ delivery_rate_24h?: number | null
}
export interface PluginConfigWithPluginInfo extends PluginConfigType {
diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py
index 72443dbe6cc51..6eb06a1649514 100644
--- a/posthog/api/__init__.py
+++ b/posthog/api/__init__.py
@@ -82,6 +82,12 @@ def api_not_found(request):
"pipeline_transformations_configs",
["team_id"],
)
+pipeline_destinations_configs_router = projects_router.register(
+ r"pipeline_destinations_configs",
+ plugin.PipelineDestinationsConfigsViewSet,
+ "pipeline_destinations_configs",
+ ["team_id"],
+)
projects_router.register(r"annotations", annotation.AnnotationsViewSet, "project_annotations", ["team_id"])
projects_router.register(
@@ -192,6 +198,12 @@ def api_not_found(request):
"organization_pipeline_transformations",
["organization_id"],
)
+organization_pipeline_destinations_router = organizations_router.register(
+ r"pipeline_destinations",
+ plugin.PipelineDestinationsViewSet,
+ "organization_pipeline_destinations",
+ ["organization_id"],
+)
organizations_router.register(
r"members",
organization_member.OrganizationMemberViewSet,
diff --git a/posthog/api/plugin.py b/posthog/api/plugin.py
index 46fa5f3c2a422..2cda937296883 100644
--- a/posthog/api/plugin.py
+++ b/posthog/api/plugin.py
@@ -852,3 +852,24 @@ def get_queryset(self):
return queryset.filter(
Q(plugin__capabilities__has_key="methods") & Q(plugin__capabilities__methods__contains=["processEvent"])
)
+
+
+class PipelineDestinationsViewSet(PluginViewSet):
+ def get_queryset(self):
+ queryset = super().get_queryset()
+ return queryset.filter(
+ Q(capabilities__has_key="methods")
+ & (Q(capabilities__methods__contains=["onEvent"]) | Q(capabilities__methods__contains=["composeWebhook"]))
+ )
+
+
+class PipelineDestinationsConfigsViewSet(PluginConfigViewSet):
+ def get_queryset(self):
+ queryset = super().get_queryset()
+ return queryset.filter(
+ Q(plugin__capabilities__has_key="methods")
+ & (
+ Q(plugin__capabilities__methods__contains=["onEvent"])
+ | Q(plugin__capabilities__methods__contains=["composeWebhook"])
+ )
+ )