diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 2d49fd90c13..380a58b7213 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -276,7 +276,6 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/project/$ref/settings/general.tsx` ← `pages/project/[ref]/settings/general.tsx` - [x] A `routes/project/$ref/settings/addons.tsx` ← `pages/project/[ref]/settings/addons.tsx` - [x] A `routes/project/$ref/settings/api.tsx` ← `pages/project/[ref]/settings/api.tsx` (sets `skipSettingsLayout: true` — page is a useEffect redirect) -- [x] A `routes/project/$ref/settings/compute-and-disk.tsx` ← `pages/project/[ref]/settings/compute-and-disk.tsx` - [x] A `routes/project/$ref/settings/dashboard.tsx` ← `pages/project/[ref]/settings/dashboard.tsx` - [x] A `routes/project/$ref/settings/infrastructure.tsx` ← `pages/project/[ref]/settings/infrastructure.tsx` - [x] A `routes/project/$ref/settings/integrations.tsx` ← `pages/project/[ref]/settings/integrations.tsx` diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 95cdf11692b..00211269c77 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -522,9 +522,9 @@ export const CreateBranchModal = () => { setShowCreateBranchModal(false)} className="pointer-events-auto" - href={`/project/${ref}/settings/compute-and-disk`} + href={`/project/${ref}/settings/infrastructure`} > - Compute and Disk + Infrastructure

diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx index 871ea45615e..271d936f9ad 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx @@ -13,7 +13,7 @@ export const AdditionalMonthlySpend = ({ be able to update the compute size and increase the disk size after the new project is created in{' '} - Project Settings > Compute and Disk + Project Settings > Infrastructure

diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx new file mode 100644 index 00000000000..a76a1ef3535 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx @@ -0,0 +1,374 @@ +import { useParams } from 'common' +import dayjs from 'dayjs' +import { Activity, BarChart2, Database, ExternalLink } from 'lucide-react' +import { useMemo } from 'react' +import type { ChartConfig } from 'ui' +import { cn } from 'ui' +import { + Chart, + ChartActions, + ChartCard, + ChartContent, + ChartEmptyState, + ChartHeader, + ChartLine, + ChartLoadingState, + ChartMetric, + type ChartLineTick, +} from 'ui-patterns/Chart' + +import type { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query' +import { useInfraMonitoringAttributesQuery } from '@/data/analytics/infra-monitoring-query' +import { formatBytes } from '@/lib/helpers' + +type UsageChartDatum = { + timestamp: string + maxCpuUsage?: number + ramUsage?: number + diskIoConsumption?: number + databaseBytes?: number + walBytes?: number + systemBytes?: number + diskSizeBytes?: number + diskUsagePercent?: number +} + +const USAGE_ATTRIBUTES = [ + 'max_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'disk_fs_used_system', + 'disk_fs_used_wal', + 'pg_database_size', + 'disk_fs_size', +] as InfraMonitoringAttribute[] + +const COMPUTE_CHART_CONFIG = { + maxCpuUsage: { + label: 'CPU', + color: 'hsl(var(--brand-default))', + }, + ramUsage: { + label: 'Memory', + color: 'hsl(var(--warning-default))', + }, + diskIoConsumption: { + label: 'Disk IO', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const DISK_CHART_CONFIG = { + databaseUsagePercent: { + label: 'Database', + color: 'hsl(var(--brand-default))', + }, + walUsagePercent: { + label: 'WAL', + color: 'hsl(var(--warning-default))', + }, + systemUsagePercent: { + label: 'System', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const PERCENTAGE_Y_AXIS_PROPS = { + domain: [0, 100] as [number, number], + allowDataOverflow: true, + ticks: [0, 25, 50, 75, 100], + tickFormatter: (value: number) => `${Math.round(Number(value))}%`, + width: 64, +} + +const formatUsagePercent = (value: number | undefined) => + value === undefined ? '—' : `${value.toFixed(0)}%` + +const getUsageMetricStatus = (value: number | undefined): 'default' | 'warning' | 'negative' => { + if (value === undefined) return 'default' + if (value >= 90) return 'negative' + if (value >= 75) return 'warning' + return 'default' +} + +const getWorstUsageMetricStatus = ( + ...values: Array +): 'default' | 'warning' | 'negative' => { + const statuses = values.map(getUsageMetricStatus) + if (statuses.includes('negative')) return 'negative' + if (statuses.includes('warning')) return 'warning' + return 'default' +} + +const getUsageCardClassName = (status: 'default' | 'warning' | 'negative') => + cn( + 'h-full flex flex-col transition-colors', + status === 'warning' && 'border-warning-400 bg-warning-200/30', + status === 'negative' && 'border-destructive-400 bg-destructive-200/30' + ) + +const toNumber = (value: string | number | undefined) => { + const parsedValue = Number(value) + return Number.isFinite(parsedValue) ? parsedValue : 0 +} + +const clampPercentage = (value: number) => Math.min(Math.max(value, 0), 100) + +const getPeakChartValue = (data: UsageChartDatum[], dataKey: keyof UsageChartDatum) => { + if (data.length === 0) return undefined + + const values = data + .map((point) => point[dataKey]) + .filter((value): value is number => typeof value === 'number') + + if (values.length === 0) return undefined + + return Math.max(...values) +} + +export const ComputeAndDiskUsageCharts = ({ className }: { className?: string }) => { + const { ref: projectRef } = useParams() + + // Intentionally anchored to mount time so the query key stays stable across re-renders. + const { startDate, endDate } = useMemo(() => { + const now = dayjs() + + return { + startDate: now.subtract(7, 'day').toISOString(), + endDate: now.toISOString(), + } + }, []) + + const { + data: usageData, + isLoading, + isError, + } = useInfraMonitoringAttributesQuery({ + projectRef, + attributes: USAGE_ATTRIBUTES, + startDate, + endDate, + interval: '1d', + }) + + const { computeChartData, diskChartData } = useMemo(() => { + if (!usageData || !('series' in usageData)) { + return { + computeChartData: [], + diskChartData: [], + } + } + + const computeChartData = usageData.data.map((point) => ({ + timestamp: point.period_start, + maxCpuUsage: clampPercentage(toNumber(point.values.max_cpu_usage)), + ramUsage: clampPercentage(toNumber(point.values.ram_usage)), + diskIoConsumption: clampPercentage(toNumber(point.values.disk_io_consumption)), + })) + + const diskChartData = usageData.data.flatMap((point) => { + const databaseBytes = toNumber(point.values.pg_database_size) + const walBytes = toNumber(point.values.disk_fs_used_wal) + const systemBytes = toNumber(point.values.disk_fs_used_system) + const totalBytes = toNumber(point.values.disk_fs_size) + if (totalBytes <= 0) return [] + + return { + timestamp: point.period_start, + databaseBytes, + walBytes, + systemBytes, + diskSizeBytes: totalBytes, + databaseUsagePercent: clampPercentage((databaseBytes / totalBytes) * 100), + walUsagePercent: clampPercentage((walBytes / totalBytes) * 100), + systemUsagePercent: clampPercentage((systemBytes / totalBytes) * 100), + } + }) + + return { computeChartData, diskChartData } + }, [usageData]) + + const peakCpuUsage = getPeakChartValue(computeChartData, 'maxCpuUsage') + const peakMemoryUsage = getPeakChartValue(computeChartData, 'ramUsage') + const peakDiskIoUsage = getPeakChartValue(computeChartData, 'diskIoConsumption') + const computePeaks = [peakCpuUsage, peakMemoryUsage, peakDiskIoUsage].filter( + (value): value is number => value !== undefined + ) + const peakComputeUsage = computePeaks.length > 0 ? Math.max(...computePeaks) : undefined + const computeUsageStatus = getWorstUsageMetricStatus( + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage + ) + + const latestDiskDataPoint = diskChartData[diskChartData.length - 1] + const latestDiskUsedBytes = + (latestDiskDataPoint?.databaseBytes ?? 0) + + (latestDiskDataPoint?.walBytes ?? 0) + + (latestDiskDataPoint?.systemBytes ?? 0) + const latestDiskSizeBytes = latestDiskDataPoint?.diskSizeBytes ?? 0 + const latestDiskUsage = + latestDiskSizeBytes > 0 + ? clampPercentage((latestDiskUsedBytes / latestDiskSizeBytes) * 100) + : undefined + const diskUsageStatus = getUsageMetricStatus(latestDiskUsage) + + const databaseReportUrl = `/project/${projectRef}/observability/database` + const chartActions = [ + { + label: 'View database report', + href: databaseReportUrl, + icon: , + }, + ] + + const isComputeEmpty = computeChartData.length === 0 + const isDiskEmpty = diskChartData.length === 0 + + return ( +
+
+ + + + +
+ + + + +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No compute data" + description="Usage data may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+ +
+ + + + +
+ + + + +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No disk data" + description="Disk metrics may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 46823e64c10..9edf61d47ca 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -2,24 +2,24 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { toast } from 'sonner' -import { - Button, - cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - DialogSectionSeparator, - Form, - Separator, -} from 'ui' +import { Button, Card, CardContent, cn, Form } from 'ui' import { Admonition } from 'ui-patterns' +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' -import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge' +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' import { DiskManagementMessage } from './DiskManagement.types' import { @@ -28,12 +28,18 @@ import { } from './DiskManagement.utils' import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection' import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog' +import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks' import { AutoScaleFields } from './fields/AutoScaleFields' -import { ComputeSizeField } from './fields/ComputeSizeField' +import { + ComputeSectionBillingBadge, + ComputeSizeField, + ComputeSizeFieldMeta, +} from './fields/ComputeSizeField' import { DiskSizeField } from './fields/DiskSizeField' import { IOPSField } from './fields/IOPSField' import { StorageTypeField } from './fields/StorageTypeField' import { ThroughputField } from './fields/ThroughputField' +import { BillingChangeBadge } from './ui/BillingChangeBadge' import { DiskCountdownRadial } from './ui/DiskCountdownRadial' import { DISK_LIMITS, @@ -41,12 +47,9 @@ import { PLAN_DETAILS, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, } from './ui/DiskManagement.constants' +import { NoticeBar } from './ui/NoticeBar' import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' -import { - MAX_WIDTH_CLASSES, - PADDING_CLASSES, - ScaffoldContainer, -} from '@/components/layouts/Scaffold' +import { PADDING_CLASSES } from '@/components/layouts/Scaffold' import { DocsButton } from '@/components/ui/DocsButton' import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' @@ -75,13 +78,14 @@ import { } from '@/hooks/misc/useSelectedProject' import { DOCS_URL, GB, PROJECT_STATUS } from '@/lib/constants' -export function DiskManagementForm() { +export function DiskManagementForm({ chartsClassName }: { chartsClassName?: string } = {}) { const { ref: projectRef } = useParams() const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { setProjectStatus } = useSetProjectStatus() - const advancedSettingsRef = useRef(null) + const autoscaleSettingsRef = useRef(null) + const storageSettingsRef = useRef(null) const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -110,7 +114,6 @@ export function DiskManagementForm() { const [isDialogOpen, setIsDialogOpen] = useState(false) const [refetchInterval, setRefetchInterval] = useState(false) const [message, setMessageState] = useState(null) - const [advancedSettingsOpen, setAdvancedSettingsOpenState] = useState(false) const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef }) const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery( @@ -183,6 +186,18 @@ export function DiskManagementForm() { const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) const isPlanUpgradeRequired = !hasAccess + const { + computeSizePrice, + diskSizePrice, + totalBeforePrice, + totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, + } = useDiskManagementReviewChanges(form, readReplicas.length) + const { formState } = form const errors = formState.errors const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 @@ -345,7 +360,7 @@ export function DiskManagementForm() { form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) } } - }, [modifiedComputeSize, isDialogOpen, project]) + }, [modifiedComputeSize, form, isDialogOpen, project]) useEffect(() => { // Initialize field values properly when data has been loaded, preserving any user changes @@ -357,218 +372,247 @@ export function DiskManagementForm() { useEffect(() => { const fieldErrors = Object.keys(errors) - if (fieldErrors.length > 0) { - if ( - fieldErrors.includes('throughput') || - fieldErrors.includes('provisionedIOPS') || - fieldErrors.includes('maxSizeGb') - ) { - setAdvancedSettingsOpenState(true) + if (fieldErrors.length === 0) return - // [Joshen] The timeout is to let the collapsible open prior to scrolling - const timeoutId = setTimeout(() => { - advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 100) + const scrollTarget = + fieldErrors.includes('maxSizeGb') || + fieldErrors.includes('growthPercent') || + fieldErrors.includes('minIncrementGb') + ? autoscaleSettingsRef + : fieldErrors.includes('throughput') || fieldErrors.includes('provisionedIOPS') + ? storageSettingsRef + : null - return () => clearTimeout(timeoutId) - } - } + if (!scrollTarget) return + + const timeoutId = setTimeout(() => { + scrollTarget.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + + return () => clearTimeout(timeoutId) }, [errors]) return ( - <> - - {isEntitlementsLoaded && isPlanUpgradeRequired && ( - - )} +
+ + + - {(isProjectResizing || - isProjectRequestingDiskChanges || - (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( -
- - {isProjectRequestingDiskChanges && ( - + + - )} - {isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions && ( - - )} -
- )} - - - - - - - - - - {isDiskNoticeVisible && } - - - -
- {isDiskNoticeVisible && ( - - )} - {isAws && ( - <> -
- - {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( - - - - )} - {isReadOnlyMode && ( - - - - )} -
- - - - )}
+ )} - {isAws && ( - <> - - - setAdvancedSettingsOpenState((prev) => !prev)} - > - -
- Advanced disk settings - - Specify additional settings for your disk, including autoscaling - configuration, IOPS, throughput, and disk type. - -
- + + + Scaling + + + + + + + + Compute size + + + + + + + -
- -
-
- -
- -
- {!!disableIopsThroughputConfig && ( + + + + + + + + + + + + Disk + + Configure provisioned storage for your primary database. + + + + + + + + + + + + + {isAws && ( + <> +
+ + {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( { - form.setValue('computeSize', 'ci_large') - }} - > - Change to LARGE Compute - - ) : ( - - ) - } - /> - )} - {isDiskTooSmallForCustomIops && - !disableIopsThroughputConfig && - !disableDiskInputs && ( - { - form.setValue('totalSize', suggestedDiskSizeForCustomIops, { - shouldDirty: true, - shouldValidate: true, - }) - }} - > - Increase to {suggestedDiskSizeForCustomIops} GB - - ) : undefined - } + type="destructive" + title="Database size is currently over 90% of disk size" + description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations" + > + - )} + + )} + {isReadOnlyMode && ( + + + + )} +
+ + + + + + + + )} +
+
+ + {isAws && ( + + + + + Advanced + + + Configure autoscaling, storage type, IOPS, and throughput. + + + + + + + + + + + + + + + + { + form.setValue('computeSize', 'ci_large') + }} + > + Change to LARGE Compute + + ) : ( + + ) + } + /> + { + form.setValue('totalSize', suggestedDiskSizeForCustomIops, { + shouldDirty: true, + shouldValidate: true, + }) + }} + > + Increase to {suggestedDiskSizeForCustomIops} GB + + ) : undefined + } + /> -
-
-
-
- - )} -
+ + + + + )} + + +
- - {isDirty ? ( - + {isDirty ? ( + +
-
+ - -
- - ) : null} - - - - + Cancel + + +
+
+ ) : null} +
+ + ) } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index f41ef295700..b0e851d4f1e 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -30,11 +30,11 @@ export function DiskManagementPanelForm() { type="default" layout="responsive" title="Disk Management has moved" - description="Disk configuration is now managed alongside Project Compute on the new Compute and Disk page." + description="Disk configuration is now managed alongside Project Compute on the Infrastructure page." actions={ } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts index 954a1ac0a5d..f675cf91feb 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts @@ -22,6 +22,8 @@ export function useDiskManagementReviewChanges( form: UseFormReturn, numReplicas: number ) { + form.watch(['computeSize', 'totalSize', 'storageType', 'provisionedIOPS', 'throughput']) + const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const isAwsNimbus = useIsAwsNimbusCloudProvider() @@ -78,6 +80,24 @@ export function useDiskManagementReviewChanges( Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + const advancedBeforePrice = Number(iopsPrice.oldPrice) + Number(throughputPrice.oldPrice) + const advancedAfterPrice = Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + + const { isDirty, dirtyFields, errors } = form.formState + + const showComputeBillingBadge = isDirty && !!dirtyFields.computeSize && !errors.computeSize + + const showDiskBillingBadge = + isDirty && + !errors.totalSize && + Number(diskSizePrice.oldPrice) !== Number(diskSizePrice.newPrice) + + const showAdvancedBillingBadge = + isDirty && + advancedBeforePrice !== advancedAfterPrice && + !errors.provisionedIOPS && + !errors.throughput + // --- Change flags --- const hasComputeChanges = @@ -166,6 +186,11 @@ export function useDiskManagementReviewChanges( throughputPrice, totalBeforePrice, totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, // change flags hasComputeChanges, hasTotalSizeChanges, diff --git a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx index db80564155a..374d024ae5e 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx @@ -55,7 +55,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => { render={({ field }) => { return ( { render={({ field }) => { return ( { render={({ field }) => { return ( Hardware resources allocated to your Postgres database

+} + +type ComputeSectionBillingBadgeProps = { + form: UseFormReturn + show: boolean + beforePrice: number + afterPrice: number +} + +export function ComputeSectionBillingBadge({ + form, + show, + beforePrice, + afterPrice, +}: ComputeSectionBillingBadgeProps) { + const { data: project } = useSelectedProjectQuery() + const { hasAccess: entitledUpdateCompute } = useCheckEntitlements( + 'instances.compute_update_available_sizes' + ) + + const computeSize = form.watch('computeSize') + const projectComputeSize = project?.infra_compute_size ?? 'nano' + const showUpgradeBadge = entitledUpdateCompute && projectComputeSize === 'nano' + + return ( + + ) +} + export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() @@ -57,8 +88,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price') - const { computeSize } = form.watch() - const { data: addons, isPending: isAddonsLoading, @@ -67,7 +96,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading - const { control, formState, setValue, trigger } = form + const { control, setValue, trigger } = form const availableAddons = useMemo(() => { return addons?.available_addons ?? [] @@ -81,44 +110,11 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { return getAvailableComputeOptions(availableAddons, project?.cloud_provider) }, [availableAddons, project?.cloud_provider]) - // Expand by default if the project's current compute size is beyond the initial visible set - const [showAllSizes, setShowAllSizes] = useState(() => { - const idx = availableOptions.findIndex((o) => o.identifier === computeSize) - return idx >= INITIALLY_VISIBLE_COUNT - }) - - // Expand whenever the selected size falls outside the visible set — covers both initial data - // load (availableOptions starts empty) and computeSize changes after mount (e.g. form reset) - useEffect(() => { - const idx = availableOptions.findIndex((o) => o.identifier === computeSize) - if (idx >= INITIALLY_VISIBLE_COUNT) { - setShowAllSizes(true) - } - }, [computeSize, availableOptions]) - const subscriptionPitr = addons?.selected_addons.find((addon) => addon.type === 'pitr') - const computeSizePrice = calculateComputeSizePrice({ - availableOptions: availableOptions, - oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro', - newComputeSize: form.getValues('computeSize'), - plan: org?.plan.id ?? 'free', - }) - const projectComputeSize = project?.infra_compute_size ?? 'nano' const showUpgradeBadge = entitledUpdateCompute && projectComputeSize === 'nano' - const selectedOptionIndex = availableOptions.findIndex((o) => o.identifier === computeSize) - const selectedOptionIsHidden = selectedOptionIndex >= INITIALLY_VISIBLE_COUNT - - // Always show all options if the selected one would be outside the visible slice, - // so the active card is never hidden from the user. - const visibleOptions = - showAllSizes || selectedOptionIsHidden - ? availableOptions - : availableOptions.slice(0, INITIALLY_VISIBLE_COUNT) - const hasHiddenOptions = availableOptions.length > INITIALLY_VISIBLE_COUNT - return ( - - -

- Hardware resources allocated to your Postgres database -

- -
- -
- - {showUpgradeBadge && form.watch('computeSize') === 'ci_nano' && ( - - )} - +
-
- {isLoading ? ( - Array(INITIALLY_VISIBLE_COUNT) - .fill(0) - .map((_, i) => ) - ) : addonsError ? ( - -

{addonsError?.message}

-
- ) : ( - <> - {visibleOptions.map((compute) => { - const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider) + {isLoading ? ( + Array(INITIALLY_VISIBLE_COUNT) + .fill(0) + .map((_, i) => ) + ) : addonsError ? ( + +

{addonsError?.message}

+
+ ) : ( + <> + {availableOptions.map((compute) => { + const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider) - const lockedMicroDueToPITR = - compute.identifier === 'ci_micro' && !!subscriptionPitr - const lockedNanoDueToPlan = - org?.plan.id !== 'free' && - project?.infra_compute_size !== 'nano' && - compute.identifier === 'ci_nano' + const lockedMicroDueToPITR = + compute.identifier === 'ci_micro' && !!subscriptionPitr + const lockedNanoDueToPlan = + org?.plan.id !== 'free' && + project?.infra_compute_size !== 'nano' && + compute.identifier === 'ci_nano' - const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR + const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR - const price = - org?.plan.id !== 'free' && - project?.infra_compute_size === 'nano' && - compute.identifier === 'ci_nano' - ? availableOptions.find( - (option: ComputeAddonVariant) => option.identifier === 'ci_micro' - )?.price - : compute.price + const price = + org?.plan.id !== 'free' && + project?.infra_compute_size === 'nano' && + compute.identifier === 'ci_nano' + ? availableOptions.find( + (option: ComputeAddonVariant) => option.identifier === 'ci_micro' + )?.price + : compute.price - const cpuLabel = (() => { - const cpuCores = compute.meta?.cpu_cores - if (typeof cpuCores === 'number') { - return `${cpuCores}-core ${cpuArchitecture} CPU` - } - if (cpuCores) { - return `${cpuCores} CPU` - } - return 'CPU' - })() + const cpuLabel = (() => { + const cpuCores = compute.meta?.cpu_cores + if (typeof cpuCores === 'number') { + return `${cpuCores}-core ${cpuArchitecture} CPU` + } + if (cpuCores) { + return `${cpuCores} CPU` + } + return 'CPU' + })() - return ( - - -
- {showUpgradeBadge && compute.identifier === 'ci_micro' && ( -
- No additional charge -
- )} -
-
- -
- {lockedOption ? ( -
- -
- ) : ( - showComputePrice && ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - - ) - )} -
-
- -
-
-
- - - {compute.identifier === 'ci_nano' && 'Up to '} - {compute.meta?.memory_gb ?? 0} GB memory - -
-
- - {cpuLabel} -
-
-
-
-
-
- {lockedMicroDueToPITR && ( - - Project has PITR enabled which requires a minimum of Small compute. - Please{' '} - - disable PITR - {' '} - first before selecting Micro - - )} - - } - /> - ) - })} - - {showAllSizes && ( + return ( e.preventDefault()} + id={compute.identifier} + key={compute.identifier} + value={compute.identifier} className={cn( - 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]' + 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]', + lockedOption && 'opacity-50' )} + disabled={disabled || lockedOption} label={ - -
-
- + + +
+ {showUpgradeBadge && compute.identifier === 'ci_micro' && ( + + +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + Free Upgrade +
+
+ e.stopPropagation()} + > +

+ Upgrade to Micro Compute +

+

+ This Project is already paying for Micro Compute. You can + upgrade to Micro Compute at any time when convenient. +

+
+
+ )} +
+
+ +
+ {lockedOption ? ( +
+ +
+ ) : ( + showComputePrice && ( + <> + + ${price} + + + {' '} + /{' '} + {compute.price_interval === 'monthly' + ? 'month' + : 'hour'} + + + ) + )} +
+
-
- Contact Us -
-
-
-
-
- - Custom memory -
-
- - Custom CPU +
+
+
+ + + {compute.identifier === 'ci_nano' && 'Up to '} + {compute.meta?.memory_gb ?? 0} GB memory + +
+
+ + {cpuLabel} +
+
-
- + + {lockedMicroDueToPITR && ( + + Project has PITR enabled which requires a minimum of Small compute. + Please{' '} + + disable PITR + {' '} + first before selecting Micro + + )} + } /> - )} - - )} -
+ ) + })} - {!isLoading && !addonsError && hasHiddenOptions && ( - + e.preventDefault()} + className={cn( + 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]' + )} + label={ + +
+
+ + +
+ Contact Us +
+
+
+
+
+ + Custom memory +
+
+ + Custom CPU +
+
+
+
+
+ } + /> + )} - +
)} /> diff --git a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx index df65d74b239..7a198cebb0f 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx @@ -15,31 +15,22 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' -import { calculateDiskSizePrice } from '../DiskManagement.utils' -import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants' import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas' -import { DiskSpaceBar } from '../ui/DiskSpaceBar' import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection' import FormMessage from '../ui/FormMessage' -import { DocsButton } from '@/components/ui/DocsButton' import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL, GB } from '@/lib/constants' +import { GB } from '@/lib/constants' type DiskSizeFieldProps = { form: UseFormReturn disableInput: boolean - setAdvancedSettingsOpenState: (state: boolean) => void } -export function DiskSizeField({ - form, - disableInput, - setAdvancedSettingsOpenState, -}: DiskSizeFieldProps) { +export function DiskSizeField({ form, disableInput }: DiskSizeFieldProps) { const { ref: projectRef } = useParams() const { control, formState, setValue, trigger, getValues, resetField, watch } = form const { data: org } = useSelectedOrganizationQuery() @@ -79,125 +70,107 @@ export function DiskSizeField({ PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} const includedDiskGB = includedDiskGBMeta[watchedStorageType] - const { defaultValues, dirtyFields, isDirty, errors } = formState - const diskSizePrice = calculateDiskSizePrice({ - planId, - oldSize: defaultValues?.totalSize || 0, - oldStorageType: defaultValues?.storageType as DiskType, - newSize: getValues('totalSize'), - newStorageType: getValues('storageType') as DiskType, - }) + const { defaultValues, dirtyFields } = formState const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 return ( -
-
- ( - - - - e.currentTarget.blur()} - onChange={(e) => { - setValue('totalSize', e.target.valueAsNumber, { - shouldDirty: true, - shouldValidate: true, - }) + ( + + {includedDiskGB > 0 && org?.plan.id && ( +

+ Your plan includes up to {includedDiskGB} GB of {watchedStorageType} storage. +

+ )} + + { + setValue('storageType', 'io2') trigger('provisionedIOPS') - trigger('throughput') + trigger('totalSize') }} - min={includedDiskGB} - /> - - GB - {isDirty ? ( - { - resetField('totalSize') - trigger('provisionedIOPS') - }} - title="Reset" - > - - ) : null} - -
-
-
- )} - /> -
- - - {includedDiskGB > 0 && - org?.plan.id && - `Your plan includes up to ${includedDiskGB} GB of ${watchedStorageType} storage.`} + > + Change to High Performance SSD + + } + /> -
- + {isProjectNew ? ( + + ) : ( + error && ( + + {error?.message} + + ) + )} + +
-
- { - setValue('storageType', 'io2') + } + > + + + e.currentTarget.blur()} + onChange={(e) => { + setValue('totalSize', e.target.valueAsNumber, { + shouldDirty: true, + shouldValidate: true, + }) trigger('provisionedIOPS') - trigger('totalSize') - setAdvancedSettingsOpenState(true) + trigger('throughput') }} - > - Change to High Performance SSD - - } - /> -
-
-
- - - {isProjectNew ? ( - - ) : ( - error && ( - - {error?.message} - - ) - )} - - -
-
+ min={includedDiskGB} + /> + + GB + {isDirty ? ( + { + resetField('totalSize') + trigger('provisionedIOPS') + }} + title="Reset" + > + + ) : null} + + + + + )} + /> ) } diff --git a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx index 45453392660..71f0a40bed5 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx @@ -13,10 +13,8 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' import { calculateComputeSizeRequiredForIops, - calculateIOPSPrice, mapAddOnVariantIdToComputeSize, } from '../DiskManagement.utils' -import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { ComputeSizeRecommendationSection } from '../ui/ComputeSizeRecommendationSection' import { DiskType, RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3 } from '../ui/DiskManagement.constants' import { DiskManagementIOPSReadReplicas } from '../ui/DiskManagementReadReplicas' @@ -37,13 +35,6 @@ export function IOPSField({ form, disableInput }: IOPSFieldProps) { const { isError } = useDiskAttributesQuery({ projectRef }) - const iopsPrice = calculateIOPSPrice({ - oldStorageType: formState.defaultValues?.storageType as DiskType, - oldProvisionedIOPS: formState.defaultValues?.provisionedIOPS || 0, - newStorageType: getValues('storageType') as DiskType, - newProvisionedIOPS: getValues('provisionedIOPS'), - }) - const disableIopsInput = RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3.includes(watchedComputeSize) && watchedStorageType === 'gp3' @@ -55,7 +46,7 @@ export function IOPSField({ form, disableInput }: IOPSFieldProps) { const reccomendedComputeSize = calculateComputeSizeRequiredForIops(watchedIOPS) return ( } labelOptional={ - <> - -

Input/output operations per second.

- +

Input/output operations per second.

} > diff --git a/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx index dbd30360c2b..0c235d4f313 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx @@ -45,7 +45,7 @@ export function StorageTypeField({ form, disableInput }: StorageTypeFieldProps) name="storageType" control={control} render={({ field }) => ( - +