feat(storage): break down retained storage on the org usage page

Splits Storage Size into current versus noncurrent objects, and attributes the
noncurrent portion to the buckets responsible, so the cost of object versioning
sits with the metric it inflates.

The split is flag-aware. With the preview off, Storage Size renders exactly the
single real series it does today; `USAGE_CATEGORIES` takes an options argument
and only emits the two stacked segments when the preview is on.

Noncurrent is deliberately not split further into noncurrent versions and
soft-deleted files: the platform can't reliably tell the two apart after the
fact, and the user's lever for shrinking either is the same lifecycle policy.
Using S3's own terms also reads better than Studio-only words like "live".

- `storage-retention-usage-query.ts` — org-scoped (the prototype keyed this
  org-level data under `['projects', undefined, …]`), real `queryOptions` shape
  with the endpoint stubbed
- `StorageRetention.constants.ts` — one definition of the two segments, used by
  both the chart attributes and the breakdown table, so labels, colors and key
  casing cannot drift
- `StorageRetention.utils.ts` + tests — maps retention days onto chart points
- `StorageRetentionBreakdown.tsx` — the segment table, retained-data warning,
  and per-bucket list, rendered through the existing `additionalInfo` hook
- `Usage.colors.ts` — `COLOR_MAP` and `AttributeColor` moved out of
  `Usage.constants` so the breakdown can read a color token without a cycle
  back through the module that renders it

Known limitation: with the preview on, the stacked chart and the breakdown read
zeros until the retention endpoint exists. No mock data is shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Francesco SansalvadoreandClaude Sonnet 5 authored and GitHub committed 2026-10-05 15:04:15 +02:00
1 parent 795537377b
commit a87e49685f
13 files changed
+344 -47

No files matched your search

