mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## Problem Primary colour serves readable text and selected controls, but those uses need different shades. Light mode needs darker text, while dark mode needs a deeper button fill. Fixed brand green on interactive chrome also prevents a custom primary hue from carrying through the interface. Some slider tracks and selected text are hard to read. ## Solution - Keep `--primary` for accessible text and small selected indicators. Use `--primary-solid` for button fills, which need a deeper shade in dark mode. - Add `--primary-bright` for focus rings, selected control chrome, chart accents, and other interactive highlights. It follows `--primary-hue`; `brand-*` stays fixed for Supabase identity. - Make slider troughs clearer and text selection translucent with theme foreground text. - Document the split in the design-system colour guide. | Before | After | | --- | --- | | <img width="980" height="244" alt="Before: light mode primary controls" src="https://github.com/user-attachments/assets/dfae325d-0dfe-4231-8bcd-3f89c4b9d793" /> | <img width="982" height="204" alt="After: light mode primary controls" src="https://github.com/user-attachments/assets/5fdcb531-a6e3-4549-8a13-9d9a5ebe6e20" /> | | <img width="610" height="120" alt="Before: slider track" src="https://github.com/user-attachments/assets/04f768e0-51e8-4d06-9b97-c52f4a34f122" /> | <img width="622" height="126" alt="After: slider track" src="https://github.com/user-attachments/assets/95127f4e-13dc-4f0f-b63c-cf5d70a28b42" /> | | <img width="652" height="512" alt="Before: dark mode controls" src="https://github.com/user-attachments/assets/3f88de66-90cc-40ee-8cf1-b5f4eb87b09a" /> | <img width="658" height="498" alt="After: dark mode controls" src="https://github.com/user-attachments/assets/906bec30-6ca1-4614-9fb3-6cf5e5feec22" /> | ## Review instructions 1. Compare light and dark mode in the [colour usage guide](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#primary-and-brand-colors). Check primary ink, primary-solid, primary-bright, and fixed brand swatches. 2. In Studio, open the ‘new table’ sheet in [Table Editor](https://studio-staging-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/dashboard/project/_/editor). Tab through the new table sheet's fields and toggles. Check the focus rings, selected controls, and the sheet's edges in both themes. You do not need to save a table. 3. Select text in Studio in both themes, including a link or primary-coloured label. The selection and text should remain legible. 4. Check the [Field](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/field) Price Range slider: the unused track should remain visible in both themes. The selected field card border should follow primary-bright. 5. Check the [Button](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/button) and [Radio Group](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/radio-group) previews. In dark mode, `primary` button fill should be deeper than primary [text](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#text); selected radios should remain readable.
274 lines
11 KiB
TypeScript
274 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, useWatch } 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 } = 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 = useWatch({ control: form.control, name: '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-primary-bright' : '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>
|
|
)}
|
|
</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>
|
|
)
|