DiskSpaceBar.tsx279 lines · main
| 1 | import MotionNumber from '@number-flow/react' |
| 2 | import { useParams } from 'common' |
| 3 | import { AnimatePresence, motion } from 'framer-motion' |
| 4 | import { Info } from 'lucide-react' |
| 5 | import { useTheme } from 'next-themes' |
| 6 | import { useMemo } from 'react' |
| 7 | import { UseFormReturn } from 'react-hook-form' |
| 8 | import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 9 | |
| 10 | import { DiskStorageSchemaType } from '../DiskManagement.schema' |
| 11 | import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants' |
| 12 | import { useDiskBreakdownQuery } from '@/data/config/disk-breakdown-query' |
| 13 | import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' |
| 14 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 15 | import { GB } from '@/lib/constants' |
| 16 | import { formatBytes } from '@/lib/helpers' |
| 17 | |
| 18 | interface DiskSpaceBarProps { |
| 19 | form: UseFormReturn<DiskStorageSchemaType> |
| 20 | } |
| 21 | |
| 22 | export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => { |
| 23 | const { ref } = useParams() |
| 24 | const { resolvedTheme } = useTheme() |
| 25 | const { formState, watch } = form |
| 26 | const isDarkMode = resolvedTheme?.includes('dark') |
| 27 | const { data: project } = useSelectedProjectQuery() |
| 28 | |
| 29 | const { |
| 30 | data: diskUtil, |
| 31 | // to do, error handling |
| 32 | } = useDiskUtilizationQuery({ |
| 33 | projectRef: ref, |
| 34 | }) |
| 35 | |
| 36 | const { data: diskBreakdown } = useDiskBreakdownQuery({ |
| 37 | projectRef: ref, |
| 38 | connectionString: project?.connectionString, |
| 39 | }) |
| 40 | |
| 41 | const diskBreakdownBytes = useMemo(() => { |
| 42 | return { |
| 43 | availableBytes: diskUtil?.metrics.fs_avail_bytes ?? 0, |
| 44 | totalUsedBytes: diskUtil?.metrics.fs_used_bytes ?? 0, |
| 45 | totalDiskSizeBytes: diskUtil?.metrics.fs_size_bytes, |
| 46 | dbSizeBytes: Math.max(0, diskBreakdown?.db_size_bytes ?? 0), |
| 47 | walSizeBytes: Math.max(0, diskBreakdown?.wal_size_bytes ?? 0), |
| 48 | systemBytes: Math.max( |
| 49 | 0, |
| 50 | (diskUtil?.metrics.fs_used_bytes ?? 0) - |
| 51 | (diskBreakdown?.db_size_bytes ?? 0) - |
| 52 | (diskBreakdown?.wal_size_bytes ?? 0) |
| 53 | ), |
| 54 | } |
| 55 | }, [diskUtil, diskBreakdown]) |
| 56 | |
| 57 | const showNewSize = formState.dirtyFields.totalSize !== undefined && diskBreakdown |
| 58 | const newTotalSize = watch('totalSize') |
| 59 | |
| 60 | const totalSize = formState.defaultValues?.totalSize || 0 |
| 61 | const usedSizeTotal = Math.round(((diskBreakdownBytes?.totalUsedBytes ?? 0) / GB) * 100) / 100 |
| 62 | const usedTotalPercentage = Math.min((usedSizeTotal / totalSize) * 100, 100) |
| 63 | |
| 64 | const usedSizeDatabase = Math.round(((diskBreakdownBytes?.dbSizeBytes ?? 0) / GB) * 100) / 100 |
| 65 | const usedPercentageDatabase = |
| 66 | totalSize === 0 ? 0 : Math.min((usedSizeDatabase / totalSize) * 100, 100) |
| 67 | const newUsedPercentageDatabase = Math.min((usedSizeDatabase / newTotalSize) * 100, 100) |
| 68 | |
| 69 | const usedSizeWAL = Math.round(((diskBreakdownBytes?.walSizeBytes ?? 0) / GB) * 100) / 100 |
| 70 | const usedPercentageWAL = totalSize === 0 ? 0 : Math.min((usedSizeWAL / totalSize) * 100, 100) |
| 71 | const newUsedPercentageWAL = Math.min((usedSizeWAL / newTotalSize) * 100, 100) |
| 72 | |
| 73 | const usedSizeSystem = Math.round(((diskBreakdownBytes?.systemBytes ?? 0) / GB) * 100) / 100 |
| 74 | const usedPercentageSystem = |
| 75 | totalSize === 0 ? 0 : Math.min((usedSizeSystem / totalSize) * 100, 100) |
| 76 | const newUsedPercentageSystem = Math.min((usedSizeSystem / newTotalSize) * 100, 100) |
| 77 | |
| 78 | const resizePercentage = AUTOSCALING_THRESHOLD * 100 |
| 79 | const newResizePercentage = AUTOSCALING_THRESHOLD * 100 |
| 80 | |
| 81 | return ( |
| 82 | <div className="flex flex-col gap-2"> |
| 83 | <div className="flex items-center h-6 gap-3"> |
| 84 | <span className="text-foreground-light text-sm font-mono flex items-center gap-2"> |
| 85 | {usedSizeTotal.toFixed(2)} |
| 86 | <span>GB used of </span> |
| 87 | <span className="text-foreground font-semibold mt-[-2px]"> |
| 88 | <MotionNumber value={newTotalSize} style={{ lineHeight: 0.8 }} className="font-mono" /> |
| 89 | </span>{' '} |
| 90 | GB |
| 91 | </span> |
| 92 | </div> |
| 93 | <div className="relative"> |
| 94 | <div |
| 95 | className={cn( |
| 96 | 'h-[35px] relative border rounded-xs w-full transition overflow-visible', |
| 97 | showNewSize ? 'bg-selection border border-brand' : 'bg-surface-300' |
| 98 | )} |
| 99 | > |
| 100 | <AnimatePresence> |
| 101 | <motion.div |
| 102 | key="currentBar" |
| 103 | initial={{ rotateY: 90, zIndex: 2 }} |
| 104 | animate={{ rotateY: 0, zIndex: 1 }} |
| 105 | exit={{ rotateY: -90, zIndex: 2 }} |
| 106 | transition={{ duration: 0.3 }} |
| 107 | style={{ transformOrigin: 'left center', backfaceVisibility: 'hidden' }} |
| 108 | className="absolute inset-0 rounded-xs overflow-hidden" |
| 109 | > |
| 110 | <div className="h-full flex"> |
| 111 | <div |
| 112 | className="relative overflow-hidden transition-all duration-500 ease-in-out bg-foreground" |
| 113 | style={{ |
| 114 | width: `${showNewSize ? newUsedPercentageDatabase : usedPercentageDatabase}%`, |
| 115 | }} |
| 116 | > |
| 117 | <div |
| 118 | className="absolute inset-0" |
| 119 | style={{ |
| 120 | backgroundImage: `repeating-linear-gradient( |
| 121 | -45deg, |
| 122 | ${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'}, |
| 123 | ${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'}, |
| 124 | transparent 1px, |
| 125 | transparent 4px |
| 126 | )`, |
| 127 | }} |
| 128 | /> |
| 129 | </div> |
| 130 | |
| 131 | <div |
| 132 | className="relative overflow-hidden transition-all duration-500 ease-in-out bg-[hsl(var(--secondary-default))]" |
| 133 | style={{ |
| 134 | width: `${showNewSize ? newUsedPercentageWAL : usedPercentageWAL}%`, |
| 135 | }} |
| 136 | /> |
| 137 | |
| 138 | <div |
| 139 | className="relative overflow-hidden transition-all duration-500 ease-in-out bg-destructive-500" |
| 140 | style={{ |
| 141 | width: `${showNewSize ? newUsedPercentageSystem : usedPercentageSystem}%`, |
| 142 | }} |
| 143 | /> |
| 144 | |
| 145 | {!showNewSize && ( |
| 146 | <div |
| 147 | className="bg-transparent-800 border-r transition-all duration-500 ease-in-out" |
| 148 | style={{ |
| 149 | width: `${resizePercentage - usedTotalPercentage <= 0 ? 0 : resizePercentage - usedTotalPercentage}%`, |
| 150 | }} |
| 151 | /> |
| 152 | )} |
| 153 | </div> |
| 154 | </motion.div> |
| 155 | </AnimatePresence> |
| 156 | <AnimatePresence> |
| 157 | {showNewSize && ( |
| 158 | <motion.div |
| 159 | initial={{ opacity: 0, x: 4 }} |
| 160 | animate={{ opacity: 1, x: 0 }} |
| 161 | exit={{ opacity: 0, x: 4 }} |
| 162 | transition={{ duration: 0.12, delay: 0.12 }} |
| 163 | className="absolute right-2 top-0 flex items-center h-full" |
| 164 | > |
| 165 | <Badge variant="success">New disk size</Badge> |
| 166 | </motion.div> |
| 167 | )} |
| 168 | </AnimatePresence> |
| 169 | </div> |
| 170 | <AnimatePresence initial={true}> |
| 171 | {!showNewSize && ( |
| 172 | <motion.div |
| 173 | key="currentSize" |
| 174 | initial={{ opacity: 0, y: -10 }} |
| 175 | animate={{ opacity: 1, y: 0 }} |
| 176 | exit={{ opacity: 0, y: 10 }} |
| 177 | transition={{ duration: 0.1 }} |
| 178 | className="absolute h-8 w-full mx-[-2px]" |
| 179 | > |
| 180 | <div |
| 181 | className="absolute top-0 left-0 h-full flex items-center transition-all duration-500 ease-in-out" |
| 182 | style={{ left: `${showNewSize ? newResizePercentage : resizePercentage}%` }} |
| 183 | > |
| 184 | <Tooltip> |
| 185 | <TooltipTrigger asChild> |
| 186 | <div className="absolute right-full bottom-0 border mr-2 px-2 py-1 bg-surface-400 rounded-sm text-xs text-foreground-light whitespace-nowrap flex items-center gap-x-1"> |
| 187 | Autoscaling <Info size={12} /> |
| 188 | </div> |
| 189 | </TooltipTrigger> |
| 190 | <TooltipContent side="bottom" className="w-[310px] flex flex-col gap-y-1"> |
| 191 | <p> |
| 192 | Briven expands your disk storage automatically when the database reached 90% |
| 193 | of the disk size. However, any disk modifications, including auto-scaling, can |
| 194 | only take place once every 4 hours. |
| 195 | </p> |
| 196 | <p> |
| 197 | If within those 4 hours you reach 95% of the disk space, your project{' '} |
| 198 | <span className="text-destructive-600">will enter read-only mode.</span> |
| 199 | </p> |
| 200 | </TooltipContent> |
| 201 | </Tooltip> |
| 202 | <div className="w-px h-full bg-border" /> |
| 203 | </div> |
| 204 | </motion.div> |
| 205 | )} |
| 206 | </AnimatePresence> |
| 207 | </div> |
| 208 | {!showNewSize && ( |
| 209 | <div className="flex items-center space-x-3 text-xs text-foreground-lighter"> |
| 210 | <LegendItem |
| 211 | name="Database" |
| 212 | size={diskBreakdownBytes.dbSizeBytes} |
| 213 | color="bg-foreground" |
| 214 | description="Total space on disk used by your database (tables, indexes, data, ...)." |
| 215 | /> |
| 216 | <LegendItem |
| 217 | name="WAL" |
| 218 | size={diskBreakdownBytes.walSizeBytes} |
| 219 | color="bg-[hsl(var(--secondary-default))]" |
| 220 | description="Total space on disk used by the write-ahead log." |
| 221 | /> |
| 222 | |
| 223 | <LegendItem |
| 224 | name="System" |
| 225 | size={diskBreakdownBytes.systemBytes} |
| 226 | color="bg-destructive-500" |
| 227 | description="Reserved space for the system to ensure your database runs smoothly. You cannot modify this." |
| 228 | /> |
| 229 | |
| 230 | <LegendItem |
| 231 | name="Available space" |
| 232 | size={diskBreakdownBytes.availableBytes} |
| 233 | color="bg-border" |
| 234 | description="Total available space on the disk left." |
| 235 | /> |
| 236 | </div> |
| 237 | )} |
| 238 | <p className="text-xs text-foreground-lighter my-4"> |
| 239 | <span className="font-semibold">Note:</span> Disk Size refers to the total space your |
| 240 | project occupies on disk, including the database itself (currently{' '} |
| 241 | <span>{formatBytes(diskBreakdownBytes?.dbSizeBytes, 2, 'GB')}</span>), additional files like |
| 242 | the write-ahead log (currently{' '} |
| 243 | <span>{formatBytes(diskBreakdownBytes?.walSizeBytes, 2, 'GB')}</span>), and other system |
| 244 | resources (currently <span>{formatBytes(diskBreakdownBytes?.systemBytes, 2, 'GB')}</span>). |
| 245 | Data can take 5 minutes to refresh. |
| 246 | </p> |
| 247 | </div> |
| 248 | ) |
| 249 | } |
| 250 | |
| 251 | const LegendItem = ({ |
| 252 | name, |
| 253 | description, |
| 254 | color, |
| 255 | size, |
| 256 | }: { |
| 257 | name: string |
| 258 | description: string |
| 259 | color: string |
| 260 | size: number |
| 261 | }) => ( |
| 262 | <Tooltip> |
| 263 | <TooltipTrigger asChild> |
| 264 | <div className="flex items-center hover:cursor-help z-10"> |
| 265 | <div className={cn('w-2 h-2 rounded-full mr-2', color)} /> |
| 266 | <span>{name}</span> |
| 267 | </div> |
| 268 | </TooltipTrigger> |
| 269 | <TooltipContent side="bottom" className="flex flex-col gap-y-1 max-w-xs"> |
| 270 | <div className="flex items-center"> |
| 271 | <div className={cn('w-2 h-2 rounded-full mr-2', color)} /> |
| 272 | <span> |
| 273 | {name} - {formatBytes(size, 2, 'GB')} |
| 274 | </span> |
| 275 | </div> |
| 276 | <p>{description}</p> |
| 277 | </TooltipContent> |
| 278 | </Tooltip> |
| 279 | ) |