From 42f8b95eb2913dfc6b43d5ff659c2ff21dba9141 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 17 Sep 2024 16:53:14 +0800 Subject: [PATCH] Support downloading storage objects in project paused state --- .../NewProject/FreeProjectLimitWarning.tsx | 94 ++++------- .../PausedState/PauseDisabledState.tsx | 144 +++++++++++++++++ .../{ => PausedState}/ProjectPausedState.tsx | 95 +---------- .../layouts/ProjectLayout/ProjectLayout.tsx | 2 +- apps/studio/data/config/project-api-query.ts | 14 +- apps/studio/data/storage/keys.ts | 1 + .../data/storage/storage-archive-query.ts | 36 +++++ apps/studio/pages/project/[ref]/index.tsx | 2 +- packages/api-types/types/api.d.ts | 148 ++++++++++++++++++ 9 files changed, 380 insertions(+), 156 deletions(-) create mode 100644 apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx rename apps/studio/components/layouts/ProjectLayout/{ => PausedState}/ProjectPausedState.tsx (73%) create mode 100644 apps/studio/data/storage/storage-archive-query.ts diff --git a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx index 72dbdb99a4e..b1ed5fb61f2 100644 --- a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx +++ b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx @@ -1,8 +1,8 @@ import Link from 'next/link' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' import type { MemberWithFreeProjectLimit } from 'data/organizations/free-project-limit-check-query' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' interface FreeProjectLimitWarningProps { membersExceededLimit: MemberWithFreeProjectLimit[] @@ -14,70 +14,36 @@ const FreeProjectLimitWarning = ({ orgSlug, }: FreeProjectLimitWarningProps) => { return ( - <> - -

- 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: -

- -

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

+ +

+ 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 create a free project within this organization. +

-
- -
+
+
- } - >
- {/* } - defaultVisibility={true} - hideCollapse - title="The organization has members who have exceeded their free project limits" - description={ -
-

- 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 create a free project within this organization. -

- -
- -
-
- } - /> */} - + + } + /> ) } diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx new file mode 100644 index 00000000000..7b98122dac0 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx @@ -0,0 +1,144 @@ +import { toast } from 'sonner' +import { ChevronDown, Download } from 'lucide-react' + +import { useParams } from 'common' +import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + WarningIcon, +} from 'ui' +import { useProjectContext } from '../ProjectContext' +import { PROJECT_STATUS } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' +import { useStorageArchiveQuery } from 'data/storage/storage-archive-query' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' +import { Database, Storage } from 'icons' + +export const PauseDisabledState = () => { + const { ref } = useParams() + const { project } = useProjectContext() + const enforceNinetyDayUnpauseExpiry = useFlag('enforceNinetyDayUnpauseExpiry') + + const { data: pauseStatus } = useProjectPauseStatusQuery( + { ref }, + { + enabled: project?.status === PROJECT_STATUS.INACTIVE && enforceNinetyDayUnpauseExpiry, + } + ) + const latestBackup = pauseStatus?.latest_downloadable_backup_id + + const { data: storageArchive } = useStorageArchiveQuery({ projectRef: ref }) + const storageArchiveUrl = storageArchive?.fileUrl + + const { mutate: downloadBackup } = useBackupDownloadMutation({ + onSuccess: (res) => { + const { fileUrl } = res + + // Trigger browser download by create,trigger and remove tempLink + const tempLink = document.createElement('a') + tempLink.href = fileUrl + document.body.appendChild(tempLink) + tempLink.click() + document.body.removeChild(tempLink) + }, + }) + + const onSelectDownloadBackup = () => { + if (ref === undefined) return console.error('Project ref is required') + if (!latestBackup) return toast.error('No backups available for download') + + const toastId = toast.loading('Fetching database backup') + + downloadBackup( + { + ref, + backup: { + id: latestBackup, + // [Joshen] Just FYI these params aren't required for the download backup request + // API types need to be updated + project_id: -1, + inserted_at: '', + isPhysicalBackup: false, + status: {}, + }, + }, + { + onSuccess: () => { + toast.success('Downloading database backup', { id: toastId }) + }, + } + ) + } + + const onSelectDownloadStorageArchive = () => { + if (!storageArchiveUrl) return toast.error('No storage archive available for download') + toast.success('Downloading storage objects') + // Trigger browser download by create,trigger and remove tempLink + const tempLink = document.createElement('a') + tempLink.href = storageArchiveUrl + document.body.appendChild(tempLink) + tempLink.click() + document.body.removeChild(tempLink) + } + + 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. + + + + + + + + onSelectDownloadBackup()} + tooltip={{ + content: { + side: 'right', + text: 'No backups available, please reach out via support for assistance', + }, + }} + > + + Download database backup + + onSelectDownloadStorageArchive()} + tooltip={{ + content: { + side: 'right', + text: 'No storage archive available for download', + }, + }} + > + + Download storage objects + + + + + + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx similarity index 73% rename from apps/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx rename to apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index a0f59a33192..20977c8d800 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' -import { Download, PauseCircle } from 'lucide-react' +import { PauseCircle } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' @@ -9,7 +9,6 @@ import { toast } from 'sonner' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useProjectRestoreMutation } from 'data/projects/project-restore-mutation' @@ -19,27 +18,18 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { PROJECT_STATUS } from 'lib/constants' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - Modal, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, - Tooltip_Shadcn_, - WarningIcon, -} from 'ui' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Modal } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' -import { useProjectContext } from './ProjectContext' -import { RestorePaidPlanProjectNotice } from './RestorePaidPlanProjectNotice' +import { useProjectContext } from '../ProjectContext' +import { RestorePaidPlanProjectNotice } from '../RestorePaidPlanProjectNotice' +import { PauseDisabledState } from './PauseDisabledState' export interface ProjectPausedStateProps { product?: string } -const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { +export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { const { ref } = useParams() const queryClient = useQueryClient() const { project } = useProjectContext() @@ -67,8 +57,7 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { 0 const isFreePlan = subscription?.plan?.id === 'free' - const isRestoreDisabled = enforceNinetyDayUnpauseExpiry && isSuccess && !pauseStatus.can_restore - const latestBackup = pauseStatus?.latest_downloadable_backup_id + const isRestoreDisabled = true // enforceNinetyDayUnpauseExpiry && isSuccess && !pauseStatus.can_restore const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery( { slug: orgSlug }, @@ -86,19 +75,6 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { }, }) - const { mutate: downloadBackup, isLoading: isDownloading } = useBackupDownloadMutation({ - onSuccess: (res) => { - const { fileUrl } = res - - // Trigger browser download by create,trigger and remove tempLink - const tempLink = document.createElement('a') - tempLink.href = fileUrl - document.body.appendChild(tempLink) - tempLink.click() - document.body.removeChild(tempLink) - }, - }) - const canResumeProject = useCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.initialize_or_resume' @@ -118,24 +94,6 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { restoreProject({ ref: project.ref }) } - const onSelectDownloadBackup = () => { - if (ref === undefined) return console.error('Project ref is required') - if (!latestBackup) return toast.error('No backups available for download') - - downloadBackup({ - ref, - backup: { - id: latestBackup, - // [Joshen] Just FYI these params aren't required for the download backup request - // API types need to be updated - project_id: -1, - inserted_at: '', - isPhysicalBackup: false, - status: {}, - }, - }) - } - return ( <>
@@ -177,42 +135,7 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { {isSuccess && ( <> {isRestoreDisabled ? ( - - - - 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. - - - - - - - {!latestBackup && ( - - No backups available, please reach out via support for - assistance - - )} - - - + ) : isFreePlan ? ( <>

@@ -337,5 +260,3 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { ) } - -export default ProjectPausedState diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 525ef25f29b..a908a87eade 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -25,7 +25,7 @@ import PauseFailedState from './PauseFailedState' import PausingState from './PausingState' import ProductMenuBar from './ProductMenuBar' import { ProjectContextProvider } from './ProjectContext' -import ProjectPausedState from './ProjectPausedState' +import { ProjectPausedState } from './PausedState/ProjectPausedState' import RestartingState from './RestartingState' import RestoreFailedState from './RestoreFailedState' import RestoringState from './RestoringState' diff --git a/apps/studio/data/config/project-api-query.ts b/apps/studio/data/config/project-api-query.ts index 51ab33af0a8..4aabfe86796 100644 --- a/apps/studio/data/config/project-api-query.ts +++ b/apps/studio/data/config/project-api-query.ts @@ -4,6 +4,8 @@ import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { configKeys } from './keys' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' export type ProjectApiVariables = { projectRef?: string @@ -17,6 +19,7 @@ export type ProjectApiResponse = { export async function getProjectApi({ projectRef }: ProjectApiVariables, signal?: AbortSignal) { if (!projectRef) throw new Error('projectRef is required') + console.log('SSS') const { data, error } = await get('/platform/props/project/{ref}/api', { params: { path: { ref: projectRef } }, @@ -33,12 +36,16 @@ export type ProjectApiError = ResponseError export const useProjectApiQuery = ( { projectRef }: ProjectApiVariables, { enabled = true, ...options }: UseQueryOptions = {} -) => - useQuery( +) => { + const project = useSelectedProject() + return useQuery( configKeys.api(projectRef), ({ signal }) => getProjectApi({ projectRef }, signal), { - enabled: enabled && typeof projectRef !== 'undefined', + enabled: + enabled && + typeof projectRef !== 'undefined' && + project?.status === PROJECT_STATUS.ACTIVE_HEALTHY, refetchInterval(data, query) { if (!data) { return false @@ -54,3 +61,4 @@ export const useProjectApiQuery = ( ...options, } ) +} diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 112eff0e10b..5fa399773f4 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -1,3 +1,4 @@ export const storageKeys = { buckets: (projectRef: string | undefined) => ['projects', projectRef, 'buckets'] as const, + archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const, } diff --git a/apps/studio/data/storage/storage-archive-query.ts b/apps/studio/data/storage/storage-archive-query.ts new file mode 100644 index 00000000000..3d22152af0c --- /dev/null +++ b/apps/studio/data/storage/storage-archive-query.ts @@ -0,0 +1,36 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { storageKeys } from './keys' + +type StorageArchiveVariables = { projectRef?: string } + +async function fetchStorageArchive({ projectRef }: StorageArchiveVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/storage/{ref}/archive', { + params: { path: { ref: projectRef } }, + signal, + }) + if (error) { + if ((error as ResponseError)?.message?.includes('Storage archive not found')) { + return { fileUrl: undefined } + } else { + handleError(error) + } + } + return data +} + +export type StorageArchiveData = Awaited> + +export const useStorageArchiveQuery = ( + { projectRef }: StorageArchiveVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + storageKeys.archive(projectRef), + ({ signal }) => fetchStorageArchive({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index 19a2c24459e..a2cc5b1b70e 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -8,7 +8,7 @@ import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' import { SecurityStatus } from 'components/interfaces/Home/SecurityStatus' import ServiceStatus from 'components/interfaces/Home/ServiceStatus' import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout' -import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' +import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' import ProjectUpgradeFailedBanner from 'components/ui/ProjectUpgradeFailedBanner' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 2af696d3d7a..b8fe8c03994 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -989,6 +989,12 @@ export interface paths { /** Get infrastructure status */ get: operations['StatusController_getStatus'] } + '/platform/storage/{ref}/archive': { + /** Gets project storage archive */ + get: operations['StorageArchiveController_getArchive'] + /** Creates project storage archive */ + post: operations['StorageArchiveController_createArchive'] + } '/platform/storage/{ref}/buckets': { /** Gets list of buckets */ get: operations['StorageBucketsController_getBuckets'] @@ -1794,6 +1800,12 @@ export interface paths { /** Get infrastructure status */ get: operations['StatusController_getStatus'] } + '/v0/storage/{ref}/archive': { + /** Gets project storage archive */ + get: operations['StorageArchiveController_getArchive'] + /** Creates project storage archive */ + post: operations['StorageArchiveController_createArchive'] + } '/v0/storage/{ref}/buckets': { /** Gets list of buckets */ get: operations['StorageBucketsController_getBuckets'] @@ -1919,6 +1931,14 @@ export interface paths { '/v1/projects/{ref}/api-keys': { /** Get project api keys */ get: operations['v1-get-project-api-keys'] + /** [Alpha] Creates a new API key for the project */ + post: operations['ApiKeysController_createApiKey'] + } + '/v1/projects/{ref}/api-keys/{id}': { + /** [Alpha] Deletes an API key for the project */ + delete: operations['ApiKeysController_deleteApiKey'] + /** [Alpha] Updates an API key for the project */ + patch: operations['ApiKeysController_updateApiKey'] } '/v1/projects/{ref}/branches': { /** @@ -2242,7 +2262,18 @@ export interface components { } ApiKeyResponse: { api_key: string + description?: string | null + hash?: string | null + id?: string | null + inserted_at?: string | null name: string + prefix?: string | null + secret_jwt_template?: components['schemas']['ApiKeySecretJWTTemplate'] | null + type?: unknown + updated_at?: string | null + } + ApiKeySecretJWTTemplate: { + role: string } ApiResponse: { autoApiService: components['schemas']['AutoApiService'] @@ -2622,6 +2653,12 @@ export interface components { token: string token_alias: string } + CreateApiKeyBody: { + description?: string | null + secret_jwt_template?: components['schemas']['ApiKeySecretJWTTemplate'] | null + /** @enum {string} */ + type: 'publishable' | 'secret' + } CreateAwsPartnerOrganizationBody: { name: string partner_billing: components['schemas']['AwsPartnerBillingBody'] @@ -3296,6 +3333,9 @@ export interface components { verify_jwt?: boolean version: number } + GetArchiveResponse: { + fileUrl: string + } GetContentCountResponse: { count: number } @@ -5496,6 +5536,10 @@ export interface components { addon_type: components['schemas']['ProjectAddonType'] addon_variant: components['schemas']['AddonVariantId'] } + UpdateApiKeyBody: { + description?: string | null + secret_jwt_template?: components['schemas']['ApiKeySecretJWTTemplate'] | null + } UpdateAuthConfigBody: { api_max_request_duration?: number db_max_pool_size?: number @@ -12450,6 +12494,9 @@ export interface operations { 201: { content: never } + 403: { + content: never + } /** @description Failed to pause the project */ 500: { content: never @@ -12558,6 +12605,9 @@ export interface operations { 'application/json': components['schemas']['RestoreCancellation'] } } + 403: { + content: never + } /** @description Failed to cancel project restoration */ 500: { content: never @@ -12816,6 +12866,44 @@ export interface operations { } } } + /** Gets project storage archive */ + StorageArchiveController_getArchive: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['GetArchiveResponse'] + } + } + /** @description Failed to get project storage archive */ + 500: { + content: never + } + } + } + /** Creates project storage archive */ + StorageArchiveController_createArchive: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 201: { + content: never + } + /** @description Failed to create project storage archive */ + 500: { + content: never + } + } + } /** Gets list of buckets */ StorageBucketsController_getBuckets: { parameters: { @@ -14531,11 +14619,71 @@ export interface operations { 'application/json': components['schemas']['ApiKeyResponse'][] } } + } + } + /** [Alpha] Creates a new API key for the project */ + ApiKeysController_createApiKey: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateApiKeyBody'] + } + } + responses: { + 201: { + content: { + 'application/json': components['schemas']['ApiKeyResponse'] + } + } + } + } + /** [Alpha] Deletes an API key for the project */ + ApiKeysController_deleteApiKey: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['ApiKeyResponse'] + } + } 403: { content: never } } } + /** [Alpha] Updates an API key for the project */ + ApiKeysController_updateApiKey: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateApiKeyBody'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['ApiKeyResponse'] + } + } + } + } /** * List all database branches * @description Returns all database branches of the specified project.