ruvector/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx
rUv 814f595995 feat(studio): Add complete RuVector Studio application
Major additions:
- Complete Next.js studio application with 1600+ components
- Docker support (Dockerfile.combined, docker-compose.yml)
- GCP deployment documentation and benchmarks
- SQL benchmark scripts for performance testing
- Sentry integration for monitoring
- Comprehensive test suite and mocks

Studio features:
- Dashboard and admin interfaces
- Data visualization components
- Authentication and user management
- API integration with RuVector backend
- Static data and public assets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:04:48 +00:00

455 lines
15 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronRight } from 'lucide-react'
import { useMemo } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { formatCurrency } from 'lib/helpers'
import {
Alert_Shadcn_,
AlertTitle_Shadcn_,
Badge,
Button,
ButtonProps,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
WarningIcon,
} from 'ui'
import { DiskStorageSchemaType } from './DiskManagement.schema'
import { DiskManagementMessage } from './DiskManagement.types'
import {
calculateComputeSizePrice,
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
getAvailableComputeOptions,
mapAddOnVariantIdToComputeSize,
} from './DiskManagement.utils'
import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection'
import { BillingChangeBadge } from './ui/BillingChangeBadge'
import { DISK_AUTOSCALE_CONFIG_DEFAULTS, DiskType } from './ui/DiskManagement.constants'
import { DiskMangementCoolDownSection } from './ui/DiskManagementCoolDownSection'
const TableHeaderRow = () => (
<TableRow>
<TableHead className="w-[140px] pl-5">Disk attribute</TableHead>
<TableHead className="text-right">-</TableHead>
<TableHead className="w-[100px]">Unit</TableHead>
<TableHead className="text-right pr-5">Price Change</TableHead>
</TableRow>
)
interface TableDataRowProps {
attribute: string
defaultValue: string | number
newValue: string | number
unit: string
beforePrice: number
afterPrice: number
hidePrice?: boolean
priceTooltip?: string
upgradeIncluded?: boolean
}
const TableDataRow = ({
attribute,
defaultValue,
newValue,
unit,
beforePrice,
afterPrice,
hidePrice = false,
priceTooltip,
upgradeIncluded = false,
}: TableDataRowProps) => (
<TableRow>
<TableCell className="pl-5">
<div className="flex flex-row gap-2 items-center">
<span>{attribute}</span>
</div>
</TableCell>
<TableCell className="text-right font-mono">
{defaultValue !== newValue ? (
<Badge variant="default" className="bg-alternative bg-opacity-100">
<div className="flex items-center gap-1">
<span className="text-xs font-mono text-foreground-muted">
{defaultValue.toString()}
</span>
<ChevronRight size={12} strokeWidth={2} className="text-foreground-muted" />
<span className="text-xs font-mono text-foreground">{newValue.toString()}</span>
</div>
</Badge>
) : (
<span className="text-xs font-mono">
<span className="text-foreground-muted"></span>
{defaultValue}
</span>
)}
</TableCell>
<TableCell className="text-xs font-mono">{unit}</TableCell>
<TableCell className="text-right pr-5">
{!upgradeIncluded && hidePrice ? (
<span className="text-xs font-mono">-</span>
) : beforePrice !== afterPrice || upgradeIncluded ? (
<BillingChangeBadge
show={true}
beforePrice={beforePrice}
afterPrice={afterPrice}
tooltip={priceTooltip}
free={upgradeIncluded}
/>
) : (
<span className="text-xs font-mono" translate="no">
{formatCurrency(beforePrice)}
</span>
)}
</TableCell>
</TableRow>
)
interface DiskSizeMeterProps {
loading: boolean
form: UseFormReturn<DiskStorageSchemaType>
numReplicas: number
isDialogOpen: boolean
disabled?: boolean
setIsDialogOpen: (isOpen: boolean) => void
onSubmit: (values: DiskStorageSchemaType) => Promise<void>
buttonSize?: ButtonProps['size']
message?: DiskManagementMessage | null
}
export const DiskManagementReviewAndSubmitDialog = ({
isDialogOpen,
setIsDialogOpen,
disabled,
form,
numReplicas,
loading,
onSubmit,
message,
buttonSize = 'medium',
}: DiskSizeMeterProps) => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const isAwsK8sProject = project?.cloud_provider === 'AWS_K8S'
const { formState, getValues } = form
const { can: canUpdateDiskConfiguration } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'projects',
{
resource: {
project_id: project?.id,
},
}
)
/**
* Queries
* */
const {
data: addons,
// request deps in Form handle errors and loading
} = useProjectAddonsQuery({ projectRef: project?.ref })
const planId = org?.plan.id ?? 'free'
const isDirty = !!Object.keys(form.formState.dirtyFields).length
const replicaTooltipText = `Price change includes primary database and ${numReplicas} replica${numReplicas > 1 ? 's' : ''}`
const availableAddons = useMemo(() => {
return addons?.available_addons ?? []
}, [addons])
const availableOptions = useMemo(() => {
return getAvailableComputeOptions(availableAddons, project?.cloud_provider)
}, [availableAddons, project?.cloud_provider])
const computeSizePrice = calculateComputeSizePrice({
availableOptions,
oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro',
newComputeSize: form.getValues('computeSize'),
plan: org?.plan.id ?? 'free',
})
const diskSizePrice = calculateDiskSizePrice({
planId: planId,
oldSize: formState.defaultValues?.totalSize || 0,
oldStorageType: formState.defaultValues?.storageType as DiskType,
newSize: getValues('totalSize'),
newStorageType: getValues('storageType') as DiskType,
numReplicas,
})
const iopsPrice = calculateIOPSPrice({
oldStorageType: form.formState.defaultValues?.storageType as DiskType,
oldProvisionedIOPS: form.formState.defaultValues?.provisionedIOPS || 0,
newStorageType: form.getValues('storageType') as DiskType,
newProvisionedIOPS: form.getValues('provisionedIOPS'),
numReplicas,
})
const throughputPrice = calculateThroughputPrice({
storageType: form.getValues('storageType') as DiskType,
newThroughput: form.getValues('throughput') || 0,
oldThroughput: form.formState.defaultValues?.throughput || 0,
numReplicas,
})
const hasComputeChanges =
form.formState.defaultValues?.computeSize !== form.getValues('computeSize')
const hasTotalSizeChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
const hasStorageTypeChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.storageType !== form.getValues('storageType')
const hasThroughputChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.throughput !== form.getValues('throughput')
const hasIOPSChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS')
const hasGrowthPercentChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
const hasMinIncrementChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb')
const hasMaxSizeChanges =
!isAwsK8sProject &&
!isAwsNimbus &&
form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
const hasDiskConfigChanges =
hasIOPSChanges ||
(hasThroughputChanges && form.getValues('storageType') === 'gp3') ||
hasTotalSizeChanges
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<ButtonTooltip
size={buttonSize}
htmlType="submit"
type="primary"
onClick={async (e) => {
e.preventDefault()
const isValid = await form.trigger()
if (isValid) setIsDialogOpen(true)
}}
disabled={disabled || !isDirty}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateDiskConfiguration
? 'You need additional permissions to update disk configuration'
: disabled
? 'Current form values are invalid'
: undefined,
},
}}
>
Review changes
</ButtonTooltip>
</DialogTrigger>
<DialogContent className="min-w-[620px]">
<DialogHeader>
<DialogTitle>Review changes</DialogTitle>
<DialogDescription>Changes will be applied shortly once confirmed.</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
{(hasComputeChanges || hasDiskConfigChanges) && (
<>
<div className="flex flex-col gap-2 p-5">
<DiskMangementRestartRequiredSection
visible={hasComputeChanges}
title="Resizing your Compute will trigger a project restart"
description="Project will restart automatically on confirmation."
/>
<DiskMangementCoolDownSection visible={hasDiskConfigChanges} />
</div>
<DialogSectionSeparator />
</>
)}
<Table>
<TableHeader className="font-mono uppercase text-xs [&_th]:h-auto [&_th]:pb-2 [&_th]:pt-4">
<TableHeaderRow />
</TableHeader>
<TableBody className="[&_td]:py-0 [&_tr]:h-[50px] [&_tr]:border-dotted">
{hasComputeChanges && (
<TableDataRow
attribute="Compute size"
defaultValue={mapAddOnVariantIdToComputeSize(
form.formState.defaultValues?.computeSize ?? 'ci_nano'
)}
newValue={mapAddOnVariantIdToComputeSize(form.getValues('computeSize'))}
unit="-"
beforePrice={Number(computeSizePrice.oldPrice)}
afterPrice={Number(computeSizePrice.newPrice)}
upgradeIncluded={
org?.plan.id !== 'free' &&
project?.infra_compute_size === 'nano' &&
form.getValues('computeSize') === 'ci_micro'
}
/>
)}
{hasStorageTypeChanges && (
<TableDataRow
hidePrice
attribute="Storage Type"
defaultValue={form.formState.defaultValues?.storageType ?? ''}
newValue={form.getValues('storageType')}
unit="-"
beforePrice={0}
afterPrice={0}
/>
)}
{(hasIOPSChanges || hasStorageTypeChanges) && (
<TableDataRow
attribute="IOPS"
defaultValue={form.formState.defaultValues?.provisionedIOPS?.toLocaleString() ?? 0}
newValue={form.getValues('provisionedIOPS')?.toLocaleString()}
unit="IOPS"
beforePrice={Number(iopsPrice.oldPrice)}
afterPrice={Number(iopsPrice.newPrice)}
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
)}
{form.getValues('storageType') === 'gp3' && hasThroughputChanges && (
<TableDataRow
attribute="Throughput"
defaultValue={form.formState.defaultValues?.throughput?.toLocaleString() ?? 0}
newValue={form.getValues('throughput')?.toLocaleString() ?? 0}
unit="MB/s"
beforePrice={Number(throughputPrice.oldPrice)}
afterPrice={Number(throughputPrice.newPrice)}
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
)}
{(hasTotalSizeChanges || hasStorageTypeChanges) && (
<TableDataRow
attribute="Disk size"
defaultValue={form.formState.defaultValues?.totalSize?.toLocaleString() ?? 0}
newValue={form.getValues('totalSize')?.toLocaleString()}
unit="GB"
beforePrice={Number(diskSizePrice.oldPrice)}
afterPrice={Number(diskSizePrice.newPrice)}
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
)}
{hasGrowthPercentChanges && (
<TableDataRow
attribute="Growth percent"
defaultValue={
form.formState.defaultValues?.growthPercent ??
DISK_AUTOSCALE_CONFIG_DEFAULTS.growthPercent
}
newValue={
form.getValues('growthPercent') ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.growthPercent
}
unit="%"
beforePrice={0}
afterPrice={0}
/>
)}
{hasMinIncrementChanges && (
<TableDataRow
attribute="Min increment"
defaultValue={
form.formState.defaultValues?.minIncrementGb ??
DISK_AUTOSCALE_CONFIG_DEFAULTS.minIncrementSize
}
newValue={
form.getValues('minIncrementGb') ??
DISK_AUTOSCALE_CONFIG_DEFAULTS.minIncrementSize
}
unit="GB"
beforePrice={0}
afterPrice={0}
/>
)}
{hasMaxSizeChanges && (
<TableDataRow
attribute="Max disk size"
defaultValue={
form.formState.defaultValues?.maxSizeGb?.toLocaleString() ??
DISK_AUTOSCALE_CONFIG_DEFAULTS.maxSizeGb
}
newValue={
form.getValues('maxSizeGb')?.toLocaleString() ??
DISK_AUTOSCALE_CONFIG_DEFAULTS.maxSizeGb
}
unit="GB"
beforePrice={0}
afterPrice={0}
/>
)}
</TableBody>
</Table>
{numReplicas > 0 && (
<div className="border-t px-4 py-2 text-sm text-foreground-lighter">
{replicaTooltipText}
</div>
)}
<DialogFooter>
<Button block size="large" type="default" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
block
type={'warning'}
size="large"
htmlType="submit"
loading={loading}
onClick={async () => {
await onSubmit(form.getValues())
}}
>
Confirm changes
</Button>
</DialogFooter>
{message && (
<>
<DialogSectionSeparator />
<DialogSection>
<Alert_Shadcn_
variant={message.type === 'error' ? 'destructive' : 'default'}
className=""
>
<WarningIcon />
<AlertTitle_Shadcn_>{message.message}</AlertTitle_Shadcn_>
</Alert_Shadcn_>
</DialogSection>
</>
)}
</DialogContent>
</Dialog>
)
}