Files
supabase/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx
kemal.earth cfd6af4be0 fix(studio): autoscaling marker positioning (#47958)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Small UI fix to stop things bundling into each other on smaller
viewports. Autoscaling marker moves from bottom to top to preserve
structural integrity.

| Before | After |
|--------|--------|
| <img width="698" height="246" alt="Screenshot 2026-07-15 at 11 28 07"
src="https://github.com/user-attachments/assets/9bbb1bb1-a361-4658-9757-30e012098d0c"
/> | <img width="687" height="252" alt="Screenshot 2026-07-15 at 11 28
15"
src="https://github.com/user-attachments/assets/ca6b6c0a-b511-4136-835f-4321a30765b8"
/> |



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Fixed the Autoscaling tooltip placement on the disk space bar so it
consistently appears in the correct spot.
* Updated the tooltip trigger to use an info icon with a static
positioned tooltip, removing the previous animated trigger behavior
while preserving existing tooltip content and visibility rules.
* Improved tooltip/marker positioning accuracy by recalculating the
horizontal placement from the independently-rounded usage segments to
prevent drift.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 14:42:51 +01:00

283 lines
11 KiB
TypeScript

import MotionNumber from '@number-flow/react'
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useMemo } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { DiskStorageSchemaType } from '../DiskManagement.schema'
import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants'
import { useDiskBreakdownQuery } from '@/data/config/disk-breakdown-query'
import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { GB } from '@/lib/constants'
import { formatBytes } from '@/lib/helpers'
interface DiskSpaceBarProps {
form: UseFormReturn<DiskStorageSchemaType>
}
export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => {
const { ref } = useParams()
const { resolvedTheme } = useTheme()
const { formState, watch } = form
const isDarkMode = resolvedTheme?.includes('dark')
const { data: project } = useSelectedProjectQuery()
const {
data: diskUtil,
// to do, error handling
} = useDiskUtilizationQuery({
projectRef: ref,
})
const { data: diskBreakdown } = useDiskBreakdownQuery({
projectRef: ref,
connectionString: project?.connectionString,
})
const diskBreakdownBytes = useMemo(() => {
return {
availableBytes: diskUtil?.metrics.fs_avail_bytes ?? 0,
totalUsedBytes: diskUtil?.metrics.fs_used_bytes ?? 0,
totalDiskSizeBytes: diskUtil?.metrics.fs_size_bytes,
dbSizeBytes: Math.max(0, diskBreakdown?.db_size_bytes ?? 0),
walSizeBytes: Math.max(0, diskBreakdown?.wal_size_bytes ?? 0),
systemBytes: Math.max(
0,
(diskUtil?.metrics.fs_used_bytes ?? 0) -
(diskBreakdown?.db_size_bytes ?? 0) -
(diskBreakdown?.wal_size_bytes ?? 0)
),
}
}, [diskUtil, diskBreakdown])
const showNewSize = formState.dirtyFields.totalSize !== undefined && diskBreakdown
const newTotalSize = watch('totalSize')
const totalSize = formState.defaultValues?.totalSize || 0
const usedSizeTotal = Math.round(((diskBreakdownBytes?.totalUsedBytes ?? 0) / GB) * 100) / 100
const usedTotalPercentage = Math.min((usedSizeTotal / totalSize) * 100, 100)
const usedSizeDatabase = Math.round(((diskBreakdownBytes?.dbSizeBytes ?? 0) / GB) * 100) / 100
const usedPercentageDatabase =
totalSize === 0 ? 0 : Math.min((usedSizeDatabase / totalSize) * 100, 100)
const newUsedPercentageDatabase = Math.min((usedSizeDatabase / newTotalSize) * 100, 100)
const usedSizeWAL = Math.round(((diskBreakdownBytes?.walSizeBytes ?? 0) / GB) * 100) / 100
const usedPercentageWAL = totalSize === 0 ? 0 : Math.min((usedSizeWAL / totalSize) * 100, 100)
const newUsedPercentageWAL = Math.min((usedSizeWAL / newTotalSize) * 100, 100)
const usedSizeSystem = Math.round(((diskBreakdownBytes?.systemBytes ?? 0) / GB) * 100) / 100
const usedPercentageSystem =
totalSize === 0 ? 0 : Math.min((usedSizeSystem / totalSize) * 100, 100)
const newUsedPercentageSystem = Math.min((usedSizeSystem / newTotalSize) * 100, 100)
const resizePercentage = AUTOSCALING_THRESHOLD * 100
// Anchored to the actual right edge of the highlighted "border-r" zone below, rather than
// trusting resizePercentage alone, since usedPercentage* are each rounded independently and
// can drift a fraction of a percent from usedTotalPercentage.
const resizeMarkerLeftPercentage =
usedPercentageDatabase +
usedPercentageWAL +
usedPercentageSystem +
Math.max(0, resizePercentage - usedTotalPercentage)
return (
<div className="flex flex-col gap-2">
<div className="flex items-center h-6 gap-3">
<span className="text-foreground-light text-sm font-mono flex items-center gap-2">
{usedSizeTotal.toFixed(2)}
<span>GB used of </span>
<span className="text-foreground font-semibold mt-[-2px]">
<MotionNumber value={newTotalSize} style={{ lineHeight: 0.8 }} className="font-mono" />
</span>{' '}
GB
</span>
</div>
<div className="relative">
<div
className={cn(
'h-[35px] relative border rounded-xs w-full transition overflow-visible',
showNewSize ? 'bg-selection border border-brand' : 'bg-surface-300'
)}
>
<AnimatePresence>
<motion.div
key="currentBar"
initial={{ rotateY: 90, zIndex: 2 }}
animate={{ rotateY: 0, zIndex: 1 }}
exit={{ rotateY: -90, zIndex: 2 }}
transition={{ duration: 0.3 }}
style={{ transformOrigin: 'left center', backfaceVisibility: 'hidden' }}
className="absolute inset-0 rounded-xs overflow-hidden"
>
<div className="h-full flex">
<div
className="relative overflow-hidden transition-all duration-500 ease-in-out bg-foreground"
style={{
width: `${showNewSize ? newUsedPercentageDatabase : usedPercentageDatabase}%`,
}}
>
<div
className="absolute inset-0"
style={{
backgroundImage: `repeating-linear-gradient(
-45deg,
${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'},
${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'},
transparent 1px,
transparent 4px
)`,
}}
/>
</div>
<div
className="relative overflow-hidden transition-all duration-500 ease-in-out bg-[hsl(var(--secondary-default))]"
style={{
width: `${showNewSize ? newUsedPercentageWAL : usedPercentageWAL}%`,
}}
/>
<div
className="relative overflow-hidden transition-all duration-500 ease-in-out bg-destructive-500"
style={{
width: `${showNewSize ? newUsedPercentageSystem : usedPercentageSystem}%`,
}}
/>
{!showNewSize && (
<div
className="bg-transparent-800 border-r transition-all duration-500 ease-in-out"
style={{
width: `${resizePercentage - usedTotalPercentage <= 0 ? 0 : resizePercentage - usedTotalPercentage}%`,
}}
/>
)}
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence>
{showNewSize && (
<motion.div
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
transition={{ duration: 0.12, delay: 0.12 }}
className="absolute right-2 top-0 flex items-center h-full"
>
<Badge variant="success">New disk size</Badge>
</motion.div>
)}
</AnimatePresence>
{!showNewSize && (
<div
className="absolute z-10"
style={{
left: `${resizeMarkerLeftPercentage}%`,
top: '-20px',
transform: 'translateX(-50%)',
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center cursor-help text-foreground-lighter hover:text-foreground transition-colors">
<Info size={14} />
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="w-[310px] flex flex-col gap-y-1 leading-relaxed text-foreground-light"
>
<h3 className="text-sm font-medium text-foreground">Autoscaling</h3>
<p>
Supabase expands your disk storage automatically when the database reaches 90%
of the disk size. However, disk modifications, including auto-scaling, are
limited to 4 within a rolling 24-hour window.
</p>
<p>
If you exhaust these modifications and reach 95% of the disk space, your project{' '}
<span className="text-destructive-600">will enter read-only mode.</span>
</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
{!showNewSize && (
<div className="flex items-center space-x-3 text-xs text-foreground-lighter">
<LegendItem
name="Database"
size={diskBreakdownBytes.dbSizeBytes}
color="bg-foreground"
description="Total space on disk used by your database (tables, indexes, data, ...)."
/>
<LegendItem
name="WAL"
size={diskBreakdownBytes.walSizeBytes}
color="bg-[hsl(var(--secondary-default))]"
description="Total space on disk used by the write-ahead log."
/>
<LegendItem
name="System"
size={diskBreakdownBytes.systemBytes}
color="bg-destructive-500"
description="Reserved space for the system to ensure your database runs smoothly. You cannot modify this."
/>
<LegendItem
name="Available space"
size={diskBreakdownBytes.availableBytes}
color="bg-border"
description="Total available space on the disk left."
/>
</div>
)}
<p className="text-xs text-foreground-lighter my-4">
<span className="font-semibold">Note:</span> Disk Size refers to the total space your
project occupies on disk, including the database itself (currently{' '}
<span>{formatBytes(diskBreakdownBytes?.dbSizeBytes, 2, 'GB')}</span>), additional files like
the write-ahead log (currently{' '}
<span>{formatBytes(diskBreakdownBytes?.walSizeBytes, 2, 'GB')}</span>), and other system
resources (currently <span>{formatBytes(diskBreakdownBytes?.systemBytes, 2, 'GB')}</span>).
Data can take 5 minutes to refresh.
</p>
</div>
)
}
const LegendItem = ({
name,
description,
color,
size,
}: {
name: string
description: string
color: string
size: number
}) => (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center hover:cursor-help z-10">
<div className={cn('w-2 h-2 rounded-full mr-2', color)} />
<span>{name}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex flex-col gap-y-1 max-w-xs">
<div className="flex items-center">
<div className={cn('w-2 h-2 rounded-full mr-2', color)} />
<span>
{name} - {formatBytes(size, 2, 'GB')}
</span>
</div>
<p>{description}</p>
</TooltipContent>
</Tooltip>
)