diff --git a/apps/studio/components/interfaces/Home/Home.tsx b/apps/studio/components/interfaces/Home/Home.tsx index fc24055f47dd7..e040864b476ed 100644 --- a/apps/studio/components/interfaces/Home/Home.tsx +++ b/apps/studio/components/interfaces/Home/Home.tsx @@ -98,6 +98,14 @@ export const Home = () => { // [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally const replicasCount = Math.max(0, (replicasData?.length ?? 1) - 1) + if (isPaused) { + return ( +
+ +
+ ) + } + return (
@@ -200,84 +208,81 @@ export const Home = () => {
- {isPaused && } - {!isPaused && ( - <> -
-
- {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && ( - <>{isNewProject ? : } - )} - {!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && } -
+ <> +
+
+ {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && ( + <>{isNewProject ? : } + )} + {!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && }
+
-
-
- {project?.status !== PROJECT_STATUS.INACTIVE && ( - <> -
-

Client libraries

-
- {clientLibraries!.map((library) => ( - // [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. - - ))} -
+
+
+ {project?.status !== PROJECT_STATUS.INACTIVE && ( + <> +
+

Client libraries

+
+ {clientLibraries!.map((library) => ( + // [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. + + ))}
- {showExamples && ( -
-

Example projects

- {!!projectHomepageExampleProjects ? ( -
- {/* [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. */} - {(projectHomepageExampleProjects as any) - .sort((a: any, b: any) => a.title.localeCompare(b.title)) - .map((project: any) => ( - - ))} -
- ) : ( -
- - - App Frameworks - - Mobile Frameworks - - - -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'app') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
- -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
-
-
- )} -
- )} - - )} -
+
+ {showExamples && ( +
+

Example projects

+ {!!projectHomepageExampleProjects ? ( +
+ {/* [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. */} + {(projectHomepageExampleProjects as any) + .sort((a: any, b: any) => a.title.localeCompare(b.title)) + .map((project: any) => ( + + ))} +
+ ) : ( +
+ + + App Frameworks + + Mobile Frameworks + + + +
+ {EXAMPLE_PROJECTS.filter((project) => project.type === 'app') + .sort((a, b) => a.title.localeCompare(b.title)) + .map((project) => ( + + ))} +
+
+ +
+ {EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile') + .sort((a, b) => a.title.localeCompare(b.title)) + .map((project) => ( + + ))} +
+
+
+
+ )} +
+ )} + + )}
- - )} +
+
) } diff --git a/apps/studio/components/interfaces/HomeNew/TopSection.tsx b/apps/studio/components/interfaces/HomeNew/TopSection.tsx index 498deedb42472..e32c7aa711d22 100644 --- a/apps/studio/components/interfaces/HomeNew/TopSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/TopSection.tsx @@ -37,19 +37,7 @@ export const TopSection = () => { : 'Welcome to your project' if (isPaused) { - return ( -
-
- {!isMainBranch && ( - - {parentProject?.name} - - )} -

{projectName}

-
- -
- ) + return } return ( diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index 8a6801bc82a32..02530e3722257 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -1,5 +1,5 @@ -import { Datadog, Grafana, Sentry } from 'icons' import { components } from 'api-types' +import { Datadog, Grafana, Sentry } from 'icons' import { BracesIcon } from 'lucide-react' const iconProps = { @@ -8,6 +8,8 @@ const iconProps = { className: 'text-foreground-light', } +export type LogDrainType = components['schemas']['CreateBackendParamsOpenapi']['type'] + export const LOG_DRAIN_TYPES = [ { value: 'webhook', @@ -39,14 +41,6 @@ export const LOG_DRAIN_TYPES = [ export const LOG_DRAIN_SOURCE_VALUES = LOG_DRAIN_TYPES.map((source) => source.value) -// export type LogDrainType = -// | (typeof LOG_DRAIN_TYPES)[number]['value'] -// | 'postgres' -// | 'bigquery' -// | 'elastic' - -export type LogDrainType = components['schemas']['LFBackend']['type'] - export const DATADOG_REGIONS = [ { label: 'AP1', diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx index d2d17faa3b828..b366b1f17e488 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx @@ -8,7 +8,7 @@ import { DOCS_RESOURCE_CONTENT } from '../ProjectAPIDocs.constants' import ResourceContent from '../ResourceContent' import type { ContentProps } from './Content.types' -const Bucket = ({ language, apikey, endpoint }: ContentProps) => { +export const Bucket = ({ language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() const snap = useAppStateSnapshot() const { data } = useBucketsQuery({ projectRef: ref }) @@ -106,5 +106,3 @@ const Bucket = ({ language, apikey, endpoint }: ContentProps) => {
) } - -export default Bucket diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunction.tsx index 9ff1d705b4517..89079425beb35 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunction.tsx @@ -6,7 +6,7 @@ import { DOCS_RESOURCE_CONTENT } from '../ProjectAPIDocs.constants' import ResourceContent from '../ResourceContent' import type { ContentProps } from './Content.types' -const Bucket = ({ language, apikey = 'API_KEY', endpoint }: ContentProps) => { +export const EdgeFunction = ({ language, apikey = 'API_KEY', endpoint }: ContentProps) => { const { ref } = useParams() const snap = useAppStateSnapshot() const { data } = useEdgeFunctionsQuery({ projectRef: ref }) @@ -37,5 +37,3 @@ const Bucket = ({ language, apikey = 'API_KEY', endpoint }: ContentProps) => {
) } - -export default Bucket diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunctions.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunctions.tsx index c33ccd8a267fe..4d2e542aa0c7a 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunctions.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/EdgeFunctions.tsx @@ -2,7 +2,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const EdgeFunctions = ({ language }: ContentProps) => { +export const EdgeFunctions = ({ language }: ContentProps) => { return ( <> @@ -12,5 +12,3 @@ const EdgeFunctions = ({ language }: ContentProps) => { ) } - -export default EdgeFunctions diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entities.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entities.tsx index 3fc51570a9359..53c09429b622b 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entities.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entities.tsx @@ -12,7 +12,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const Entities = ({ language }: ContentProps) => { +export const Entities = ({ language }: ContentProps) => { const { ref } = useParams() const [isGeneratingTypes, setIsGeneratingTypes] = useState(false) @@ -62,5 +62,3 @@ const Entities = ({ language }: ContentProps) => { ) } - -export default Entities diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entity.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entity.tsx index 1cd6064f7fb7b..ba969463c82bf 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entity.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Entity.tsx @@ -30,7 +30,7 @@ function getColumnType(type: string, format: string) { } } -const Entity = ({ language, apikey = '', endpoint = '' }: ContentProps) => { +export const Entity = ({ language, apikey = '', endpoint = '' }: ContentProps) => { const { ref } = useParams() const snap = useAppStateSnapshot() const resource = snap.activeDocsSection[1] @@ -144,5 +144,3 @@ const Entity = ({ language, apikey = '', endpoint = '' }: ContentProps) => { ) } - -export default Entity diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 3852809125466..cd12e9babf80f 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -11,7 +11,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { +export const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref }) const { data } = useProjectSettingsV2Query({ projectRef: ref }) @@ -139,5 +139,3 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => ) } - -export default Introduction diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Realtime.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Realtime.tsx index e628ddca522be..9fe9c40fcf552 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Realtime.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Realtime.tsx @@ -2,7 +2,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const Realtime = ({ language }: ContentProps) => { +export const Realtime = ({ language }: ContentProps) => { return ( <> @@ -13,5 +13,3 @@ const Realtime = ({ language }: ContentProps) => { ) } - -export default Realtime diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Storage.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Storage.tsx index 18f7b851799b8..a8b754ec407b5 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Storage.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Storage.tsx @@ -2,12 +2,10 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const Storage = ({ language }: ContentProps) => { +export const Storage = ({ language }: ContentProps) => { return ( <> ) } - -export default Storage diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/StoredProcedures.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/StoredProcedures.tsx index 20ef8e3767c96..46828c2503918 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/StoredProcedures.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/StoredProcedures.tsx @@ -2,7 +2,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const StoredProcedures = ({ language }: ContentProps) => { +export const StoredProcedures = ({ language }: ContentProps) => { return ( <> { ) } - -export default StoredProcedures diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/UserManagement.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/UserManagement.tsx index e4bc2e18795c5..0c502381fca4b 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/UserManagement.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/UserManagement.tsx @@ -3,7 +3,7 @@ import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -const UserManagement = ({ language, apikey, endpoint }: ContentProps) => { +export const UserManagement = ({ language, apikey, endpoint }: ContentProps) => { const { authenticationSignInProviders } = useIsFeatureEnabled([ 'authentication:sign_in_providers', ]) @@ -87,5 +87,3 @@ const UserManagement = ({ language, apikey, endpoint }: ContentProps) => { ) } - -export default UserManagement diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/index.ts b/apps/studio/components/interfaces/ProjectAPIDocs/Content/index.ts deleted file mode 100644 index 5eff8ef2ab36a..0000000000000 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as EdgeFunctions } from './EdgeFunctions' -export { default as Entities } from './Entities' -export { default as Introduction } from './Introduction' -export { default as Realtime } from './Realtime' -export { default as Storage } from './Storage' -export { default as StoredProcedures } from './StoredProcedures' -export { default as UserManagement } from './UserManagement' - -export { default as Bucket } from './Bucket' -export { default as EdgeFunction } from './EdgeFunction' -export { default as Entity } from './Entity' -export { RPC } from './RPC' diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx index 4ec31a5b9d556..ac99c9d48a282 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx @@ -6,19 +6,17 @@ import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useAppStateSnapshot } from 'state/app-state' -import { - Bucket, - EdgeFunction, - EdgeFunctions, - Entities, - Entity, - Introduction, - RPC, - Realtime, - Storage, - StoredProcedures, - UserManagement, -} from './Content' +import { Bucket } from './Content/Bucket' +import { EdgeFunction } from './Content/EdgeFunction' +import { EdgeFunctions } from './Content/EdgeFunctions' +import { Entities } from './Content/Entities' +import { Entity } from './Content/Entity' +import { Introduction } from './Content/Introduction' +import { Realtime } from './Content/Realtime' +import { RPC } from './Content/RPC' +import { Storage } from './Content/Storage' +import { StoredProcedures } from './Content/StoredProcedures' +import { UserManagement } from './Content/UserManagement' import FirstLevelNav from './FirstLevelNav' import LanguageSelector from './LanguageSelector' import SecondLevelNav from './SecondLevelNav' @@ -34,7 +32,7 @@ import SecondLevelNav from './SecondLevelNav' * - GraphiQL needs a better home, cannot be placed under Database as its "API" */ -const ProjectAPIDocs = () => { +export const ProjectAPIDocs = () => { const { ref } = useParams() const snap = useAppStateSnapshot() const isIntroduction = @@ -154,5 +152,3 @@ const ProjectAPIDocs = () => { ) } - -export default ProjectAPIDocs diff --git a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx index 9e4f0d3b2e1a7..7b17d5d43aa02 100644 --- a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx @@ -19,14 +19,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { smartRegionToExactRegion } from './ProjectCreation.utils' interface PostgresVersionDetails { - postgresEngine: PostgresEngine | undefined - releaseChannel: ReleaseChannel | undefined + postgresEngine?: Exclude + releaseChannel?: ReleaseChannel } interface PostgresVersionSelectorProps { cloudProvider: CloudProvider dbRegion: string - organizationSlug: string | undefined + organizationSlug?: string field: ControllerRenderProps form: UseFormReturn type?: 'create' | 'unpause' diff --git a/apps/studio/components/interfaces/Reports/GridResize.tsx b/apps/studio/components/interfaces/Reports/GridResize.tsx index 4283e1658e9b4..f4634f748346e 100644 --- a/apps/studio/components/interfaces/Reports/GridResize.tsx +++ b/apps/studio/components/interfaces/Reports/GridResize.tsx @@ -13,8 +13,8 @@ import { import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' -import uuidv4 from 'lib/uuid' import { Dashboards } from 'types' import { createSqlSnippetSkeletonV2 } from '../SQLEditor/SQLEditor.utils' import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' diff --git a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx index 6adb08bc15370..5f5f30c5df97d 100644 --- a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx +++ b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx @@ -17,7 +17,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { Switch, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -const ComplianceConfig = () => { +export const ComplianceConfig = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() const [isSensitive, setIsSensitive] = useState(false) @@ -125,5 +125,3 @@ const ComplianceConfig = () => { ) } - -export default ComplianceConfig diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index d753a214cec29..11031ac0335d1 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -10,7 +10,7 @@ export interface DeleteProjectButtonProps { type?: 'danger' | 'default' } -const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { +export const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { const { data: project } = useSelectedProjectQuery() const [isOpen, setIsOpen] = useState(false) @@ -39,5 +39,3 @@ const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { ) } - -export default DeleteProjectButton diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx index da7c163192a87..2e4d2f5cdf8e7 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel.tsx @@ -2,7 +2,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui' -import DeleteProjectButton from './DeleteProjectButton' +import { DeleteProjectButton } from './DeleteProjectButton' export const DeleteProjectPanel = () => { const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index fb8b87e4d5407..a7c259a7d2a03 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -27,7 +27,7 @@ import { import PauseProjectButton from './Infrastructure/PauseProjectButton' import RestartServerButton from './Infrastructure/RestartServerButton' -const General = () => { +export const General = () => { const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() @@ -199,5 +199,3 @@ const General = () => { ) } - -export default General diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert.tsx index 3209c31a593de..b285e56658147 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert.tsx @@ -10,12 +10,12 @@ import { z } from 'zod' import { useFlag, useParams } from 'common' import { PLAN_DETAILS } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants' import { Markdown } from 'components/interfaces/Markdown' +import { extractPostgresVersionDetails } from 'components/interfaces/ProjectCreation/PostgresVersionSelector' import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' import { ProjectUpgradeTargetVersion, useProjectUpgradeEligibilityQuery, } from 'data/config/project-upgrade-eligibility-query' -import { ReleaseChannel } from 'data/projects/new-project.constants' import { useSetProjectStatus } from 'data/projects/project-detail-query' import { useProjectUpgradeMutation } from 'data/projects/project-upgrade-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -43,24 +43,10 @@ import { import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -interface PostgresVersionDetails { - postgresEngine: string - releaseChannel: ReleaseChannel -} - const formatValue = ({ postgres_version, release_channel }: ProjectUpgradeTargetVersion) => { return `${postgres_version}|${release_channel}` } -export const extractPostgresVersionDetails = (value: string): PostgresVersionDetails | null => { - if (!value) { - return null - } - - const [postgresEngine, releaseChannel] = value.split('|') - return { postgresEngine, releaseChannel } as PostgresVersionDetails -} - export const ProjectUpgradeAlert = () => { const router = useRouter() const { ref } = useParams() @@ -99,6 +85,7 @@ export const ProjectUpgradeAlert = () => { const versionDetails = extractPostgresVersionDetails(postgresVersionSelection) if (!versionDetails) return toast.error('Invalid Postgres version') + if (!versionDetails.postgresEngine) return toast.error('Missing target version') upgradeProject({ ref, diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx index e683228a12b98..abfea702cdad1 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -15,7 +15,7 @@ import { DOCS_URL } from 'lib/constants' import { Button, InfoIcon, Listbox, Loading, Modal, WarningIcon } from 'ui' import { Admonition } from 'ui-patterns' -const TransferProjectButton = () => { +export const TransferProjectButton = () => { const { data: project } = useSelectedProjectQuery() const projectRef = project?.ref const projectOrgId = project?.organization_id @@ -291,5 +291,3 @@ const TransferProjectButton = () => { ) } - -export default TransferProjectButton diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx index 268a142522c1b..77935542725df 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel.tsx @@ -3,9 +3,9 @@ import { Truck } from 'lucide-react' import { FormHeader } from 'components/ui/Forms/FormHeader' import Panel from 'components/ui/Panel' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import TransferProjectButton from './TransferProjectButton' +import { TransferProjectButton } from './TransferProjectButton' -const TransferProjectPanel = () => { +export const TransferProjectPanel = () => { const { data: project } = useSelectedProjectQuery() if (project === undefined) return null @@ -38,5 +38,3 @@ const TransferProjectPanel = () => { ) } - -export default TransferProjectPanel diff --git a/apps/studio/components/interfaces/Settings/General/index.ts b/apps/studio/components/interfaces/Settings/General/index.ts deleted file mode 100644 index daebfc2f9e473..0000000000000 --- a/apps/studio/components/interfaces/Settings/General/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as ComplianceConfig } from './ComplianceConfig/ProjectComplianceMode' -export { CustomDomainConfig } from './CustomDomainConfig/CustomDomainConfig' -export { default as DeleteProjectButton } from './DeleteProjectPanel/DeleteProjectButton' -export { default as General } from './General' -export { default as TransferProjectButton } from './TransferProjectPanel/TransferProjectButton' -export { default as TransferProjectPanel } from './TransferProjectPanel/TransferProjectPanel' diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts new file mode 100644 index 0000000000000..828b6ec314db6 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/AnalyticsBucketDetails.utils.ts @@ -0,0 +1,13 @@ +import { snakeCase } from 'lodash' + +export const getAnalyticsBucketPublicationName = (bucketId: string) => { + return `analytics_${snakeCase(bucketId)}_publication` +} + +export const getAnalyticsBucketS3KeyName = (bucketId: string) => { + return `${snakeCase(bucketId)}_keys` +} + +export const getAnalyticsBucketFDWName = (bucketId: string) => { + return `${snakeCase(bucketId)}_fdw` +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/ConnectTablesDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/ConnectTablesDialog.tsx new file mode 100644 index 0000000000000..430558b41273a --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/ConnectTablesDialog.tsx @@ -0,0 +1,275 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation' +import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation' +import { useReplicationSourcesQuery } from 'data/replication/sources-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' +import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation' +import { useTablesQuery } from 'data/tables/tables-query' +import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { snakeCase } from 'lodash' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { MultiSelector } from 'ui-patterns/multi-select' +import { convertKVStringArrayToJson } from '../../Integrations/Wrappers/Wrappers.utils' +import { getCatalogURI } from '../StorageSettings/StorageSettings.utils' +import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils' +import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' + +/** + * [Joshen] So far this is purely just setting up a "Connect from empty state" flow + * Doing it bit by bit as this is quite an unknown territory, will adjust as we figure out + * limitations, correctness, etc, etc. ETL is also only available on staging so its quite hard + * to test things locally (Local set up is technically available but quite high friction) + * + * What's missing afaict: + * - Deleting namespaces + * - Removing tables + * - Adding more tables + * - Error handling due to multiple async processes + */ + +const FormSchema = z.object({ + tables: z.array(z.string()).min(1, 'At least one table is required'), +}) + +const formId = 'connect-tables-form' +const isEnabled = false // Kill switch if we wanna hold off supporting connecting tables + +type ConnectTablesForm = z.infer + +export const ConnectTablesDialog = ({ bucket }: { bucket: AnalyticsBucket }) => { + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { tables: [] }, + }) + + const [visible, setVisible] = useState(false) + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucket.id }) + const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + + const { data: projectSettings } = useProjectSettingsV2Query({ projectRef }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true }) + const { serviceKey } = getKeys(apiKeys) + + const { data: tables } = useTablesQuery({ + projectRef, + connectionString: project?.connectionString, + includeColumns: false, + }) + + const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) + const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + + const { mutateAsync: createNamespace, isLoading: isCreatingNamespace } = + useIcebergNamespaceCreateMutation() + + const { mutateAsync: createPublication, isLoading: isCreatingPublication } = + useCreatePublicationMutation() + + const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } = + useCreateDestinationPipelineMutation({ + onSuccess: () => {}, + }) + + const { mutateAsync: startPipeline } = useStartPipelineMutation() + + const isConnecting = isCreatingNamespace || creatingDestinationPipeline || isCreatingPublication + + const onSubmit: SubmitHandler = async (values) => { + // [Joshen] Currently creates the destination for the analytics bucket here + // Which also involves creating a namespace + publication + // Publication name is automatically generated as {bucket.id}_publication + // Destination name is automatically generated as {bucket.id}_destination + + if (!projectRef) return console.error('Project ref is required') + if (!sourceId) return toast.error('Source ID is required') + + try { + const publicationName = getAnalyticsBucketPublicationName(bucket.id) + await createPublication({ + projectRef, + sourceId, + name: publicationName, + tables: values.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }), + }) + + const keysToDecrypt = Object.entries(wrapperValues) + .filter(([name]) => + ['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name) + ) + .map(([_, keyId]) => keyId) + const decryptedValues = await getDecryptedValues({ + projectRef, + connectionString: project?.connectionString, + ids: keysToDecrypt, + }) + + const warehouseName = bucket.id + const catalogToken = serviceKey?.api_key ?? '' + const s3AccessKeyId = decryptedValues[wrapperValues['vault_aws_access_key_id']] + const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']] + const s3Region = projectSettings?.region ?? '' + + const protocol = projectSettings?.app_config?.protocol ?? 'https' + const endpoint = + projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint + const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint) + const namespace = `${bucket.id}_namespace` + await createNamespace({ + catalogUri, + warehouse: warehouseName, + token: catalogToken, + namespace, + }) + + const icebergConfiguration = { + projectRef, + warehouseName, + namespace, + catalogToken, + s3AccessKeyId, + s3SecretAccessKey, + s3Region, + } + const destinationName = `${snakeCase(bucket.id)}_destination` + + const { pipeline_id: pipelineId } = await createDestinationPipeline({ + projectRef, + destinationName, + destinationConfig: { iceberg: icebergConfiguration }, + sourceId, + pipelineConfig: { publicationName }, + }) + + // Pipeline can start behind the scenes, don't need to await + startPipeline({ projectRef, pipelineId }) + toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`) + form.reset() + setVisible(false) + } catch (error: any) { + // [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly + // e.g publication gets created, but namespace creation fails -> should the old publication get deleted? + // Another question is probably whether all of these step by step logic should be at the API level instead of client level + // Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA + toast.error(`Failed to connect tables: ${error.message}`) + } + } + + const handleClose = () => { + form.reset() + setVisible(false) + } + + return ( + { + if (!open) handleClose() + }} + > + + } + onClick={() => setVisible(true)} + tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }} + > + Connect tables + + + + + + Connect tables + + + + + +
+ +

+ Select the database tables to send data from. A destination analytics table will be + created for each, and data will replicate automatically. +

+
+ + + ( + + + + + + + {tables?.map((table) => ( + + {`${table.schema}.${table.name}`} + + ))} + + + + + + )} + /> + + +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/CopyEnvButton.tsx similarity index 95% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/CopyEnvButton.tsx index a4aa58474a60f..d1261e3bc0264 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/CopyEnvButton.tsx @@ -34,7 +34,7 @@ export const CopyEnvButton = ({ ).then((values) => values.join('\n')) copyToClipboard(envFile, () => { - toast.success('Copied to clipboard as .env file') + toast.success('Copied to clipboard as environment variables') setIsLoading(false) }) }, [serverOptions, values]) diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/DecryptedReadOnlyInput.tsx similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/DecryptedReadOnlyInput.tsx diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/NamespaceRow.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/NamespaceRow.tsx similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/NamespaceRow.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/NamespaceRow.tsx diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/NamespaceWithTables.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/NamespaceWithTables.tsx new file mode 100644 index 0000000000000..2faad51fe8862 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/NamespaceWithTables.tsx @@ -0,0 +1,336 @@ +import { ChevronRight, Code, Info, MoreVertical, Plus, Replace, Trash2 } from 'lucide-react' +import Link from 'next/link' +import { useMemo, useState } from 'react' + +import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' +import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog' +import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' +import { FDW } from 'data/fdw/fdws-query' +import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + Card, + CardHeader, + CardTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' + +type NamespaceWithTablesProps = { + bucketName: string + namespace: string + sourceType: 'replication' | 'direct' + schema: string + tables: (FormattedWrapperTable & { id: number })[] + token: string + wrapperInstance: FDW + wrapperValues: Record + wrapperMeta: WrapperMeta +} + +// Component for individual table rows within a namespace +const TableRowComponent = ({ + index, + tableName, + isConnected, + isLoading, +}: { + index: number + tableName: string + isConnected: boolean + isLoading?: boolean +}) => { + const { data: project } = useSelectedProjectQuery() + + const handleQueryTable = () => { + // TODO: Implement query table functionality + console.log('Query table:', tableName) + } + + const handleDeleteTable = () => { + // TODO: Implement delete table functionality + console.log('Delete table:', tableName) + } + + return ( + + {tableName} + +
+
+ {/* Outer faded dot with pulsing background */} + + {/* Inner colored dot */} + +
+ + {isLoading && !isConnected + ? 'Connecting...' + : isConnected + ? 'Connected' + : 'Not connected'} + +
+
+ {isConnected && ( + + <> + + + + + )} + + + + + + + Table name + + Status + + + + + {allTables.length === 0 ? ( + + +

No tables yet

+

+ {sourceType === 'direct' + ? ' Publish an analytics table from your Iceberg client' + : 'Connect a table from your database'} +

+
+
+ ) : ( + allTables.map(({ name, isConnected }, index) => ( + + )) + )} +
+
+ setImportForeignSchemaShown(false)} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/constants.ts b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/constants.ts similarity index 93% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/constants.ts rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/constants.ts index d7a2711792ddf..21957b2208bb9 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/constants.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/constants.ts @@ -20,7 +20,7 @@ export const DESCRIPTIONS: Record = { vault_aws_access_key_id: 'Matches the AWS access key ID from an S3 access key.', vault_aws_secret_access_key: 'Matches the AWS secret access from an S3 access key.', vault_token: 'Corresponds to the service role key.', - warehouse: 'Matches the name of the bucket.', + warehouse: 'Matches the name of this bucket.', 's3.endpoint': '', catalog_uri: '', } diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/index.tsx similarity index 84% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/index.tsx index 13e365d7f2de7..5685cc1affe00 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/index.tsx @@ -1,11 +1,14 @@ +import { uniq } from 'lodash' +import { SquarePlus } from 'lucide-react' +import Link from 'next/link' +import { useMemo, useState } from 'react' + import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' -import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { convertKVStringArrayToJson, formatWrapperTables, - wrapperMetaComparator, } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' @@ -22,17 +25,12 @@ import { DatabaseExtension, useDatabaseExtensionsQuery, } from 'data/database-extensions/database-extensions-query' -import { useFDWsQuery } from 'data/fdw/fdws-query' -import { Bucket } from 'data/storage/buckets-query' +import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' -import { snakeCase, uniq } from 'lodash' -import { Plus } from 'lucide-react' -import Link from 'next/link' -import { useMemo, useState } from 'react' import { Button, Card, @@ -47,51 +45,36 @@ import { import { Admonition } from 'ui-patterns/admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteBucketModal } from '../DeleteBucketModal' +import { ConnectTablesDialog } from './ConnectTablesDialog' import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './constants' import { CopyEnvButton } from './CopyEnvButton' import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput' import { NamespaceRow } from './NamespaceRow' +import { NamespaceWithTables } from './NamespaceWithTables' import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' +import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' import { useIcebergWrapperExtension } from './useIcebergWrapper' -export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { +export const AnalyticBucketDetails = ({ bucket }: { bucket: AnalyticsBucket }) => { + const config = BUCKET_TYPES.analytics const [modal, setModal] = useState<'delete' | null>(null) const isStorageV2 = useIsNewStorageUIEnabled() const { data: project } = useSelectedProjectQuery() + const { state: extensionState } = useIcebergWrapperExtension() - const { data: extensionsData } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, + /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */ + const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({ + bucketId: bucket.id, }) + const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') + const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta - const { data, isLoading: isFDWsLoading } = useFDWsQuery({ + const { data: extensionsData } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - - /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */ - const wrapperInstance = useMemo(() => { - return data - ?.filter((wrapper) => - wrapperMetaComparator( - { - handlerName: WRAPPER_HANDLERS.ICEBERG, - server: { - options: [], - }, - }, - wrapper - ) - ) - .find((w) => w.name === snakeCase(`${bucket.name}_fdw`)) - }, [data, bucket.name]) - - const { state: extensionState } = useIcebergWrapperExtension() - - const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') - - const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) - const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta + const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers') const { data: token, isSuccess: isSuccessToken } = useVaultSecretDecryptedValueQuery( { @@ -99,9 +82,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { connectionString: project?.connectionString, id: wrapperValues.vault_token, }, - { - enabled: wrapperValues.vault_token !== undefined, - } + { enabled: wrapperValues.vault_token !== undefined } ) const { data: namespacesData, isLoading: isLoadingNamespaces } = useIcebergNamespacesQuery( @@ -135,11 +116,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { }) }, [wrapperTables, namespacesData]) - const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers') - - const config = BUCKET_TYPES['analytics'] - - const state = isFDWsLoading + const state = isLoading ? 'loading' : extensionState === 'installed' ? wrapperInstance @@ -150,7 +127,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { return ( <> { )} {state === 'not-installed' && ( { )} {state === 'needs-upgrade' && ( { <> {isStorageV2 ? ( - +
Tables - Analytics tables connected to this bucket. + Analytics tables stored in this bucket
- + {namespaces.length > 0 && }
- - - - - Name - Schema - Created at - - - - - -

No tables yet

-

- Create an analytics table to get started -

-
-
-
-
-
+ {isLoadingNamespaces || isLoading ? ( + + ) : namespaces.length === 0 ? ( + + ) : ( +
+ {namespaces.map(({ namespace, schema, tables }) => ( + + ))} +
+ )}
) : ( @@ -233,7 +218,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { - {isLoadingNamespaces || isFDWsLoading ? ( + {isLoadingNamespaces || isLoading ? ( ) : namespaces.length === 0 ? ( @@ -275,7 +260,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { {namespaces.map(({ namespace, schema, tables }) => ( {
- Configuration + Connection details - Connect to this bucket from an Iceberg client.{' '} + Connect an Iceberg client to this bucket.{' '} @@ -332,7 +317,7 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => { )} - {state === 'missing' && } + {state === 'missing' && }
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx new file mode 100644 index 0000000000000..e75b00d6117b1 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx @@ -0,0 +1,137 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' +import { FDW } from 'data/fdw/fdws-query' +import { + ReplicationPublication, + useReplicationPublicationsQuery, +} from 'data/replication/publications-query' +import { useReplicationSourcesQuery } from 'data/replication/sources-query' +import { useS3AccessKeyDeleteMutation } from 'data/storage/s3-access-key-delete-mutation' +import { S3AccessKey, useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { + getAnalyticsBucketPublicationName, + getAnalyticsBucketS3KeyName, +} from './AnalyticsBucketDetails.utils' +import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' + +/** + * Returns all the data that's associated to a specified analytics bucket (e.g publications, S3 keys, etc) + * Used for cleaning up analytics bucket after deletion + */ +export const useAnalyticsBucketAssociatedEntities = ( + { projectRef, bucketId }: { projectRef?: string; bucketId: string }, + options: { enabled: boolean } = { enabled: true } +) => { + // [Joshen] Opting to skip cleaning up ETL related entities within old UI + // Also to prevent an unnecessary call to /sources for existing UI + const isStorageV2 = useIsNewStorageUIEnabled() + + const { can: canReadS3Credentials } = useAsyncCheckPermissions( + PermissionAction.STORAGE_ADMIN_READ, + '*' + ) + + const { data: icebergWrapper, meta: icebergWrapperMeta } = useAnalyticsBucketWrapperInstance( + { bucketId }, + { enabled: options.enabled } + ) + + const { data: s3AccessKeys } = useStorageCredentialsQuery( + { projectRef }, + { enabled: canReadS3Credentials && options.enabled } + ) + const s3AccessKey = (s3AccessKeys?.data ?? []).find( + (x) => x.description === getAnalyticsBucketS3KeyName(bucketId) + ) + + const { data: sourcesData } = useReplicationSourcesQuery( + { projectRef }, + { enabled: isStorageV2 && options.enabled } + ) + const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + + const { data: publications = [] } = useReplicationPublicationsQuery( + { projectRef, sourceId }, + { enabled: options.enabled } + ) + const publication = publications.find( + (p) => p.name === getAnalyticsBucketPublicationName(bucketId) + ) + + return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } +} + +export const useAnalyticsBucketDeleteCleanUp = () => { + const { mutateAsync: deleteFDW, isLoading: isDeletingWrapper } = useFDWDeleteMutation({ + // Silence default error handler toast + onError: () => {}, + }) + + const { mutateAsync: deleteS3AccessKey, isLoading: isDeletingKey } = useS3AccessKeyDeleteMutation( + { + // Silence default error handler toast + onError: () => {}, + } + ) + + const isDeleting = isDeletingWrapper || isDeletingKey + + const mutateAsync = async ({ + bucketId, + projectRef, + connectionString, + icebergWrapper, + icebergWrapperMeta, + s3AccessKey, + publication, + }: { + bucketId?: string + projectRef?: string + connectionString?: string + icebergWrapper?: FDW + icebergWrapperMeta?: WrapperMeta + s3AccessKey?: S3AccessKey + publication?: ReplicationPublication + }) => { + if (!!icebergWrapper && !!icebergWrapperMeta) { + try { + await deleteFDW({ + projectRef, + connectionString, + wrapper: icebergWrapper, + wrapperMeta: icebergWrapperMeta, + }) + } catch (error: any) { + console.error(`Failed to delete iceberg wrapper for ${bucketId}:`, error.message) + } + } else { + console.warn(`Unable to find and delete iceberg wrapper for ${bucketId}`) + } + + if (!!s3AccessKey) { + try { + await deleteS3AccessKey({ projectRef, id: s3AccessKey.id }) + } catch (error: any) { + console.error(`Failed to delete S3 access key for: ${bucketId}`, error.message) + } + } else { + console.warn(`Unable to find and delete corresponding S3 access key for ${bucketId}`) + } + + if (!!publication) { + try { + // [TODO] Delete the publication + } catch (error: any) { + console.error(`Failed to delete replication publication for: ${bucketId}`, error.message) + } + } else { + console.warn(`Unable to find and delete replication publication for ${bucketId}`) + } + } + + return { mutateAsync, isLoading: isDeleting } +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx new file mode 100644 index 0000000000000..409b4b8a85d85 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx @@ -0,0 +1,44 @@ +import { snakeCase } from 'lodash' +import { useMemo } from 'react' + +import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' +import { + getWrapperMetaForWrapper, + wrapperMetaComparator, +} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { useFDWsQuery } from 'data/fdw/fdws-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' + +export const useAnalyticsBucketWrapperInstance = ( + { bucketId }: { bucketId: string }, + options?: { enabled?: boolean } +) => { + const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() + + const { data, isLoading: isLoadingFDWs } = useFDWsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + options + ) + + const icebergWrapper = useMemo(() => { + return data + ?.filter((wrapper) => + wrapperMetaComparator( + { handlerName: WRAPPER_HANDLERS.ICEBERG, server: { options: [] } }, + wrapper + ) + ) + .find((w) => w.name === snakeCase(`${bucketId}_fdw`)) + }, [data, bucketId]) + + const icebergWrapperMeta = getWrapperMetaForWrapper(icebergWrapper) + + return { + data: icebergWrapper, + meta: icebergWrapperMeta, + isLoading: isLoadingProject || isLoadingFDWs, + } +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useIcebergWrapper.tsx similarity index 100% rename from apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx rename to apps/studio/components/interfaces/Storage/AnalyticsBucketDetails/useIcebergWrapper.tsx diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx index 3071838300e7e..bd797d70cd791 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { useParams } from 'common' import { ScaffoldHeader, ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' +import { AnalyticsBucket, useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query' import { Button, Card, @@ -27,28 +27,28 @@ import { CreateSpecializedBucketModal } from './CreateSpecializedBucketModal' import { DeleteBucketModal } from './DeleteBucketModal' import { EmptyBucketState } from './EmptyBucketState' +// [Joshen] Remove typecasts bucket: any once infra changes for analytics bucket is in + export const AnalyticsBuckets = () => { const router = useRouter() const { ref } = useParams() - const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null) - const [selectedBucket, setSelectedBucket] = useState() const [filterString, setFilterString] = useState('') + const [selectedBucket, setSelectedBucket] = useState() + const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null) - const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref }) + const { data: buckets = [], isLoading: isLoadingBuckets } = useAnalyticsBucketsQuery({ + projectRef: ref, + }) - const analyticsBuckets = buckets - .filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS') - .filter((bucket) => - filterString.length === 0 - ? true - : bucket.name.toLowerCase().includes(filterString.toLowerCase()) - ) + const analyticsBuckets = buckets.filter((bucket: any) => + filterString.length === 0 ? true : bucket.id.toLowerCase().includes(filterString.toLowerCase()) + ) return ( <> {!isLoadingBuckets && - buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'ANALYTICS').length === + buckets.filter((bucket: any) => !('type' in bucket) || bucket.type === 'ANALYTICS').length === 0 ? ( ) : ( @@ -87,7 +87,7 @@ export const AnalyticsBuckets = () => { {analyticsBuckets.length === 0 && filterString.length > 0 && ( - +

No results found

@@ -96,18 +96,10 @@ export const AnalyticsBuckets = () => { )} - {analyticsBuckets.map((bucket) => ( - { - const url = `/project/${ref}/storage/analytics/buckets/${bucket.id}` - if (event.metaKey) window.open(url, '_blank') - else router.push(url) - }} - > + {analyticsBuckets.map((bucket: any) => ( + -

{bucket.name}

+

{bucket.id}

@@ -124,25 +116,18 @@ export const AnalyticsBuckets = () => { - - diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx index 1f04b7f610441..a628ce5b465dd 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx @@ -92,7 +92,7 @@ export const FilesBuckets = () => { {filesBuckets.length === 0 && filterString.length > 0 && ( - +

No results found

@@ -102,15 +102,7 @@ export const FilesBuckets = () => { )} {filesBuckets.map((bucket) => ( - { - const url = `/project/${ref}/storage/files/buckets/${bucket.id}` - if (event.metaKey) window.open(url, '_blank') - else router.push(url) - }} - > +

{bucket.name}

@@ -153,25 +145,18 @@ export const FilesBuckets = () => { - diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts index ad340ad84d234..72149e736f494 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts @@ -1,19 +1,42 @@ import { useParams } from 'common' +import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query' import { useBucketsQuery } from 'data/storage/buckets-query' import { useStorageV2Page } from '../Storage.utils' +// [Joshen] Adding isStorageV2 checks here to support the existing UI while API changes are not on prod just yet + export const useSelectedBucket = () => { - const page = useStorageV2Page() const { ref, bucketId } = useParams() + const isStorageV2 = useIsNewStorageUIEnabled() + const page = useStorageV2Page() + + const { + data: analyticsBuckets = [], + isSuccess: isSuccessAnalyticsBuckets, + isError: isErrorAnalyticsBuckets, + error: errorAnalyticsBuckets, + } = useAnalyticsBucketsQuery({ projectRef: ref }, { enabled: isStorageV2 }) + + const { + data: buckets = [], + isSuccess: isSuccessBuckets, + isError: isErrorBuckets, + error: errorBuckets, + } = useBucketsQuery({ projectRef: ref }) + + const isSuccess = isStorageV2 ? isSuccessBuckets && isSuccessAnalyticsBuckets : isSuccessBuckets + const isError = isStorageV2 ? isErrorBuckets || isErrorAnalyticsBuckets : isErrorBuckets + const error = isStorageV2 ? errorBuckets || errorAnalyticsBuckets : errorBuckets - const { data: buckets = [], isSuccess, isError, error } = useBucketsQuery({ projectRef: ref }) - const bucketsByType = + const bucket = page === 'files' - ? buckets.filter((b) => b.type === 'STANDARD') + ? buckets.find((b) => b.id === bucketId) : page === 'analytics' - ? buckets.filter((b) => b.type === 'ANALYTICS') - : buckets - const bucket = bucketsByType.find((b) => b.id === bucketId) + ? analyticsBuckets.find((b: any) => b.id === bucketId) + : // [Joshen] Remove typecasts bucket: any once infra changes for analytics bucket is in + // [Joshen] Temp fallback to buckets for backwards compatibility old UI + buckets.find((b) => b.id === bucketId) return { bucket, isSuccess, isError, error } } diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx index 2d775349fc523..a310de9eb1d76 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx @@ -34,7 +34,7 @@ export const RevokeCredentialModal = ({ }) return ( - + diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index 4f173c4a52014..ed7f36359b361 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -12,8 +12,8 @@ import { routerMock } from 'tests/lib/route-mock' import { DeleteBucketModal } from '../DeleteBucketModal' const bucket: Bucket = { - id: faker.string.uuid(), - name: `test`, + id: 'test', + name: 'test', owner: faker.string.uuid(), public: faker.datatype.boolean(), allowed_mime_types: faker.helpers.multiple(() => faker.system.mimeType(), { diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx index 8eaa7b8bad4b7..965f16e067844 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' +import { InlineLink } from 'components/ui/InlineLink' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useStorageArchiveCreateMutation } from 'data/storage/storage-archive-create-mutation' @@ -12,16 +13,13 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Database, Storage } from 'icons' import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns' export const PauseDisabledState = () => { const { ref } = useParams() @@ -116,25 +114,56 @@ export const PauseDisabledState = () => { } return ( - - - Project cannot be restored through the dashboard - - This project has been paused for over{' '} - - {pauseStatus?.max_days_till_restore_disabled ?? 90} days - {' '} - and cannot be restored through the dashboard. However, your data remains intact and can be - downloaded as a backup. - - + <> + +

+ This project has been paused for over{' '} + + {pauseStatus?.max_days_till_restore_disabled ?? 90} days + {' '} + and cannot be restored through the dashboard. However, your data remains intact and can be + downloaded as a backup. +

+ +
+

Recovery options:

+
    +
  • + + + Restore the backup to a new Supabase project + +
  • +
  • + + + Restore the backup on your local machine + +
  • +
+
+
+
+
+

Export your data

+

+ Download backups for your database and storage objects +

+
- + { */} - - - - +
+ ) } diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 41fa6ea86647b..482fb73888acd 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import dayjs from 'dayjs' -import { ExternalLink, PauseCircle } from 'lucide-react' +import { PauseCircle } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useForm } from 'react-hook-form' @@ -9,11 +9,14 @@ import { toast } from 'sonner' import { z } from 'zod' import { useFlag, useParams } from 'common' -import { PostgresVersionSelector } from 'components/interfaces/ProjectCreation/PostgresVersionSelector' +import { + extractPostgresVersionDetails, + PostgresVersionSelector, +} from 'components/interfaces/ProjectCreation/PostgresVersionSelector' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { InlineLinkClassName } from 'components/ui/InlineLink' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { PostgresEngine, ReleaseChannel } from 'data/projects/new-project.constants' import { useSetProjectStatus } from 'data/projects/project-detail-query' import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useProjectRestoreMutation } from 'data/projects/project-restore-mutation' @@ -21,35 +24,35 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { usePHFlag } from 'hooks/ui/useFlag' -import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' +import { PROJECT_STATUS } from 'lib/constants' import { AWS_REGIONS, CloudProvider } from 'shared-data' import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, Button, - FormField_Shadcn_, + Card, + CardContent, + CardFooter, + cn, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogTitle, Form_Shadcn_, - Modal, + FormField_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' +import { TimestampInfo } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { RestorePaidPlanProjectNotice } from '../RestorePaidPlanProjectNotice' import { PauseDisabledState } from './PauseDisabledState' export interface ProjectPausedStateProps { product?: string } -interface PostgresVersionDetails { - postgresEngine: Exclude - releaseChannel: ReleaseChannel -} - -export const extractPostgresVersionDetails = (value: string): PostgresVersionDetails => { - const [postgresEngine, releaseChannel] = value.split('|') - return { postgresEngine, releaseChannel } as PostgresVersionDetails -} - export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() @@ -66,7 +69,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { data: pauseStatus, error: pauseStatusError, isError, - isSuccess, + isSuccess: isPauseStatusSuccess, isLoading, } = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE }) @@ -76,7 +79,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { 0 const isFreePlan = selectedOrganization?.plan?.id === 'free' - const isRestoreDisabled = isSuccess && !pauseStatus.can_restore + const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery( { slug: orgSlug }, @@ -138,214 +141,207 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { return ( <> -
-
-
-
-
- -
+ + + +
+
+

The project "{project?.name}" is currently paused

+
+ {isLoading && } -
-
-

- The project "{project?.name ?? ''}" is currently paused. -

-

+ {isPauseStatusSuccess && !isRestoreDisabled ? ( + isFreePlan ? ( + <> +

+ All data, including backups and storage objects, remains safe. You can + resume this project from the dashboard within{' '} + + + + {finalDaysRemainingBeforeRestoreDisabled} day + {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} + {' '} + + + Free projects cannot be restored through the dashboard if they are + paused for more than {pauseStatus.max_days_till_restore_disabled} days + + {' '} + (until{' '} + + ). After that, this project will not be resumable, but data will still be + available for download. +

+

+ {enableProBenefitWording === 'variant-a' + ? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.' + : 'To prevent future pauses, consider upgrading to Pro.'} +

+ + ) : ( +

+ Your project data is safe but inaccessible while paused. Once resumed, usage + will be billed by compute size and hours active. +

+ ) + ) : !isLoading ? ( +

All of your project's data is still intact, but your project is inaccessible while paused.{' '} {product !== undefined ? ( <> - Restore this project to access the{' '} - {product} page + Resume this project to access the{' '} + {product} page. - ) : ( - 'Restore this project and get back to building!' - )} + ) : !isRestoreDisabled ? ( + 'Resume this project and get back to building!' + ) : null}

-
- - {isLoading && } - {isError && ( - - )} - {isSuccess && ( - <> - {isRestoreDisabled ? ( - - ) : isFreePlan ? ( - <> -

- {enableProBenefitWording === 'variant-a' - ? 'Upgrade to Pro plan to prevent future pauses and use Pro features like branching, compute upgrades, and daily backups.' - : 'To prevent future pauses, consider upgrading to Pro.'} -

- - - Project can be restored through the dashboard within the next{' '} - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - - - Free projects cannot be restored through the dashboard if they are - paused for more than{' '} - - {pauseStatus?.max_days_till_restore_disabled} days - - . The latest that your project can be restored is by{' '} - - {dayjs() - .utc() - .add(pauseStatus.remaining_days_till_restore_disabled ?? 0, 'day') - .format('DD MMM YYYY')} - - . However, your database backup and Storage objects will still be - available for download thereafter. - - - - - - - ) : ( - - )} - - )} + ) : null}
- - {isSuccess && !isRestoreDisabled && ( -
- - Restore project - - {isFreePlan ? ( - - ) : ( - - )} -
- )}
-
-
+ + + {isError && ( + + )} + + {isPauseStatusSuccess && !isRestoreDisabled && ( + + + Resume project + + + {isFreePlan ? ( + + ) : ( + + )} + + )} - } + + + setShowConfirmRestore(false)} - header={'Restore this project'} + onConfirm={() => form.handleSubmit(onConfirmRestore)()} + loading={isRestoring} + confirmLabel="Confirm resume" + cancelLabel="Cancel" >
{showPostgresVersionSelector && ( - -
- ( - - )} - /> -
-
+
+ ( + + )} + /> +
)} - - - -
-
+ - setShowFreeProjectLimitWarning(false)} + setShowFreeProjectLimitWarning(false)} > - -

- The following members have reached their maximum limits for the number of active free - plan projects within organizations where they are an administrator or owner: -

-
    - {(membersExceededLimit || []).map((member, idx: number) => ( -
  • - {member.username || member.primary_email} (Limit: {member.free_project_limit} free - projects) -
  • - ))} -
-

- These members will need to either delete, pause, or upgrade one or more of these - projects before you're able to unpause this project. -

-
- - - - -
+ + + + Your organization has members who have exceeded their free project limits + + + +

+ The following members have reached their maximum limits for the number of active free + plan projects within organizations where they are an administrator or owner: +

+
    + {(membersExceededLimit || []).map((member, idx: number) => ( +
  • + {member.username || member.primary_email} (Limit: {member.free_project_limit} free + projects) +
  • + ))} +
+

+ These members will need to either delete, pause, or upgrade one or more of these + projects before you're able to resume this project. +

+
+ + + +
+
) } diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 68d6e88b3597b..507121413df39 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,12 +1,13 @@ -import { AnimatePresence, motion } from 'framer-motion' import Head from 'next/head' import { useRouter } from 'next/router' import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' -import { useParams, useFlag } from 'common' + +import { useFlag, useParams } from 'common' import { CreateBranchModal } from 'components/interfaces/BranchManagement/CreateBranchModal' -import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { ProjectAPIDocs } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { Loading } from 'components/ui/Loading' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' +import { AnimatePresence, motion } from 'framer-motion' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -19,6 +20,8 @@ import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' +import { LayoutSidebar } from './LayoutSidebar' +import { LayoutSidebarProvider } from './LayoutSidebar/LayoutSidebarProvider' import { LoadingState } from './LoadingState' import { ProjectPausedState } from './PausedState/ProjectPausedState' import { PauseFailedState } from './PauseFailedState' @@ -29,8 +32,6 @@ import RestartingState from './RestartingState' import { RestoreFailedState } from './RestoreFailedState' import RestoringState from './RestoringState' import { UpgradingState } from './UpgradingState' -import { LayoutSidebar } from './LayoutSidebar' -import { LayoutSidebarProvider } from './LayoutSidebar/LayoutSidebarProvider' // [Joshen] This is temporary while we unblock users from managing their project // if their project is not responding well for any reason. Eventually needs a bit of an overhaul diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 7cade33d6da85..28dda2cedd8b6 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -19,8 +19,8 @@ import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from 'data/content/ import { useSqlSnippetsQuery } from 'data/content/sql-snippets-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' -import uuidv4 from 'lib/uuid' import { useSnippetFolders, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { TreeView } from 'ui' diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index e22c2d522138b..89e82df6d9269 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -24,8 +24,8 @@ import { Snippet } from 'data/content/sql-folders-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import useLatest from 'hooks/misc/useLatest' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' -import uuidv4 from 'lib/uuid' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { Button, diff --git a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx index 10057b9076094..81be39cbbada9 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -25,6 +25,8 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { const { pathname } = router const suffix = !!featurePreviewModal ? `?featurePreviewModal=${featurePreviewModal}` : '' + if (!ref) return + if (isStorageV2) { // From old UI to new UI if (pathname.endsWith('/storage/settings')) { @@ -58,7 +60,7 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isStorageV2]) + }, [ref, isStorageV2]) return ( > + +export const useDeletePublicationMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deletePublication(vars), + async onSuccess(data, variables, context) { + const { projectRef, sourceId } = variables + await queryClient.invalidateQueries(replicationKeys.publications(projectRef, sourceId)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete publication: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/replication/publications-query.ts b/apps/studio/data/replication/publications-query.ts index 576424cd09aee..8a10561946baf 100644 --- a/apps/studio/data/replication/publications-query.ts +++ b/apps/studio/data/replication/publications-query.ts @@ -1,11 +1,15 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query' +import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { replicationKeys } from './keys' type ReplicationPublicationsParams = { projectRef?: string; sourceId?: number } +export type ReplicationPublication = + components['schemas']['ReplicationPublicationsResponse']['publications'][number] + async function fetchReplicationPublications( { projectRef, sourceId }: ReplicationPublicationsParams, signal?: AbortSignal diff --git a/apps/studio/data/storage/analytics-bucket-create-mutation.ts b/apps/studio/data/storage/analytics-bucket-create-mutation.ts new file mode 100644 index 0000000000000..55d9a081ee3ac --- /dev/null +++ b/apps/studio/data/storage/analytics-bucket-create-mutation.ts @@ -0,0 +1,58 @@ +// @ts-nocheck +// [Joshen] To remove after infra changes for analytics bucket is in +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { components } from 'api-types' +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type AnalyticsBucketCreateVariables = CreateAnalyticsBucketBody & { + projectRef: string +} + +type CreateAnalyticsBucketBody = components['schemas']['CreateStorageAnalyticsBucketBody'] + +async function createAnalyticsBucket({ projectRef, bucketName }: AnalyticsBucketCreateVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketName) throw new Error('Bucket name is required') + + const { data, error } = await post('/platform/storage/{ref}/analytics-buckets', { + params: { path: { ref: projectRef } }, + body: { bucketName }, + }) + + if (error) handleError(error) + return data +} + +type AnalyticsBucketCreateData = Awaited> + +export const useAnalyticsBucketCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createAnalyticsBucket(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(storageKeys.analyticsBuckets(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create analytics bucket: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/analytics-bucket-delete-mutation.ts b/apps/studio/data/storage/analytics-bucket-delete-mutation.ts new file mode 100644 index 0000000000000..d71105d2b78a3 --- /dev/null +++ b/apps/studio/data/storage/analytics-bucket-delete-mutation.ts @@ -0,0 +1,61 @@ +// @ts-nocheck +// [Joshen] To remove after infra changes for analytics bucket is in +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { useIsNewStorageUIEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +type AnalyticsBucketDeleteVariables = { + projectRef: string + id: string +} + +async function deleteAnalyticsBucket({ projectRef, id }: AnalyticsBucketDeleteVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!id) throw new Error('Bucket name is requried') + + const { data, error } = await del('/platform/storage/{ref}/analytics-buckets/{id}', { + params: { path: { ref: projectRef, id } }, + } as any) + + if (error) handleError(error) + return data +} + +type AnalyticsBucketDeleteData = Awaited> + +export const useAnalyticsBucketDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + const isStorageV2 = useIsNewStorageUIEnabled() + + return useMutation({ + mutationFn: (vars) => deleteAnalyticsBucket(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + if (isStorageV2) { + await queryClient.invalidateQueries(storageKeys.analyticsBuckets(projectRef)) + } else { + await queryClient.invalidateQueries(storageKeys.buckets(projectRef)) + } + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete analytics bucket: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/analytics-buckets-query.ts b/apps/studio/data/storage/analytics-buckets-query.ts new file mode 100644 index 0000000000000..03fd2c7e65b6b --- /dev/null +++ b/apps/studio/data/storage/analytics-buckets-query.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +// [Joshen] To remove after infra changes for analytics bucket is in +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError } from 'types' +import { storageKeys } from './keys' + +export type AnalyticsBucketsVariables = { projectRef?: string } +export type AnalyticsBucket = components['schemas']['StorageAnalyticsBucketResponse'] +export type AnalyticsBuckets = components['schemas'][] + +export async function getAnalyticsBuckets( + { projectRef }: AnalyticsBucketsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/storage/{ref}/analytics-buckets', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + return data.data +} + +export type AnalyticsBucketsData = Awaited> +export type AnalyticsBucketsError = ResponseError + +export const useAnalyticsBucketsQuery = ( + { projectRef }: AnalyticsBucketsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: storageKeys.analyticsBuckets(projectRef), + queryFn: ({ signal }) => getAnalyticsBuckets({ projectRef }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && isActive, + ...options, + retry: (failureCount, error) => { + if ( + typeof error === 'object' && + error !== null && + error.message.startsWith('Tenant config') && + error.message.endsWith('not found') + ) { + return false + } + + if (failureCount < 3) { + return true + } + + return false + }, + }) +} diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts index 77f2db799110e..7586d1524530b 100644 --- a/apps/studio/data/storage/bucket-delete-mutation.ts +++ b/apps/studio/data/storage/bucket-delete-mutation.ts @@ -3,29 +3,26 @@ import { toast } from 'sonner' import { del, handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' -import { BucketType } from './buckets-query' import { storageKeys } from './keys' type BucketDeleteVariables = { projectRef: string id: string - type: BucketType } -async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) { +async function deleteBucket({ projectRef, id }: BucketDeleteVariables) { if (!projectRef) throw new Error('projectRef is required') if (!id) throw new Error('Bucket name is requried') - if (type !== 'ANALYTICS') { - const { error: emptyBucketError } = await post('/platform/storage/{ref}/buckets/{id}/empty', { - params: { path: { ref: projectRef, id } }, - }) - if (emptyBucketError) handleError(emptyBucketError) - } + const { error: emptyBucketError } = await post('/platform/storage/{ref}/buckets/{id}/empty', { + params: { path: { ref: projectRef, id } }, + }) + if (emptyBucketError) handleError(emptyBucketError) const { data, error: deleteBucketError } = await del('/platform/storage/{ref}/buckets/{id}', { - params: { path: { ref: projectRef, id }, query: { type } }, + params: { path: { ref: projectRef, id } }, } as any) + if (deleteBucketError) handleError(deleteBucketError) return data } diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts index 0779e0d9f6369..621e069d9d405 100644 --- a/apps/studio/data/storage/buckets-query.ts +++ b/apps/studio/data/storage/buckets-query.ts @@ -44,8 +44,7 @@ export const useBucketsQuery = ( if ( typeof error === 'object' && error !== null && - error.message.startsWith('Tenant config') && - error.message.endsWith('not found') + error.message.includes('Missing tenant config') ) { return false } diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index 56752fd634651..0b2c08edf9763 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -1,7 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { snakeCase } from 'lodash' import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' +import { + getAnalyticsBucketFDWName, + getAnalyticsBucketS3KeyName, +} from 'components/interfaces/Storage/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' import { getCatalogURI, getConnectionURL, @@ -40,10 +43,10 @@ export const useIcebergWrapperCreateMutation = () => { const mutateAsync = async ({ bucketName }: { bucketName: string }) => { const createS3KeyData = await createS3AccessKey({ projectRef: project?.ref, - description: `${snakeCase(bucketName)}_keys`, + description: getAnalyticsBucketS3KeyName(bucketName), }) - const wrapperName = `${snakeCase(bucketName)}_fdw` + const wrapperName = getAnalyticsBucketFDWName(bucketName) const params: FDWCreateVariables = { projectRef: project?.ref, diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index e68911d547c71..5d796b07b0dcc 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -1,5 +1,7 @@ export const storageKeys = { buckets: (projectRef: string | undefined) => ['projects', projectRef, 'buckets'] as const, + analyticsBuckets: (projectRef: string | undefined) => + ['projects', projectRef, 'analytics-buckets'] as const, archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const, icebergNamespaces: (catalog: string, warehouse: string) => ['catalog', catalog, 'warehouse', warehouse, 'namespaces'] as const, diff --git a/apps/studio/data/storage/s3-access-key-query.ts b/apps/studio/data/storage/s3-access-key-query.ts index ffdc0243653d9..61adee253acf4 100644 --- a/apps/studio/data/storage/s3-access-key-query.ts +++ b/apps/studio/data/storage/s3-access-key-query.ts @@ -1,12 +1,17 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query' +import { components } from 'api-types' import { get, handleError } from 'data/fetchers' +import { IS_PLATFORM } from 'lib/constants' import { ResponseError } from 'types' import { storageCredentialsKeys } from './s3-access-key-keys' -import { IS_PLATFORM } from 'lib/constants' type StorageCredentialsVariables = { projectRef?: string } +export type S3AccessKey = components['schemas']['GetStorageCredentialsResponse']['data'][number] & { + access_key: string +} + async function fetchStorageCredentials( { projectRef }: StorageCredentialsVariables, signal?: AbortSignal @@ -19,16 +24,7 @@ async function fetchStorageCredentials( }) if (error) handleError(error) - - // Generated types by openapi are wrong so we need to cast it. - return data as unknown as { - data: { - id: string - created_at: string - access_key: string - description: string - }[] - } + return data as { data: S3AccessKey[] } } export type StorageCredentialsData = Awaited> diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index 1c53bda36cdac..7699fdd23108b 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -1,4 +1,6 @@ +import { v4 as _uuidV4 } from 'uuid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + import { detectBrowser, detectOS, @@ -22,10 +24,23 @@ import { timeout, tryParseInt, tryParseJson, + uuidv4, } from './helpers' import { copyToClipboard } from 'ui' +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mocked-uuid'), +})) + +describe('uuidv4', () => { + it('calls uuid.v4 and returns the result', () => { + const result = uuidv4() + expect(_uuidV4).toHaveBeenCalled() + expect(result).toBe('mocked-uuid') + }) +}) + describe('tryParseJson', () => { it('should return the parsed JSON', () => { const result = tryParseJson('{"test": "test"}') diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 7c4d38c1b0b50..a0a21bd26e80b 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -1,7 +1,12 @@ -export { default as uuidv4 } from './uuid' import { UIEvent } from 'react' +import { v4 as _uuidV4 } from 'uuid' + import type { TablesData } from '../data/tables/tables-query' +export const uuidv4 = () => { + return _uuidV4() +} + export const isAtBottom = ({ currentTarget }: UIEvent): boolean => { return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight } diff --git a/apps/studio/lib/uuid.test.ts b/apps/studio/lib/uuid.test.ts deleted file mode 100644 index 6ceeb8bf49aa7..0000000000000 --- a/apps/studio/lib/uuid.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { v4 as _uuidV4 } from 'uuid' -import uuidv4 from './uuid' - -vi.mock('uuid', () => ({ - v4: vi.fn(() => 'mocked-uuid'), -})) - -describe('uuidv4', () => { - it('calls uuid.v4 and returns the result', () => { - const result = uuidv4() - expect(_uuidV4).toHaveBeenCalled() - expect(result).toBe('mocked-uuid') - }) -}) diff --git a/apps/studio/lib/uuid.ts b/apps/studio/lib/uuid.ts deleted file mode 100644 index c33432b302deb..0000000000000 --- a/apps/studio/lib/uuid.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { v4 as _uuidV4 } from 'uuid' - -const uuidv4 = () => { - return _uuidV4() -} - -export default uuidv4 diff --git a/apps/studio/pages/project/[ref]/settings/general.tsx b/apps/studio/pages/project/[ref]/settings/general.tsx index 11943bbd52f38..618016b289f99 100644 --- a/apps/studio/pages/project/[ref]/settings/general.tsx +++ b/apps/studio/pages/project/[ref]/settings/general.tsx @@ -1,11 +1,9 @@ import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' -import { - ComplianceConfig, - CustomDomainConfig, - General, - TransferProjectPanel, -} from 'components/interfaces/Settings/General' +import { ComplianceConfig } from 'components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode' +import { CustomDomainConfig } from 'components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig' import { DeleteProjectPanel } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel' +import { General } from 'components/interfaces/Settings/General/General' +import { TransferProjectPanel } from 'components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel' import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' diff --git a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx index 19e8e6768178c..7ea03e11ad711 100644 --- a/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/analytics/buckets/[bucketId].tsx @@ -1,13 +1,17 @@ -import { useParams } from 'common' +import Link from 'next/link' -import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticBucketDetails' +import { useParams } from 'common' +import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticsBucketDetails' import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError' import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket' import DefaultLayout from 'components/layouts/DefaultLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import type { NextPageWithLayout } from 'types' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' const AnalyticsBucketPage: NextPageWithLayout = () => { const { bucketId } = useParams() @@ -16,23 +20,29 @@ const AnalyticsBucketPage: NextPageWithLayout = () => { const { bucket, error, isSuccess, isError } = useSelectedBucket() // [Joshen] Checking against projectRef from storage explorer to check if the store has initialized + // We can probably replace this with a better skeleton loader that's more representative of the page layout if (!project || !projectRef) return null return ( -
+
{isError && } {isSuccess ? ( !bucket ? (
-

Bucket {bucketId} cannot be found

+ + +
- ) : bucket.type === 'ANALYTICS' ? ( - ) : ( -
-

This bucket is not an analytics bucket

-
+ ) ) : null}
diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index 241ecb1f31dc9..8965ff4b64e45 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -1,11 +1,12 @@ import { useParams } from 'common' -import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticBucketDetails' +import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticsBucketDetails' import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError' import { StorageExplorer } from 'components/interfaces/Storage/StorageExplorer/StorageExplorer' import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket' import DefaultLayout from 'components/layouts/DefaultLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import type { NextPageWithLayout } from 'types' @@ -29,7 +30,7 @@ const PageLayout: NextPageWithLayout = () => {

Bucket {bucketId} cannot be found

) : bucket.type === 'ANALYTICS' ? ( - + ) : ( ) diff --git a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx index 40903b99a29f8..8a8b741620908 100644 --- a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx @@ -10,6 +10,7 @@ import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer import DefaultLayout from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import { Bucket } from 'data/storage/buckets-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useStoragePolicyCounts } from 'hooks/storage/useStoragePolicyCounts' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' @@ -23,8 +24,8 @@ const BucketPage: NextPageWithLayout = () => { const { bucket, error, isSuccess, isError } = useSelectedBucket() const [showEditModal, setShowEditModal] = useState(false) - const { getPolicyCount } = useStoragePolicyCounts(bucket ? [bucket] : []) - const policyCount = bucket ? getPolicyCount(bucket.name) : 0 + const { getPolicyCount } = useStoragePolicyCounts(bucket ? [bucket as Bucket] : []) + const policyCount = bucket ? getPolicyCount(bucket.id) : 0 // [Joshen] Checking against projectRef from storage explorer to check if the store has initialized if (!project || !projectRef || !isSuccess) return null diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 1985673d9ae9f..89941bc713e6c 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -3086,6 +3086,7 @@ export interface components { root_key: string } PostgresConfigResponse: { + checkpoint_timeout?: number effective_cache_size?: string hot_standby_feedback?: boolean logical_decoding_work_mem?: string @@ -3725,6 +3726,7 @@ export interface components { root_key: string } UpdatePostgresConfigBody: { + checkpoint_timeout?: number effective_cache_size?: string hot_standby_feedback?: boolean logical_decoding_work_mem?: string diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index bbc76eb612cf3..3d3762b524626 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -843,11 +843,10 @@ export interface paths { cookie?: never } /** Get notifications */ - get: operations['NotificationsController_getNotificationsV2'] + get: operations['NotificationsController_getNotifications'] put?: never post?: never - /** Delete notifications */ - delete: operations['NotificationsController_deleteNotifications'] + delete?: never options?: never head?: never /** Update notifications */ @@ -5818,9 +5817,6 @@ export interface components { GetCloudMarketplaceRedirectUrlResponse: { url: string } - GetContentCountResponse: { - count: number - } GetContentCountV2Response: { favorites: number private: number @@ -6842,6 +6838,7 @@ export interface components { } | { enabled: boolean + unit: string unlimited: boolean value: number } @@ -6855,6 +6852,8 @@ export interface components { type: 'boolean' | 'numeric' | 'set' } hasAccess: boolean + /** @enum {string} */ + type: 'boolean' | 'numeric' | 'set' }[] } ListGitHubConnectionsResponse: { @@ -7017,18 +7016,7 @@ export interface components { from: string to: string } - NotificationResponseV1: { - /** @description Any JSON-serializable value */ - data: unknown - id: string - inserted_at: string - /** @description Any JSON-serializable value */ - meta: unknown - notification_name: string - notification_status: string - project_id: number - } - NotificationResponseV2: { + NotificationResponse: { /** @description Any JSON-serializable value */ data: unknown id: string @@ -7514,6 +7502,7 @@ export interface components { relation_schema: string } PostgresConfigResponse: { + checkpoint_timeout?: number effective_cache_size?: string hot_standby_feedback?: boolean logical_decoding_work_mem?: string @@ -9701,7 +9690,7 @@ export interface components { name: string role_scoped_projects: string[] } - UpdateNotificationBodyV2: { + UpdateNotificationBody: { /** Format: uuid */ id: string /** @enum {string} */ @@ -9718,9 +9707,6 @@ export interface components { lint_name?: string note?: string } - UpdateNotificationsBodyV1: { - ids: string[] - } UpdateOrganizationBody: { additional_billing_emails?: string[] /** Format: email */ @@ -9780,6 +9766,7 @@ export interface components { server_lifetime?: number } UpdatePostgresConfigBody: { + checkpoint_timeout?: number effective_cache_size?: string hot_standby_feedback?: boolean logical_decoding_work_mem?: string @@ -12598,7 +12585,7 @@ export interface operations { } } } - NotificationsController_getNotificationsV2: { + NotificationsController_getNotifications: { parameters: { query?: { limit?: number @@ -12619,7 +12606,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['NotificationResponseV2'][] + 'application/json': components['schemas']['NotificationResponse'][] } } /** @description Failed to retrieve notifications */ @@ -12631,36 +12618,6 @@ export interface operations { } } } - NotificationsController_deleteNotifications: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateNotificationsBodyV1'] - } - } - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['NotificationResponseV1'][] - } - } - /** @description Failed to delete notifications */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } NotificationsController_updateNotificationsV2: { parameters: { query?: never @@ -12670,7 +12627,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['UpdateNotificationBodyV2'] + 'application/json': components['schemas']['UpdateNotificationBody'] } } responses: { @@ -12679,7 +12636,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['NotificationResponseV2'][] + 'application/json': components['schemas']['NotificationResponse'][] } } /** @description Failed to update notifications */