Support downloading storage objects in project paused state

This commit is contained in:
Joshen Lim committed 2024-09-17 16:53:14 +08:00
1 parent 40322b20b6
commit 42f8b95eb2
9 files changed
+380 -156

No files matched your search

@@ -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 (
<>
<Admonition
type={'default'}
title={'The organization has members who have exceeded their free project limits'}
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
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:
</p>
<ul className="pl-5 list-disc">
{membersExceededLimit.map((member, idx: number) => (
<li key={`member-${idx}`}>
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
projects)
</li>
))}
</ul>
<p className="text-sm leading-normal">
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.
</p>
<Admonition
type={'default'}
title={'The organization has members who have exceeded their free project limits'}
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
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:
</p>
<ul className="pl-5 list-disc">
{membersExceededLimit.map((member, idx: number) => (
<li key={`member-${idx}`}>
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
projects)
</li>
))}
</ul>
<p className="text-sm leading-normal">
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.
</p>
<div>
<Button asChild type="secondary">
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
</Button>
</div>
<div>
<Button asChild type="secondary">
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
</Button>
</div>
}
></Admonition>
{/* <InformationBox
icon={<AlertCircle className="text-foreground" size="large" strokeWidth={1.5} />}
defaultVisibility={true}
hideCollapse
title="The organization has members who have exceeded their free project limits"
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
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:
</p>
<ul className="pl-5 list-disc">
{membersExceededLimit.map((member, idx: number) => (
<li key={`member-${idx}`}>
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
projects)
</li>
))}
</ul>
<p className="text-sm leading-normal">
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.
</p>
<div>
<Button asChild type="primary">
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade plan</Link>
</Button>
</div>
</div>
}
/> */}
</>
</div>
}
/>
)
}
@@ -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 (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Project cannot be restored through the dashboard</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
This project has been paused for over{' '}
<span className="text-foreground">
{pauseStatus?.max_days_till_restore_disabled ?? 90} days
</span>{' '}
and cannot be restored through the dashboard. However, your data remains intact and can be
downloaded as a backup.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="flex items-center gap-x-2 mt-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<Download />} iconRight={<ChevronDown />}>
Download backup
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!latestBackup}
onClick={() => onSelectDownloadBackup()}
tooltip={{
content: {
side: 'right',
text: 'No backups available, please reach out via support for assistance',
},
}}
>
<Database size={16} />
Download database backup
</DropdownMenuItemTooltip>
<DropdownMenuItemTooltip
disabled={!storageArchiveUrl}
className="gap-x-2"
onClick={() => onSelectDownloadStorageArchive()}
tooltip={{
content: {
side: 'right',
text: 'No storage archive available for download',
},
}}
>
<Storage size={16} />
Download storage objects
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
@@ -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 (
<>
<div className="space-y-4">
@@ -177,42 +135,7 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
{isSuccess && (
<>
{isRestoreDisabled ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Project cannot be restored through the dashboard
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
This project has been paused for over{' '}
<span className="text-foreground">
{pauseStatus?.max_days_till_restore_disabled ?? 90} days
</span>{' '}
and cannot be restored through the dashboard. However, your data
remains intact and can be downloaded as a backup.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="flex items-center gap-x-2 mt-3">
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
icon={<Download />}
loading={isDownloading}
disabled={!latestBackup}
className="pointer-events-auto"
onClick={() => onSelectDownloadBackup()}
>
Download backup
</Button>
</TooltipTrigger_Shadcn_>
{!latestBackup && (
<TooltipContent_Shadcn_ side="bottom">
No backups available, please reach out via support for
assistance
</TooltipContent_Shadcn_>
)}
</Tooltip_Shadcn_>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<PauseDisabledState />
) : isFreePlan ? (
<>
<p className="text-sm text-foreground-light text-center">
@@ -337,5 +260,3 @@ const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
</>
)
}
export default ProjectPausedState
@@ -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'
+11 -3
View File
@@ -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 = <TData = ProjectApiData>(
{ projectRef }: ProjectApiVariables,
{ enabled = true, ...options }: UseQueryOptions<ProjectApiData, ProjectApiError, TData> = {}
) =>
useQuery<ProjectApiData, ProjectApiError, TData>(
) => {
const project = useSelectedProject()
return useQuery<ProjectApiData, ProjectApiError, TData>(
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 = <TData = ProjectApiData>(
...options,
}
)
}
+1
View File
@@ -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,
}
@@ -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<ReturnType<typeof fetchStorageArchive>>
export const useStorageArchiveQuery = <TData = StorageArchiveData>(
{ projectRef }: StorageArchiveVariables,
{ enabled = true, ...options }: UseQueryOptions<StorageArchiveData, ResponseError, TData> = {}
) =>
useQuery<StorageArchiveData, ResponseError, TData>(
storageKeys.archive(projectRef),
({ signal }) => fetchStorageArchive({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
+1 -1
View File
@@ -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'
+148
View File
@@ -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.