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"]) + ) + )