@@ -3,7 +3,8 @@ import { useMemo } from 'react'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { SectionContent } from './SectionContent'
import { Attribute, AttributeColor } from './Usage.constants'
import type { AttributeColor } from './Usage.colors'
import { Attribute } from './Usage.constants'
import { dailyUsageToDataPoints } from './Usage.utils'
import UsageBarChart from './UsageBarChart'
import Panel from '@/components/ui/Panel'
@@ -1,7 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { toStorageSizeChartData } from './StorageRetention/StorageRetention.utils'
import { dailyUsageToDataPoints } from './Usage.utils'
import UsageSection from './UsageSection/UsageSection'
import { useIsStorageVersioningEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { DataPoint } from '@/data/analytics/constants'
import { PricingMetric, type OrgDailyUsageResponse } from '@/data/analytics/org-daily-stats-query'
import { storageRetentionUsageQueryOptions } from '@/data/storage/versioning/storage-retention-usage-query'
import type { OrgSubscription } from '@/data/subscriptions/types'
export interface SizeAndCountsProps {
@@ -25,17 +30,34 @@ const SizeAndCounts = ({
startDate,
endDate,
}: SizeAndCountsProps) => {
const isStorageVersioningEnabled = useIsStorageVersioningEnabled()
// The daily org stats don't break Storage Size into segments; that split comes
// from the retention endpoint.
const { data: retention, isPending: isLoadingRetention } = useQuery({
...storageRetentionUsageQueryOptions({ orgSlug }),
enabled: isStorageVersioningEnabled,
})
const storageSizeChartMeta = isStorageVersioningEnabled
? {
isLoading: isLoadingRetention,
margin: 14,
data: toStorageSizeChartData(retention?.daily ?? []),
}
: {
isLoading: isLoadingOrgDailyStats,
margin: 14,
data: dailyUsageToDataPoints(
orgDailyStats,
(metric) => metric === PricingMetric.STORAGE_SIZE
),
}
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean }
} = {
[PricingMetric.STORAGE_SIZE]: {
isLoading: isLoadingOrgDailyStats,
margin: 14,
data: dailyUsageToDataPoints(
orgDailyStats,
(metric) => metric === PricingMetric.STORAGE_SIZE
),
},
[PricingMetric.STORAGE_SIZE]: storageSizeChartMeta,
[PricingMetric.DATABASE_SIZE]: {
isLoading: isLoadingOrgDailyStats,
margin: 14,
@@ -0,0 +1,25 @@
import type { AttributeColor } from '../Usage.colors'
import type { StorageRetentionTotals } from '@/data/storage/versioning/storage-retention-usage-query'
interface StorageSizeSegment {
/** The stacked chart series key, matching what the usage endpoint emits. */
attributeKey: string
/** The corresponding field on the retention totals. */
totalsKey: keyof StorageRetentionTotals
name: string
color: AttributeColor
}
/**
* What makes up Storage Size once versioning is in play. Single source of truth
* for both the stacked bars and the breakdown table, so they can't drift.
*/
export const STORAGE_SIZE_SEGMENTS: readonly StorageSizeSegment[] = [
{ attributeKey: 'current', totalsKey: 'current', name: 'Current objects', color: 'white' },
{
attributeKey: 'noncurrent',
totalsKey: 'noncurrent',
name: 'Noncurrent objects',
color: 'yellow',
},
]
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { STORAGE_SIZE_SEGMENTS } from './StorageRetention.constants'
import { toStorageSizeChartData } from './StorageRetention.utils'
import type { StorageRetentionDayPoint } from '@/data/storage/versioning/storage-retention-usage-query'
const day = (overrides: Partial<StorageRetentionDayPoint> = {}): StorageRetentionDayPoint => ({
date: '2026-08-01T00:00:00Z',
current: 100,
noncurrent: 25,
...overrides,
})
describe('toStorageSizeChartData', () => {
it('returns an empty series for no data, rather than a placeholder point', () => {
expect(toStorageSizeChartData([])).toEqual([])
})
it('emits one point per day, in the order given', () => {
const points = toStorageSizeChartData([
day({ date: '2026-08-01T00:00:00Z' }),
day({ date: '2026-08-02T00:00:00Z' }),
])
expect(points.map((point) => point.period_start)).toEqual([
'2026-08-01T00:00:00Z',
'2026-08-02T00:00:00Z',
])
})
it('maps each retention total onto the chart series key the attribute list declares', () => {
// A mismatch here means a stacked bar silently renders as zero, so the keys
// must come from the same source the attributes do.
const [point] = toStorageSizeChartData([day()])
expect(point.current).toBe(100)
expect(point.noncurrent).toBe(25)
})
it('emits a series for every declared segment', () => {
const [point] = toStorageSizeChartData([day()])
for (const segment of STORAGE_SIZE_SEGMENTS) {
expect(point, segment.attributeKey).toHaveProperty(segment.attributeKey)
}
})
it('formats the axis label as day and short month', () => {
const [point] = toStorageSizeChartData([day({ date: '2026-08-09T00:00:00Z' })])
expect(point.periodStartFormatted).toBe('09 Aug')
})
it('keeps zeros as zeros so an empty segment still stacks', () => {
const [point] = toStorageSizeChartData([day({ noncurrent: 0 })])
expect(point.noncurrent).toBe(0)
})
})
@@ -0,0 +1,22 @@
import dayjs from 'dayjs'
import { STORAGE_SIZE_SEGMENTS } from './StorageRetention.constants'
import type { DataPoint } from '@/data/analytics/constants'
import type { StorageRetentionDayPoint } from '@/data/storage/versioning/storage-retention-usage-query'
/**
* The retention daily series as `DataPoint[]`, keyed by the same attribute keys
* the Storage Size attribute list declares, so every bar has a series to draw.
*/
export const toStorageSizeChartData = (daily: StorageRetentionDayPoint[]): DataPoint[] =>
daily.map((day) => {
const segments = Object.fromEntries(
STORAGE_SIZE_SEGMENTS.map((segment) => [segment.attributeKey, day[segment.totalsKey]])
)
return {
period_start: day.date,
periodStartFormatted: dayjs(day.date).format('DD MMM'),
...segments,
}
})
@@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { COLOR_MAP } from '../Usage.colors'
import { STORAGE_SIZE_SEGMENTS } from './StorageRetention.constants'
import { storageRetentionUsageQueryOptions } from '@/data/storage/versioning/storage-retention-usage-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { formatBytes } from '@/lib/helpers'
/**
* What is driving Storage Size — current objects versus the noncurrent data
* versioning retains — attributed to the buckets responsible. Rendered via
* `additionalInfo` so the noncurrent cost sits with the metric it inflates.
*/
export const StorageRetentionBreakdown = () => {
const { data: organization } = useSelectedOrganizationQuery()
const { data, isPending, isSuccess } = useQuery(
storageRetentionUsageQueryOptions({ orgSlug: organization?.slug })
)
if (isPending) {
return (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
)
}
if (!isSuccess) return null
const { totals, byBucket } = data
const bucketsWithNoncurrentData = byBucket.filter((bucket) => bucket.noncurrent > 0)
return (
<div className="space-y-4">
<div>
<p className="mb-2 text-sm">What’s driving this</p>
{STORAGE_SIZE_SEGMENTS.map((segment) => (
<div
key={segment.attributeKey}
className="flex items-center justify-between border-b py-1"
>
<div className="flex items-center gap-x-2">
<div className={cn('h-3 w-3 rounded-xs', COLOR_MAP[segment.color].marker)} />
<p className="text-xs text-foreground-light">{segment.name}</p>
</div>
<p className="text-xs">{formatBytes(totals[segment.totalsKey])}</p>
</div>
))}
<div className="flex items-center justify-between py-1">
<p className="text-xs text-foreground-light">Total</p>
<p className="text-xs">{formatBytes(totals.current + totals.noncurrent)}</p>
</div>
</div>
{totals.noncurrent > 0 && (
<Admonition
type="warning"
title={`${formatBytes(totals.noncurrent)} is retained recovery data`}
>
<p className="text-xs">
Noncurrent objects stay billable after an overwrite or delete. They are freed when a
lifecycle policy expires them, when the retention limit is reached, or when you delete
the version yourself.
</p>
</Admonition>
)}
{bucketsWithNoncurrentData.length > 0 && (
<div>
<p className="mb-2 text-sm">Noncurrent data by bucket</p>
{bucketsWithNoncurrentData.map((bucket) => (
<div key={bucket.bucket} className="flex items-center justify-between border-b py-1">
<p className="font-mono text-xs text-foreground-light">{bucket.bucket}</p>
<p className="w-16 text-right text-xs">{formatBytes(bucket.noncurrent)}</p>
</div>
))}
<p className="pt-2 text-xs text-foreground-lighter">
Retention is set per bucket, in the bucket’s settings.
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,20 @@
/**
* Separate from `Usage.constants` so modules that only need a color token don't
* import the category tree — a cycle for anything `Usage.constants` renders.
*/
export const COLOR_MAP = {
white: { bar: 'fill-foreground', marker: 'bg-foreground' },
green: { bar: 'fill-green-800', marker: 'bg-green-800' },
'dark-green': { bar: 'fill-green-1000', marker: 'bg-green-1000' },
blue: { bar: 'fill-blue-900', marker: 'bg-blue-900' },
yellow: { bar: 'fill-yellow-800', marker: 'bg-yellow-800' },
'dark-yellow': { bar: 'fill-yellow-1000', marker: 'bg-yellow-1000' },
orange: { bar: 'fill-orange-800', marker: 'bg-orange-800' },
'dark-orange': { bar: 'fill-orange-1000', marker: 'bg-orange-1100' },
teal: { bar: 'fill-teal-600', marker: 'bg-teal-700' },
red: { bar: 'fill-red-800', marker: 'bg-red-800' },
'dark-red': { bar: 'fill-red-1000', marker: 'bg-red-1000' },
purple: { bar: 'fill-purple-900', marker: 'bg-purple-900' },
}
export type AttributeColor = keyof typeof COLOR_MAP
@@ -1,47 +1,21 @@
import { ReactNode } from 'react'
import { Admonition } from 'ui-patterns/Admonition'
import { STORAGE_SIZE_SEGMENTS } from './StorageRetention/StorageRetention.constants'
import { StorageRetentionBreakdown } from './StorageRetention/StorageRetentionBreakdown'
import type { AttributeColor } from './Usage.colors'
import { USAGE_APPROACHING_THRESHOLD } from '@/components/interfaces/Billing/Billing.constants'
import { EgressType, PricingMetric } from '@/data/analytics/org-daily-stats-query'
import type { OrgSubscription } from '@/data/subscriptions/types'
import type { OrgUsageResponse } from '@/data/usage/org-usage-query'
import { DOCS_URL } from '@/lib/constants'
export const COLOR_MAP = {
white: { bar: 'fill-foreground', marker: 'bg-foreground' },
green: { bar: 'fill-green-800', marker: 'bg-green-800' },
'dark-green': { bar: 'fill-green-1000', marker: 'bg-green-1000' },
blue: { bar: 'fill-blue-900', marker: 'bg-blue-900' },
yellow: { bar: 'fill-yellow-800', marker: 'bg-yellow-800' },
'dark-yellow': { bar: 'fill-yellow-1000', marker: 'bg-yellow-1000' },
orange: { bar: 'fill-orange-800', marker: 'bg-orange-800' },
'dark-orange': { bar: 'fill-orange-1000', marker: 'bg-orange-1100' },
teal: { bar: 'fill-teal-600', marker: 'bg-teal-700' },
red: { bar: 'fill-red-800', marker: 'bg-red-800' },
'dark-red': { bar: 'fill-red-1000', marker: 'bg-red-1000' },
purple: { bar: 'fill-purple-900', marker: 'bg-purple-900' },
}
export const USAGE_STATUS = {
NORMAL: 'NORMAL',
APPROACHING: 'APPROACHING',
EXCEEDED: 'EXCEEDED',
}
export type AttributeColor =
| 'white'
| 'blue'
| 'green'
| 'yellow'
| 'orange'
| 'purple'
| 'red'
| 'dark-red'
| 'dark-orange'
| 'dark-yellow'
| 'dark-green'
| 'teal'
export interface Attribute {
key: string
name?: string
@@ -73,9 +47,18 @@ export interface CategoryMeta {
attributes: CategoryAttribute[]
}
export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[] = (
subscription
) => {
export interface UsageCategoryOptions {
/**
* On, Storage Size splits into current / noncurrent segments and gains a
* retention breakdown. Off, it stays the single real series.
*/
isStorageVersioningEnabled?: boolean
}
export const USAGE_CATEGORIES: (
subscription?: OrgSubscription,
options?: UsageCategoryOptions
) => CategoryMeta[] = (subscription, { isStorageVersioningEnabled = false } = {}) => {
const egressAttributes: CategoryAttribute[] = [
{
anchor: 'egress',
@@ -215,13 +198,23 @@ export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[
databaseAndStorageSizeAttributes.push({
anchor: 'storageSize',
key: PricingMetric.STORAGE_SIZE,
attributes: [{ key: PricingMetric.STORAGE_SIZE.toLowerCase(), color: 'white' }],
attributes: isStorageVersioningEnabled
? STORAGE_SIZE_SEGMENTS.map(({ attributeKey, name, color }) => ({
key: attributeKey,
name,
color,
}))
: [{ key: PricingMetric.STORAGE_SIZE.toLowerCase(), color: 'white' }],
name: 'Storage Size',
chartPrefix: 'Average',
unit: 'bytes',
description:
'Sum of all objects in your storage buckets.\nBilling is prorated down to the hour and will be displayed GB-Hrs.',
description: isStorageVersioningEnabled
? 'Sum of all objects in your storage buckets, including noncurrent objects retained by object versioning.\nBilling is prorated down to the hour and will be displayed GB-Hrs.'
: 'Sum of all objects in your storage buckets.\nBilling is prorated down to the hour and will be displayed GB-Hrs.',
chartDescription: 'The data refreshes every hour.',
...(isStorageVersioningEnabled && {
additionalInfo: () => <StorageRetentionBreakdown />,
}),
links: [
{
name: 'Storage',
@@ -11,7 +11,8 @@ import {
} from 'recharts'
import { cn } from 'ui'
import { Attribute, COLOR_MAP } from './Usage.constants'
import { COLOR_MAP } from './Usage.colors'
import { Attribute } from './Usage.constants'
import { MultiAttributeTooltipContent, SingleAttributeTooltipContent } from './UsageChartTooltips'
import { DataPoint } from '@/data/analytics/constants'
@@ -1,7 +1,8 @@
import type { Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent'
import { cn } from 'ui'
import { Attribute, COLOR_MAP } from './Usage.constants'
import { COLOR_MAP } from './Usage.colors'
import { Attribute } from './Usage.constants'
export interface SingleAttributeTooltipContentProps {
name: string
@@ -3,6 +3,7 @@ import { CategoryMetaKey, USAGE_CATEGORIES } from '../Usage.constants'
import AttributeUsage from './AttributeUsage'
import DatabaseSizeUsage from './DatabaseSizeUsage'
import { DiskUsage } from './DiskUsage'
import { useIsStorageVersioningEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ScaffoldContainer } from '@/components/layouts/Scaffold'
import { DataPoint } from '@/data/analytics/constants'
import { PricingMetric } from '@/data/analytics/org-daily-stats-query'
@@ -47,7 +48,9 @@ const UsageSection = ({
end: !currentBillingCycleSelected && endDate ? new Date(endDate) : undefined,
})
const categoryMeta = USAGE_CATEGORIES(subscription).find(
const isStorageVersioningEnabled = useIsStorageVersioningEnabled()
const categoryMeta = USAGE_CATEGORIES(subscription, { isStorageVersioningEnabled }).find(
(category) => category.key === categoryKey
)
if (!categoryMeta) return null
+3
View File
@@ -74,6 +74,9 @@ export const storageKeys = {
objectName,
...(lifecyclePolicy ? [lifecyclePolicy] : []),
] as const,
/** Org-scoped: storage retention is billed per organization, not per project. */
retentionUsage: (orgSlug: string | undefined) =>
['organizations', orgSlug, 'storage-retention-usage'] as const,
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
[projectRef, 'warehouse', warehouse, 'namespaces'] as const,
icebergNamespace: ({
@@ -0,0 +1,65 @@
import { queryOptions } from '@tanstack/react-query'
import { storageKeys } from '../keys'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
/** Storage bytes split by what is keeping them around. All values are bytes. */
export interface StorageRetentionTotals {
/** The version served when an object is fetched without a version ID. */
current: number
/**
* Everything still billable that isn't the current object: noncurrent versions
* plus the empty delete-marker placeholders behind archived files. Not split
* further — the platform can't reliably tell the two apart after the fact, and
* the user's lever for shrinking either is the same lifecycle policy.
*/
noncurrent: number
}
export interface StorageRetentionDayPoint extends StorageRetentionTotals {
date: string
}
export interface StorageRetentionBucketSummary extends StorageRetentionTotals {
bucket: string
}
export interface StorageRetentionUsage {
totals: StorageRetentionTotals
daily: StorageRetentionDayPoint[]
byBucket: StorageRetentionBucketSummary[]
}
export type StorageRetentionUsageVariables = {
/** Retention usage is billed per organization, not per project. */
orgSlug?: string
}
export type StorageRetentionUsageError = ResponseError
const EMPTY_USAGE: StorageRetentionUsage = {
totals: { current: 0, noncurrent: 0 },
daily: [],
byBucket: [],
}
async function getStorageRetentionUsage(
{ orgSlug }: StorageRetentionUsageVariables,
_signal?: AbortSignal
): Promise<StorageRetentionUsage> {
if (!orgSlug) throw new Error('orgSlug is required')
// TODO(storage-versioning): replace with the real endpoint once the platform
// reports retention usage. The breakdown renders zeros until then.
return EMPTY_USAGE
}
export type StorageRetentionUsageData = Awaited<ReturnType<typeof getStorageRetentionUsage>>
export const storageRetentionUsageQueryOptions = ({ orgSlug }: StorageRetentionUsageVariables) =>
queryOptions({
queryKey: storageKeys.retentionUsage(orgSlug),
queryFn: ({ signal }) => getStorageRetentionUsage({ orgSlug }, signal),
enabled: IS_PLATFORM && typeof orgSlug !== 'undefined',
})