Files
supabase/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx
2023-07-18 11:50:57 +08:00

460 lines
19 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useParams, useTheme } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { setProjectStatus } from 'data/projects/projects-query'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
import { BASE_PATH, PROJECT_STATUS } from 'lib/constants'
import Telemetry from 'lib/telemetry'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
import { Alert, Button, IconExternalLink, IconInfo, Modal, Radio, SidePanel } from 'ui'
import * as Tooltip from '@radix-ui/react-tooltip'
const COMPUTE_CATEGORY_OPTIONS: {
id: 'micro' | 'optimized'
name: string
imageUrl: string
imageUrlLight: string
}[] = [
{
id: 'micro',
name: 'Micro Compute',
imageUrl: `${BASE_PATH}/img/optimized-compute-off.png`,
imageUrlLight: `${BASE_PATH}/img/optimized-compute-off--light.png`,
},
{
id: 'optimized',
name: 'Optimized Compute',
imageUrl: `${BASE_PATH}/img/optimized-compute-on.png`,
imageUrlLight: `${BASE_PATH}/img/optimized-compute-on--light.png`,
},
]
const ComputeInstanceSidePanel = () => {
const queryClient = useQueryClient()
const { ui } = useStore()
const router = useRouter()
const { ref: projectRef } = useParams()
const { isDarkMode } = useTheme()
const { project: selectedProject } = useProjectContext()
const organization = useSelectedOrganization()
const isOrgBilling = !!organization?.subscription_id
const [showConfirmationModal, setShowConfirmationModal] = useState(false)
const [selectedCategory, setSelectedCategory] = useState<'micro' | 'optimized'>('micro')
const [selectedOption, setSelectedOption] = useState<string>('ci_micro')
const canUpdateCompute = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
)
const snap = useSubscriptionPageStateSnapshot()
const visible = snap.panelKey === 'computeInstance'
const onClose = () => snap.setPanelKey(undefined)
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { mutate: updateAddon, isLoading: isUpdating } = useProjectAddonUpdateMutation({
onSuccess: () => {
ui.setNotification({
duration: 8000,
category: 'success',
message: `Successfully updated compute instance to ${selectedCompute?.name}. Your project is currently being restarted to update its instance`,
})
setProjectStatus(queryClient, projectRef!, PROJECT_STATUS.RESTORING)
onClose()
router.push(`/project/${projectRef}`)
},
onError: (error) => {
ui.setNotification({
error,
category: 'error',
message: `Unable to update compute instance: ${error.message}`,
})
},
})
const { mutate: removeAddon, isLoading: isRemoving } = useProjectAddonRemoveMutation({
onSuccess: () => {
ui.setNotification({
duration: 8000,
category: 'success',
message: `Successfully updated compute instance to Micro. Your project is currently being restarted to update its instance`,
})
setProjectStatus(queryClient, projectRef!, PROJECT_STATUS.RESTORING)
onClose()
router.push(`/project/${projectRef}`)
},
onError: (error) => {
ui.setNotification({
error,
category: 'error',
message: `Unable to update compute instance: ${error.message}`,
})
},
})
const isSubmitting = isUpdating || isRemoving
const projectId = selectedProject?.id
const cpuArchitecture = getCloudProviderArchitecture(selectedProject?.cloud_provider)
const selectedAddons = addons?.selected_addons ?? []
const availableAddons = addons?.available_addons ?? []
const isFreePlan = subscription?.plan?.id === 'free'
const subscriptionCompute = selectedAddons.find((addon) => addon.type === 'compute_instance')
const pitrAddon = selectedAddons.find((addon) => addon.type === 'pitr')
const availableOptions =
availableAddons.find((addon) => addon.type === 'compute_instance')?.variants ?? []
const selectedCompute = availableOptions.find((option) => option.identifier === selectedOption)
const hasChanges = selectedOption !== (subscriptionCompute?.variant.identifier ?? 'ci_micro')
const blockMicroDowngradeDueToPitr =
pitrAddon !== undefined && selectedOption === 'ci_micro' && hasChanges
useEffect(() => {
if (visible) {
if (subscriptionCompute !== undefined) {
setSelectedCategory('optimized')
setSelectedOption(subscriptionCompute.variant.identifier)
} else {
setSelectedCategory('micro')
setSelectedOption('ci_micro')
}
Telemetry.sendActivity(
{
activity: 'Side Panel Viewed',
source: 'Dashboard',
data: {
title: 'Change project compute size',
section: 'Add ons',
},
projectRef,
},
router
)
}
}, [visible, isLoading])
const onConfirmUpdateComputeInstance = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!projectId) return console.error('Project ID is required')
if (selectedOption === 'ci_micro' && subscriptionCompute !== undefined) {
removeAddon({ projectRef, variant: subscriptionCompute.variant.identifier })
} else {
updateAddon({ projectRef, type: 'compute_instance', variant: selectedOption })
}
}
return (
<>
<SidePanel
size="xxlarge"
visible={visible}
onCancel={onClose}
onConfirm={() => setShowConfirmationModal(true)}
loading={isLoading}
disabled={
isFreePlan ||
isLoading ||
!hasChanges ||
blockMicroDowngradeDueToPitr ||
!canUpdateCompute
}
tooltip={
isFreePlan
? 'Unable to update compute instance on a free plan'
: !canUpdateCompute
? 'You do not have permission to update compute instance'
: undefined
}
header={
<div className="flex items-center justify-between">
<h4>Change project compute size</h4>
<Link href="https://supabase.com/docs/guides/platform/compute-add-ons">
<a target="_blank" rel="noreferrer">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
About compute sizes
</Button>
</a>
</Link>
</div>
}
>
<SidePanel.Content>
<div className="py-6 space-y-4">
<p className="text-sm">
For the database, compute size refers to the amount of CPU and memory allocated to the
database instance.
</p>
<div className="!mt-8 pb-4">
<div className="flex gap-3">
{COMPUTE_CATEGORY_OPTIONS.map((option) => {
const isSelected = selectedCategory === option.id
return (
<div
key={option.id}
className={clsx('col-span-3 group space-y-1', isFreePlan && 'opacity-75')}
onClick={() => {
setSelectedCategory(option.id)
if (option.id === 'micro') setSelectedOption('ci_micro')
Telemetry.sendActivity(
{
activity: 'Option Selected',
source: 'Dashboard',
data: {
title: 'Change project compute size',
section: 'Add ons',
option: option.name,
},
projectRef,
},
router
)
}}
>
<img
alt="Compute Instance"
className={clsx(
'relative rounded-xl transition border bg-no-repeat bg-center bg-cover cursor-pointer w-[160px] h-[96px]',
isSelected
? 'border-scale-1200'
: 'border-scale-900 opacity-50 group-hover:border-scale-1000 group-hover:opacity-100'
)}
width={160}
height={96}
src={isDarkMode ? option.imageUrl : option.imageUrlLight}
/>
<p
className={clsx(
'text-sm transition',
isSelected ? 'text-scale-1200' : 'text-scale-1000'
)}
>
{option.name}
</p>
</div>
)
})}
</div>
</div>
{selectedCategory === 'optimized' && (
<div className="pb-4">
{isFreePlan && (
<Alert
withIcon
className="mb-4"
variant="info"
title="Changing your compute size is only available on the Pro plan"
actions={
isOrgBilling ? (
<Link href={`/org/${organization.slug}/billing?panel=subscriptionPlan`}>
<a>
<Button type="default">View available plans</Button>
</a>
</Link>
) : (
<Button type="default" onClick={() => snap.setPanelKey('subscriptionPlan')}>
View available plans
</Button>
)
}
>
Upgrade your plan to change the compute size of your project
</Alert>
)}
<Radio.Group
type="large-cards"
size="tiny"
id="compute-instance"
label={<p className="text-sm">Choose the compute size you want to use</p>}
onChange={(event: any) => setSelectedOption(event.target.value)}
>
{availableOptions.map((option) => (
<Radio
className="col-span-3 !p-0"
disabled={isFreePlan}
name="compute-instance"
key={option.identifier}
checked={selectedOption === option.identifier}
label={option.name}
value={option.identifier}
>
<div className="w-full group">
<div className="border-b border-scale-500 px-4 py-2">
<p className="text-sm">{option.name}</p>
</div>
<div className="px-4 py-2">
<p className="text-scale-1000">{option.meta?.memory_gb ?? 0} GB memory</p>
<p className="text-scale-1000">
{option.meta?.cpu_cores ?? 0}-core {cpuArchitecture} CPU (
{option.meta?.cpu_dedicated ? 'Dedicated' : 'Shared'})
</p>
<div className="flex justify-between items-center mt-2">
<div className="flex items-center space-x-1">
<span className="text-scale-1200 text-sm">
${option.price.toLocaleString()}
</span>
<span className="text-scale-1000 translate-y-[1px]">
{' '}
/ {option.price_interval === 'monthly' ? 'month' : 'hour'}
</span>
</div>
{option.price_interval === 'hourly' && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="flex items-center">
<IconInfo
size={14}
strokeWidth={2}
className="hover:text-scale-1000"
/>
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<div className="flex items-center space-x-1">
<p className="text-scale-1200 text-sm">
${Number(option.price * 672).toFixed(0)} - $
{Number(option.price * 744).toFixed(0)} per month
</p>
</div>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
</div>
</div>
</div>
</Radio>
))}
</Radio.Group>
</div>
)}
{selectedCategory === 'micro' && (
<p className="text-sm text-scale-1100">
Your database will use the standard Micro size instance of 2-core {cpuArchitecture} CPU (Shared)
with 1GB of memory.
</p>
)}
{hasChanges &&
(selectedCategory === 'micro' && !isOrgBilling ? (
<p className="text-sm text-scale-1100">
Upon clicking confirm, the amount of that's unused during the current billing
cycle will be returned as credits that can be used for subsequent billing cycles
</p>
) : selectedCategory !== 'micro' && selectedCompute?.price_interval === 'monthly' ? (
// Monthly payment with project-level subscription
<p className="text-sm text-scale-1100">
Upon clicking confirm, the amount of{' '}
<span className="text-scale-1200">
${selectedCompute?.price.toLocaleString()}
</span>{' '}
will be added to your monthly invoice. Any previous compute addon is prorated and
you're immediately charged for the remaining days of your billing cycle. The addon
is prepaid per month and in case of a downgrade, you get credits for the remaining
time.
</p>
) : selectedCategory !== 'micro' ? (
// Hourly usage-billing with org-level subscription
<p className="text-sm text-scale-1100">
There are no immediate charges when changing compute. Compute Hours are a
usage-based item and you're billed at the end of your billing cycle based on your
compute usage. Read more about{' '}
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
<a target="_blank" rel="noreferrer" className="underline">
Compute Billing
</a>
</Link>
.
</p>
) : (
<></>
))}
{hasChanges && !blockMicroDowngradeDueToPitr && (
<Alert
withIcon
variant="info"
title="Your project will need to be restarted when changing it's compute size"
>
It will take up to 2 minutes for changes to take place, in which your project will
be unavailable during that time.
</Alert>
)}
{blockMicroDowngradeDueToPitr && (
<Alert
withIcon
variant="info"
className="mb-4"
title="Disable PITR before downgrading to Micro Compute"
actions={
<Button type="default" onClick={() => snap.setPanelKey('pitr')}>
Change PITR
</Button>
}
>
<p>
You currently have PITR enabled. The minimum compute instance size for using PITR
is the Small Compute.
</p>
<p>You need to disable PITR before downgrading to Micro Compute.</p>
</Alert>
)}
</div>
</SidePanel.Content>
</SidePanel>
<Modal
size="medium"
alignFooter="right"
visible={showConfirmationModal}
loading={isSubmitting}
onCancel={() => setShowConfirmationModal(false)}
onConfirm={() => onConfirmUpdateComputeInstance()}
header="Confirm to upgrade compute instance now"
>
<Modal.Content>
<div className="py-6">
<Alert
withIcon
variant="warning"
title="Your project will need to be restarted when changing it's compute size"
>
It will take up to 2 minutes for changes to take place, in which your project will be
unavailable during that time.
</Alert>
</div>
</Modal.Content>
</Modal>
</>
)
}
export default ComputeInstanceSidePanel