diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 39df67b53f..1cd246fb8c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2156,10 +2156,21 @@ export interface EndpointType extends WithAccessControl { updated_at: string created_by: UserBasicType | null cache_age_seconds: number + is_materialized: boolean /** Purely local value to determine whether the query endpoint should be highlighted, e.g. as a fresh duplicate. */ _highlight?: boolean /** Last execution time from ClickHouse query_log table */ last_executed_at?: string + materialization?: EndpointMaterializationType +} + +export interface EndpointMaterializationType { + can_materialize: boolean + reason?: string + status?: string + error?: string + last_materialized_at?: string + sync_frequency?: DataWarehouseSyncInterval } export interface DashboardBasicType extends WithAccessControl { diff --git a/playwright/__snapshots__/e2e/insight-sharing-password.spec.ts-snapshots/insight-sharing-password-login-chromium-linux.png b/playwright/__snapshots__/e2e/insight-sharing-password.spec.ts-snapshots/insight-sharing-password-login-chromium-linux.png index b2b426c0e4..f9585899f2 100644 Binary files a/playwright/__snapshots__/e2e/insight-sharing-password.spec.ts-snapshots/insight-sharing-password-login-chromium-linux.png and b/playwright/__snapshots__/e2e/insight-sharing-password.spec.ts-snapshots/insight-sharing-password-login-chromium-linux.png differ diff --git a/products/endpoints/frontend/EndpointCodeExamples.tsx b/products/endpoints/frontend/EndpointCodeExamples.tsx index 693077f7f3..1a206ad016 100644 --- a/products/endpoints/frontend/EndpointCodeExamples.tsx +++ b/products/endpoints/frontend/EndpointCodeExamples.tsx @@ -5,6 +5,7 @@ import { LemonSelect } from '@posthog/lemon-ui' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' import { SceneSection } from '~/layout/scenes/components/SceneSection' +import { isInsightQueryNode } from '~/queries/utils' import { CodeExampleTab, endpointLogic } from './endpointLogic' @@ -19,10 +20,10 @@ function generateVariablesJson(variables: Record): string { } return entries - .map(([key, value], index) => { + .map(([_, value], index) => { const isLast = index === entries.length - 1 const comma = isLast ? '' : ',' - return ` "${key}": ${JSON.stringify(value.value)}${comma}` + return ` "${value.code_name}": ${JSON.stringify(value.value)}${comma}` }) .join('\n') } @@ -93,11 +94,11 @@ export function EndpointCodeExamples({ tabId }: EndpointCodeExamplesProps): JSX. const { setActiveCodeExampleTab } = useActions(endpointLogic({ tabId })) const { activeCodeExampleTab, endpoint } = useValues(endpointLogic({ tabId })) - if (!endpoint) { + if (!endpoint || isInsightQueryNode(endpoint.query)) { return <> } - const variables = endpoint.parameters || {} + const variables = endpoint.query.variables || {} const getCodeExample = (tab: CodeExampleTab): string => { switch (tab) { diff --git a/products/endpoints/frontend/EndpointConfiguration.tsx b/products/endpoints/frontend/EndpointConfiguration.tsx index 0937def534..57eed6d67f 100644 --- a/products/endpoints/frontend/EndpointConfiguration.tsx +++ b/products/endpoints/frontend/EndpointConfiguration.tsx @@ -1,10 +1,12 @@ import { useActions, useValues } from 'kea' -import { LemonDivider, LemonSelect } from '@posthog/lemon-ui' +import { IconDatabase, IconRefresh } from '@posthog/icons' +import { LemonBanner, LemonDivider, LemonSelect, LemonSwitch } from '@posthog/lemon-ui' import { LemonField } from 'lib/lemon-ui/LemonField' import { SceneSection } from '~/layout/scenes/components/SceneSection' +import { DataWarehouseSyncInterval } from '~/types' import { endpointLogic } from './endpointLogic' @@ -13,7 +15,6 @@ interface EndpointConfigurationProps { } type CacheAgeOption = number | null - const CACHE_AGE_OPTIONS: { value: CacheAgeOption; label: string }[] = [ { value: null, label: 'Default caching behavior' }, { value: 300, label: '5 minutes' }, @@ -25,14 +26,35 @@ const CACHE_AGE_OPTIONS: { value: CacheAgeOption; label: string }[] = [ { value: 259200, label: '3 days' }, ] +const SYNC_FREQUENCY_OPTIONS: { value: DataWarehouseSyncInterval; label: string }[] = [ + { value: '1hour', label: 'Every hour' }, + { value: '6hour', label: 'Every 6 hours' }, + { value: '24hour', label: 'Once a day' }, + { value: '7day', label: 'Once a week' }, +] + export function EndpointConfiguration({ tabId }: EndpointConfigurationProps): JSX.Element { - const { endpoint, cacheAge } = useValues(endpointLogic({ tabId })) - const { setCacheAge } = useActions(endpointLogic({ tabId })) + const { setCacheAge, setSyncFrequency, setIsMaterialized } = useActions(endpointLogic({ tabId })) + const { + endpoint, + cacheAge, + syncFrequency, + isMaterialized: localIsMaterialized, + } = useValues(endpointLogic({ tabId })) if (!endpoint) { return <> } + const canMaterialize = endpoint.materialization?.can_materialize ?? false + const isMaterialized = localIsMaterialized !== null ? localIsMaterialized : endpoint.is_materialized + const materializationStatus = endpoint.materialization?.status + const lastMaterializedAt = endpoint.materialization?.last_materialized_at + + const handleToggleMaterialization = (): void => { + setIsMaterialized(!isMaterialized) + } + return (
@@ -43,6 +65,59 @@ export function EndpointConfiguration({ tabId }: EndpointConfigurationProps): JS > + + + + +
+ {isMaterialized && ( +
+
+
+ + Materialization status +
+ + {materializationStatus || 'Pending'} + +
+ + {lastMaterializedAt && ( +
+ + Last materialized: {new Date(lastMaterializedAt).toLocaleString()} +
+ )} + + {endpoint.materialization?.error && ( + + {endpoint.materialization.error} + + )} +
+ )} + + {isMaterialized && ( + + + + )} +
diff --git a/products/endpoints/frontend/EndpointHeader.tsx b/products/endpoints/frontend/EndpointHeader.tsx index e044488468..2a0ac99713 100644 --- a/products/endpoints/frontend/EndpointHeader.tsx +++ b/products/endpoints/frontend/EndpointHeader.tsx @@ -3,6 +3,7 @@ import { useActions, useValues } from 'kea' import { LemonButton } from '@posthog/lemon-ui' import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' +import { EndpointRequest } from '~/queries/schema/schema-general' import { isInsightVizNode } from '~/queries/utils' import { endpointLogic } from './endpointLogic' @@ -14,8 +15,11 @@ export interface EndpointSceneHeaderProps { export const EndpointSceneHeader = ({ tabId }: EndpointSceneHeaderProps): JSX.Element => { const { endpoint, endpointLoading, localQuery } = useValues(endpointSceneLogic({ tabId })) - const { endpointName, endpointDescription, cacheAge } = useValues(endpointLogic({ tabId })) - const { setEndpointDescription, updateEndpoint, createEndpoint, setCacheAge } = useActions(endpointLogic({ tabId })) + const { endpointName, endpointDescription, cacheAge, syncFrequency, isMaterialized } = useValues( + endpointLogic({ tabId }) + ) + const { setEndpointDescription, updateEndpoint, createEndpoint, setCacheAge, setSyncFrequency, setIsMaterialized } = + useActions(endpointLogic({ tabId })) const { setLocalQuery } = useActions(endpointSceneLogic({ tabId })) const isNewEndpoint = !endpoint?.name || endpoint.name === 'new-endpoint' @@ -24,7 +28,15 @@ export const EndpointSceneHeader = ({ tabId }: EndpointSceneHeaderProps): JSX.El const hasDescriptionChange = endpointDescription !== null && endpointDescription !== endpoint?.description const hasQueryChange = localQuery !== null const hasCacheAgeChange = cacheAge !== (endpoint?.cache_age_seconds ?? null) - const hasChanges = hasNameChange || hasDescriptionChange || hasQueryChange || hasCacheAgeChange + const hasSyncFrequencyChange = syncFrequency !== (endpoint?.materialization?.sync_frequency ?? null) + const hasIsMaterializedChange = isMaterialized !== null && isMaterialized !== endpoint?.is_materialized + const hasChanges = + hasNameChange || + hasDescriptionChange || + hasQueryChange || + hasCacheAgeChange || + hasSyncFrequencyChange || + hasIsMaterializedChange const handleSave = (): void => { let queryToSave = (localQuery || endpoint?.query) as any @@ -35,17 +47,20 @@ export const EndpointSceneHeader = ({ tabId }: EndpointSceneHeaderProps): JSX.El if (isNewEndpoint) { createEndpoint({ - name: endpointName || endpoint?.name || '', - description: endpointDescription || endpoint?.description, + name: endpointName || '', + description: endpointDescription || undefined, query: queryToSave, }) } else { - updateEndpoint(endpoint.name, { - name: endpointName || endpoint?.name, - description: endpointDescription || endpoint?.description, - cache_age_seconds: cacheAge ?? undefined, - query: queryToSave, - }) + const updatePayload: Partial = { + description: hasDescriptionChange ? endpointDescription : undefined, + cache_age_seconds: hasCacheAgeChange ? (cacheAge ?? undefined) : undefined, + query: hasQueryChange ? queryToSave : undefined, + is_materialized: hasIsMaterializedChange ? isMaterialized : undefined, + sync_frequency: hasSyncFrequencyChange ? (syncFrequency ?? undefined) : undefined, + } + + updateEndpoint(endpoint.name, updatePayload) } } @@ -53,6 +68,8 @@ export const EndpointSceneHeader = ({ tabId }: EndpointSceneHeaderProps): JSX.El if (endpoint) { setEndpointDescription(endpoint.description || '') setCacheAge(endpoint.cache_age_seconds ?? null) + setSyncFrequency(endpoint.materialization?.sync_frequency ?? null) + setIsMaterialized(null) } setLocalQuery(null) } diff --git a/products/endpoints/frontend/endpointLogic.tsx b/products/endpoints/frontend/endpointLogic.tsx index 9bff4a1c1b..f08d9b67e4 100644 --- a/products/endpoints/frontend/endpointLogic.tsx +++ b/products/endpoints/frontend/endpointLogic.tsx @@ -4,12 +4,12 @@ import { router } from 'kea-router' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' -import { slugify } from 'lib/utils' +import { debounce, slugify } from 'lib/utils' import { permanentlyMount } from 'lib/utils/kea-logic-builders' import { urls } from 'scenes/urls' import { EndpointRequest, HogQLQuery, InsightQueryNode, NodeKind } from '~/queries/schema/schema-general' -import { EndpointType } from '~/types' +import { DataWarehouseSyncInterval, EndpointType } from '~/types' import type { endpointLogicType } from './endpointLogicType' import { endpointsLogic } from './endpointsLogic' @@ -43,18 +43,17 @@ export const endpointLogic = kea([ setIsUpdateMode: (isUpdateMode: boolean) => ({ isUpdateMode }), setSelectedEndpointName: (selectedEndpointName: string | null) => ({ selectedEndpointName }), setCacheAge: (cacheAge: number | null) => ({ cacheAge }), + setSyncFrequency: (syncFrequency: DataWarehouseSyncInterval | null) => ({ syncFrequency }), + setIsMaterialized: (isMaterialized: boolean | null) => ({ isMaterialized }), createEndpoint: (request: EndpointRequest) => ({ request }), createEndpointSuccess: (response: any) => ({ response }), - createEndpointFailure: (error: any) => ({ error }), + createEndpointFailure: () => ({}), updateEndpoint: (name: string, request: Partial) => ({ name, request }), updateEndpointSuccess: (response: any) => ({ response }), - updateEndpointFailure: (error: any) => ({ error }), + updateEndpointFailure: () => ({}), deleteEndpoint: (name: string) => ({ name }), deleteEndpointSuccess: (response: any) => ({ response }), - deleteEndpointFailure: (error: any) => ({ error }), - deactivateEndpoint: (name: string) => ({ name }), - deactivateEndpointSuccess: (response: any) => ({ response }), - deactivateEndpointFailure: (error: any) => ({ error }), + deleteEndpointFailure: () => ({}), }), reducers({ endpointName: [null as string | null, { setEndpointName: (_, { endpointName }) => endpointName }], @@ -81,6 +80,18 @@ export const endpointLogic = kea([ setCacheAge: (_, { cacheAge }) => cacheAge, }, ], + syncFrequency: [ + '24hour' as DataWarehouseSyncInterval | null, + { + setSyncFrequency: (_, { syncFrequency }) => syncFrequency, + }, + ], + isMaterialized: [ + null as boolean | null, + { + setIsMaterialized: (_, { isMaterialized }) => isMaterialized, + }, + ], }), loaders(({ actions }) => ({ endpoint: [ @@ -104,99 +115,77 @@ export const endpointLogic = kea([ // TODO: This does not belong here. Refactor to the endpointSceneLogic? actions.setCacheAge(endpoint.cache_age_seconds ?? null) + actions.setSyncFrequency(endpoint.materialization?.sync_frequency ?? null) + actions.setIsMaterialized(endpoint.is_materialized ?? null) return endpoint }, }, ], })), - listeners(({ actions }) => ({ - createEndpoint: async ({ request }) => { - try { - if (request.name) { - request.name = slugify(request.name) + listeners(({ actions }) => { + const reloadEndpoint = debounce((name: string): void => { + actions.loadEndpoint(name) + }, 2000) + return { + createEndpoint: async ({ request }) => { + try { + if (request.name) { + request.name = slugify(request.name) + } + const response = await api.endpoint.create(request) + actions.createEndpointSuccess(response) + } catch (error) { + console.error('Failed to create endpoint:', error) + actions.createEndpointFailure() } - const response = await api.endpoint.create(request) - actions.createEndpointSuccess(response) - } catch (error) { - console.error('Failed to create endpoint:', error) - actions.createEndpointFailure(error) - } - }, - createEndpointSuccess: ({ response }) => { - actions.setEndpointName('') - actions.setEndpointDescription('') - lemonToast.success( - <> - Endpoint created successfully! -
- You will be redirected to the endpoint page. - , - { - onClose: () => { - router.actions.push(urls.endpoint(response.name)) + }, + createEndpointSuccess: ({ response }) => { + actions.setEndpointName('') + actions.setEndpointDescription('') + lemonToast.success(<>Endpoint created, { + button: { + label: 'View', + action: () => router.actions.push(urls.endpoint(response.name)), }, - } - ) - }, - createEndpointFailure: ({ error }) => { - console.error('Failed to create endpoint:', error) - lemonToast.error('Failed to create endpoint') - }, - updateEndpoint: async ({ name, request }) => { - try { - const response = await api.endpoint.update(name, request) - actions.updateEndpointSuccess(response) - } catch (error) { - console.error('Failed to update endpoint:', error) - actions.updateEndpointFailure(error) - } - }, - updateEndpointSuccess: ({ response }) => { - lemonToast.success('Endpoint updated successfully') - actions.loadEndpoint(response.name) - }, - updateEndpointFailure: ({ error }) => { - console.error('Failed to update endpoint:', error) - lemonToast.error('Failed to update endpoint') - }, - deleteEndpoint: async ({ name }) => { - try { - // TODO: Add confirmation dialog - await api.endpoint.delete(name) - actions.deleteEndpointSuccess(name) - } catch (error) { - console.error('Failed to delete endpoint:', error) - actions.deleteEndpointFailure(error) - } - }, - deleteEndpointSuccess: () => { - lemonToast.success('Endpoint deleted successfully') - actions.loadEndpoints() - }, - deleteEndpointFailure: ({ error }) => { - console.error('Failed to delete endpoint:', error) - lemonToast.error('Failed to delete endpoint') - }, - deactivateEndpoint: async ({ name }) => { - try { - await api.endpoint.update(name, { - is_active: false, }) - actions.deactivateEndpointSuccess({}) - } catch (error) { - console.error('Failed to deactivate endpoint:', error) - actions.deactivateEndpointFailure(error) - } - }, - deactivateEndpointSuccess: () => { - lemonToast.success('Endpoint deactivated successfully') - actions.loadEndpoints() - }, - deactivateEndpointFailure: ({ error }) => { - console.error('Failed to deactivate endpoint:', error) - lemonToast.error('Failed to deactivate endpoint') - }, - })), + }, + createEndpointFailure: () => { + lemonToast.error('Failed to create endpoint') + }, + updateEndpoint: async ({ name, request }) => { + try { + const response = await api.endpoint.update(name, request) + actions.updateEndpointSuccess(response) + } catch (error) { + console.error('Failed to update endpoint:', error) + actions.updateEndpointFailure() + } + }, + updateEndpointSuccess: ({ response }) => { + lemonToast.success('Endpoint updated') + reloadEndpoint(response.name) + }, + updateEndpointFailure: () => { + lemonToast.error('Failed to update endpoint') + }, + deleteEndpoint: async ({ name }) => { + try { + await api.endpoint.delete(name) + actions.deleteEndpointSuccess(name) + } catch (error) { + console.error('Failed to delete endpoint:', error) + actions.deleteEndpointFailure() + } + }, + deleteEndpointSuccess: () => { + lemonToast.success('Endpoint deleted') + actions.loadEndpoints() + }, + deleteEndpointFailure: () => { + lemonToast.error('Failed to delete endpoint') + }, + } + }), permanentlyMount(), ])