index.tsx453 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { uniq } from 'lodash' |
| 3 | import { Loader2 } from 'lucide-react' |
| 4 | import Link from 'next/link' |
| 5 | import { useRouter } from 'next/router' |
| 6 | import { parseAsBoolean, useQueryState } from 'nuqs' |
| 7 | import { useEffect, useMemo, useState } from 'react' |
| 8 | import { Button, Card, CardContent } from 'ui' |
| 9 | import { EmptyStatePresentational } from 'ui-patterns' |
| 10 | import { Admonition } from 'ui-patterns/admonition' |
| 11 | import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader' |
| 12 | |
| 13 | import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal' |
| 14 | import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket' |
| 15 | import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants' |
| 16 | import { BucketHeader } from './BucketHeader' |
| 17 | import { CreateTableInstructions } from './CreateTable/CreateTableInstructions' |
| 18 | import { NamespaceWithTables } from './NamespaceWithTables' |
| 19 | import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' |
| 20 | import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities' |
| 21 | import { useIcebergWrapperExtension } from './useIcebergWrapper' |
| 22 | import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants' |
| 23 | import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' |
| 24 | import { |
| 25 | convertKVStringArrayToJson, |
| 26 | formatWrapperTables, |
| 27 | } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils' |
| 28 | import { |
| 29 | ScaffoldContainer, |
| 30 | ScaffoldSection, |
| 31 | ScaffoldSectionTitle, |
| 32 | } from '@/components/layouts/Scaffold' |
| 33 | import AlertError from '@/components/ui/AlertError' |
| 34 | import { InlineLink } from '@/components/ui/InlineLink' |
| 35 | import { |
| 36 | DatabaseExtension, |
| 37 | useDatabaseExtensionsQuery, |
| 38 | } from '@/data/database-extensions/database-extensions-query' |
| 39 | import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' |
| 40 | import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' |
| 41 | import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' |
| 42 | import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation' |
| 43 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 44 | import { DOCS_URL } from '@/lib/constants' |
| 45 | |
| 46 | export const AnalyticBucketDetails = () => { |
| 47 | const router = useRouter() |
| 48 | const { ref: projectRef } = useParams() |
| 49 | const { data: project } = useSelectedProjectQuery() |
| 50 | const { state: extensionState } = useIcebergWrapperExtension() |
| 51 | const { |
| 52 | data: bucket, |
| 53 | error: bucketError, |
| 54 | isSuccess: isSuccessBucket, |
| 55 | isError: isErrorBucket, |
| 56 | } = useSelectedAnalyticsBucket() |
| 57 | |
| 58 | const [showDeleteModal, setShowDeleteModal] = useQueryState( |
| 59 | 'delete', |
| 60 | parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) |
| 61 | ) |
| 62 | // [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after |
| 63 | // updating connected tables until namespaces are updated |
| 64 | // Namespace would just be the schema (Which is currently limited to public) |
| 65 | // Wrapper table would be {schema}_{table}_changelog |
| 66 | const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) |
| 67 | const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) |
| 68 | |
| 69 | const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation() |
| 70 | |
| 71 | const { |
| 72 | publication, |
| 73 | pipeline, |
| 74 | icebergWrapper: wrapperInstance, |
| 75 | isLoadingWrapperInstance, |
| 76 | } = useAnalyticsBucketAssociatedEntities({ |
| 77 | projectRef, |
| 78 | bucketId: bucket?.name, |
| 79 | }) |
| 80 | const { data, isSuccess: isSuccessPipelineStatus } = useReplicationPipelineStatusQuery( |
| 81 | { projectRef, pipelineId: pipeline?.id }, |
| 82 | { |
| 83 | refetchInterval: (query) => { |
| 84 | const data = query.state.data |
| 85 | if (data?.status.name !== 'started') return 4000 |
| 86 | else return false |
| 87 | }, |
| 88 | } |
| 89 | ) |
| 90 | const pipelineStatus = data?.status.name |
| 91 | const isPipelineRunning = pipelineStatus === 'started' |
| 92 | const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '') |
| 93 | |
| 94 | const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) |
| 95 | const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') |
| 96 | const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta |
| 97 | const state = isLoadingWrapperInstance |
| 98 | ? 'loading' |
| 99 | : extensionState === 'installed' |
| 100 | ? wrapperInstance |
| 101 | ? 'added' |
| 102 | : 'missing' |
| 103 | : extensionState |
| 104 | |
| 105 | const wrapperTables = useMemo(() => { |
| 106 | if (!wrapperInstance) return [] |
| 107 | return formatWrapperTables(wrapperInstance, wrapperMeta!) |
| 108 | }, [wrapperInstance, wrapperMeta]) |
| 109 | |
| 110 | const { data: extensionsData } = useDatabaseExtensionsQuery({ |
| 111 | projectRef: project?.ref, |
| 112 | connectionString: project?.connectionString, |
| 113 | }) |
| 114 | const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers') |
| 115 | |
| 116 | const { |
| 117 | data: namespacesData = [], |
| 118 | isPending: isLoadingNamespaces, |
| 119 | isSuccess: isSuccessNamespaces, |
| 120 | } = useIcebergNamespacesQuery( |
| 121 | { |
| 122 | projectRef, |
| 123 | warehouse: wrapperValues.warehouse, |
| 124 | }, |
| 125 | { |
| 126 | refetchInterval: (query) => { |
| 127 | const data = query.state.data |
| 128 | if (pollIntervalNamespaces === 0) return false |
| 129 | |
| 130 | const publicationTableSchemas = publication?.tables.map((x) => x.schema) ?? [] |
| 131 | const isSynced = !publicationTableSchemas.some((x) => !data?.includes(x)) |
| 132 | if (isSynced) { |
| 133 | setPollIntervalNamespaces(0) |
| 134 | return false |
| 135 | } |
| 136 | |
| 137 | return pollIntervalNamespaces |
| 138 | }, |
| 139 | } |
| 140 | ) |
| 141 | |
| 142 | const publicationTableSchemas = (publication?.tables ?? []).map((x) => x.schema) |
| 143 | const isSyncedPublicationTableSchemasAndNamespaces = !publicationTableSchemas.some( |
| 144 | (x) => !namespacesData.includes(x) |
| 145 | ) |
| 146 | const isPollingForData = pollIntervalNamespaces > 0 || pollIntervalNamespaceTables > 0 |
| 147 | |
| 148 | const namespaces = useMemo(() => { |
| 149 | const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[] |
| 150 | const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])]) |
| 151 | |
| 152 | return namespaces.map((namespace) => { |
| 153 | const tables = wrapperTables.filter((t) => t.table.split('.')[0] === namespace) |
| 154 | const schema = tables[0]?.schema |
| 155 | |
| 156 | return { |
| 157 | namespace: namespace, |
| 158 | schema: schema, |
| 159 | tables: tables, |
| 160 | } |
| 161 | }) |
| 162 | }, [wrapperTables, namespacesData]) |
| 163 | |
| 164 | useEffect(() => { |
| 165 | if (isSuccessNamespaces && !isSyncedPublicationTableSchemasAndNamespaces) { |
| 166 | setPollIntervalNamespaces(4000) |
| 167 | } |
| 168 | }, [isSuccessNamespaces, isSyncedPublicationTableSchemasAndNamespaces]) |
| 169 | |
| 170 | return ( |
| 171 | <> |
| 172 | {isErrorBucket ? ( |
| 173 | <ScaffoldContainer bottomPadding> |
| 174 | <ScaffoldSection isFullWidth> |
| 175 | <AlertError subject="Failed to fetch analytics buckets" error={bucketError} /> |
| 176 | </ScaffoldSection> |
| 177 | </ScaffoldContainer> |
| 178 | ) : ( |
| 179 | <ScaffoldContainer bottomPadding> |
| 180 | {state === 'loading' ? ( |
| 181 | <ScaffoldSection isFullWidth> |
| 182 | <BucketHeader showActions={false} /> |
| 183 | <GenericTableLoader /> |
| 184 | </ScaffoldSection> |
| 185 | ) : state === 'not-installed' ? ( |
| 186 | <ExtensionNotInstalled |
| 187 | bucketName={bucket?.name} |
| 188 | projectRef={project?.ref!} |
| 189 | wrapperMeta={wrapperMeta} |
| 190 | wrappersExtension={wrappersExtension!} |
| 191 | /> |
| 192 | ) : state === 'needs-upgrade' ? ( |
| 193 | <ExtensionNeedsUpgrade |
| 194 | bucketName={bucket?.name} |
| 195 | projectRef={project?.ref!} |
| 196 | wrapperMeta={wrapperMeta} |
| 197 | wrappersExtension={wrappersExtension!} |
| 198 | /> |
| 199 | ) : state === 'missing' ? ( |
| 200 | <WrapperMissing bucketName={bucket?.name} /> |
| 201 | ) : state === 'added' && wrapperInstance ? ( |
| 202 | <> |
| 203 | <ScaffoldSection isFullWidth> |
| 204 | <BucketHeader /> |
| 205 | |
| 206 | {isLoadingNamespaces || isLoadingWrapperInstance ? ( |
| 207 | <GenericTableLoader headers={['Name']} /> |
| 208 | ) : namespaces.length === 0 ? ( |
| 209 | <> |
| 210 | {HIDE_REPLICATION_USER_FLOW ? ( |
| 211 | <CreateTableInstructions /> |
| 212 | ) : isPollingForData ? ( |
| 213 | <EmptyStatePresentational |
| 214 | icon={ |
| 215 | <Loader2 |
| 216 | size={24} |
| 217 | strokeWidth={1.5} |
| 218 | className="animate-spin text-foreground-muted" |
| 219 | /> |
| 220 | } |
| 221 | title="Connecting table(s) to bucket" |
| 222 | description="Tables will be shown here once the connection is complete" |
| 223 | /> |
| 224 | ) : null} |
| 225 | </> |
| 226 | ) : ( |
| 227 | <> |
| 228 | {!!pipeline && !!isSuccessPipelineStatus && !isPipelineRunning && ( |
| 229 | <Admonition |
| 230 | type="note" |
| 231 | layout="horizontal" |
| 232 | className="[&>div]:pl-10 [&>div]:translate-y-[-3px]" |
| 233 | childProps={{ title: { className: 'block capitalize-sentence' } }} |
| 234 | showIcon={isPipelineStopped} |
| 235 | title={ |
| 236 | isPipelineStopped |
| 237 | ? `Replication on the bucket has ${pipelineStatus}` |
| 238 | : `${pipelineStatus} replication on the bucket...` |
| 239 | } |
| 240 | description={ |
| 241 | isPipelineStopped |
| 242 | ? 'Data changes from Postgres tables is currently not streaming to their corresponding analytics bucket table' |
| 243 | : 'Data changes from Postgres tables will resume streaming once pipeline has started' |
| 244 | } |
| 245 | actions={ |
| 246 | <div className="flex items-center gap-x-2"> |
| 247 | <Button asChild type="default"> |
| 248 | <Link |
| 249 | href={`/project/${projectRef}/database/replication/${pipeline.replicator_id}`} |
| 250 | > |
| 251 | View replication |
| 252 | </Link> |
| 253 | </Button> |
| 254 | {isPipelineStopped && ( |
| 255 | <Button |
| 256 | type="default" |
| 257 | loading={isStartingPipeline} |
| 258 | onClick={async () => { |
| 259 | if (projectRef) { |
| 260 | await startPipeline({ projectRef, pipelineId: pipeline.id }) |
| 261 | } |
| 262 | }} |
| 263 | > |
| 264 | Restart |
| 265 | </Button> |
| 266 | )} |
| 267 | </div> |
| 268 | } |
| 269 | > |
| 270 | {!isPipelineStopped && ( |
| 271 | <Loader2 size={18} className="absolute top-1.5 left-[3px] animate-spin" /> |
| 272 | )} |
| 273 | </Admonition> |
| 274 | )} |
| 275 | <div className="flex flex-col gap-y-10"> |
| 276 | {namespaces.map(({ namespace, schema, tables }) => ( |
| 277 | <NamespaceWithTables |
| 278 | key={namespace} |
| 279 | namespace={namespace} |
| 280 | sourceType="direct" |
| 281 | schema={schema} |
| 282 | tables={tables as any} |
| 283 | wrapperValues={wrapperValues} |
| 284 | pollIntervalNamespaceTables={pollIntervalNamespaceTables} |
| 285 | setPollIntervalNamespaceTables={setPollIntervalNamespaceTables} |
| 286 | /> |
| 287 | ))} |
| 288 | </div> |
| 289 | </> |
| 290 | )} |
| 291 | </ScaffoldSection> |
| 292 | |
| 293 | <SimpleConfigurationDetails bucketName={bucket?.name} /> |
| 294 | </> |
| 295 | ) : null} |
| 296 | |
| 297 | <ScaffoldSection isFullWidth className="flex flex-col gap-y-4"> |
| 298 | <header> |
| 299 | <ScaffoldSectionTitle>Manage</ScaffoldSectionTitle> |
| 300 | </header> |
| 301 | <Card> |
| 302 | <CardContent className="flex flex-col md:flex-row md:justify-between gap-y-4 gap-x-8 md:items-center"> |
| 303 | <div className="flex flex-col"> |
| 304 | <h3>Delete bucket</h3> |
| 305 | <p className="text-sm text-foreground-lighter"> |
| 306 | This will also delete any data in your bucket. Make sure you have a backup if |
| 307 | you want to keep your data. |
| 308 | </p> |
| 309 | </div> |
| 310 | <Button |
| 311 | type="danger" |
| 312 | disabled={!bucket?.name || !isSuccessBucket} |
| 313 | onClick={() => setShowDeleteModal(true)} |
| 314 | > |
| 315 | Delete bucket |
| 316 | </Button> |
| 317 | </CardContent> |
| 318 | </Card> |
| 319 | </ScaffoldSection> |
| 320 | </ScaffoldContainer> |
| 321 | )} |
| 322 | |
| 323 | <DeleteAnalyticsBucketModal |
| 324 | visible={showDeleteModal} |
| 325 | bucketId={bucket?.name} |
| 326 | onClose={() => setShowDeleteModal(false)} |
| 327 | onSuccess={() => router.push(`/project/${projectRef}/storage/analytics`)} |
| 328 | /> |
| 329 | </> |
| 330 | ) |
| 331 | } |
| 332 | |
| 333 | const ExtensionNotInstalled = ({ |
| 334 | bucketName, |
| 335 | projectRef, |
| 336 | wrapperMeta, |
| 337 | wrappersExtension, |
| 338 | }: { |
| 339 | bucketName?: string |
| 340 | projectRef: string |
| 341 | wrapperMeta: WrapperMeta |
| 342 | wrappersExtension: DatabaseExtension |
| 343 | }) => { |
| 344 | const databaseNeedsUpgrading = |
| 345 | (wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '') |
| 346 | |
| 347 | return ( |
| 348 | <> |
| 349 | <ScaffoldSection isFullWidth> |
| 350 | <Admonition type="warning" title="Missing required extension"> |
| 351 | <p> |
| 352 | The Wrappers extension is required in order to query analytics tables.{' '} |
| 353 | {databaseNeedsUpgrading && |
| 354 | 'Please first upgrade your database and then install the extension.'}{' '} |
| 355 | <InlineLink |
| 356 | href={`${DOCS_URL}/guides/database/extensions/wrappers/iceberg`} |
| 357 | target="_blank" |
| 358 | rel="noreferrer" |
| 359 | className="text-foreground-lighter hover:text-foreground transition-colors" |
| 360 | > |
| 361 | Learn more |
| 362 | </InlineLink> |
| 363 | </p> |
| 364 | <Button type="default" asChild className="mt-2" onClick={() => {}}> |
| 365 | <Link |
| 366 | href={ |
| 367 | databaseNeedsUpgrading |
| 368 | ? `/project/${projectRef}/settings/infrastructure` |
| 369 | : `/project/${projectRef}/database/extensions?filter=wrappers` |
| 370 | } |
| 371 | > |
| 372 | {databaseNeedsUpgrading ? 'Upgrade database' : 'Install extension'} |
| 373 | </Link> |
| 374 | </Button> |
| 375 | </Admonition> |
| 376 | </ScaffoldSection> |
| 377 | <SimpleConfigurationDetails bucketName={bucketName} /> |
| 378 | </> |
| 379 | ) |
| 380 | } |
| 381 | |
| 382 | const ExtensionNeedsUpgrade = ({ |
| 383 | bucketName, |
| 384 | projectRef, |
| 385 | wrapperMeta, |
| 386 | wrappersExtension, |
| 387 | }: { |
| 388 | bucketName?: string |
| 389 | projectRef: string |
| 390 | wrapperMeta: WrapperMeta |
| 391 | wrappersExtension: DatabaseExtension |
| 392 | }) => { |
| 393 | // [Joshen] Default version is what's on the DB, so if the installed version is already the default version |
| 394 | // but still doesnt meet the minimum extension version, then DB upgrade is required |
| 395 | const databaseNeedsUpgrading = |
| 396 | wrappersExtension?.installed_version === wrappersExtension?.default_version |
| 397 | |
| 398 | return ( |
| 399 | <> |
| 400 | <ScaffoldSection isFullWidth> |
| 401 | <Admonition type="warning" title="Outdated extension version"> |
| 402 | <p> |
| 403 | The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} |
| 404 | {wrapperMeta.minimumExtensionVersion}. You have version{' '} |
| 405 | {wrappersExtension?.installed_version} installed. Please{' '} |
| 406 | {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension |
| 407 | by disabling and enabling the Wrappers extension. |
| 408 | </p> |
| 409 | <p> |
| 410 | Before reinstalling the wrapper extension, you must first remove all existing wrappers. |
| 411 | Afterward, you can recreate the wrappers. |
| 412 | </p> |
| 413 | <Button asChild type="default"> |
| 414 | <Link |
| 415 | href={ |
| 416 | databaseNeedsUpgrading |
| 417 | ? `/project/${projectRef}/settings/infrastructure` |
| 418 | : `/project/${projectRef}/database/extensions?filter=wrappers` |
| 419 | } |
| 420 | > |
| 421 | {databaseNeedsUpgrading ? 'Upgrade database' : 'Extensions'} |
| 422 | </Link> |
| 423 | </Button> |
| 424 | </Admonition> |
| 425 | </ScaffoldSection> |
| 426 | <SimpleConfigurationDetails bucketName={bucketName} /> |
| 427 | </> |
| 428 | ) |
| 429 | } |
| 430 | |
| 431 | const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { |
| 432 | const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } = |
| 433 | useIcebergWrapperCreateMutation() |
| 434 | |
| 435 | const onSetupWrapper = async () => { |
| 436 | if (!bucketName) return console.error('Bucket name is required') |
| 437 | await createIcebergWrapper({ bucketName }) |
| 438 | } |
| 439 | |
| 440 | return ( |
| 441 | <> |
| 442 | <ScaffoldSection isFullWidth> |
| 443 | <Admonition type="warning" title="Missing integration"> |
| 444 | <p>The Iceberg Wrapper integration is required in order to query analytics tables.</p> |
| 445 | <Button type="default" loading={isCreatingIcebergWrapper} onClick={onSetupWrapper}> |
| 446 | Install wrapper |
| 447 | </Button> |
| 448 | </Admonition> |
| 449 | </ScaffoldSection> |
| 450 | <SimpleConfigurationDetails bucketName={bucketName} /> |
| 451 | </> |
| 452 | ) |
| 453 | } |