diff --git a/apps/studio/components/interfaces/Organization/Usage/Compute.tsx b/apps/studio/components/interfaces/Organization/Usage/Compute.tsx index e32af374af8..6794c1b76f7 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Compute.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Compute.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx b/apps/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx index 172f8c25653..87995b8e6bc 100644 --- a/apps/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/SizeAndCounts.tsx @@ -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, diff --git a/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.constants.ts b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.constants.ts new file mode 100644 index 00000000000..f384cb11764 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.constants.ts @@ -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', + }, +] diff --git a/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.test.ts b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.test.ts new file mode 100644 index 00000000000..c3226f8e79d --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.test.ts @@ -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 => ({ + 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) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.ts b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.ts new file mode 100644 index 00000000000..cf8add46275 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetention.utils.ts @@ -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, + } + }) diff --git a/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetentionBreakdown.tsx b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetentionBreakdown.tsx new file mode 100644 index 00000000000..23319163986 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/StorageRetention/StorageRetentionBreakdown.tsx @@ -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 ( +
+ + +
+ ) + } + if (!isSuccess) return null + + const { totals, byBucket } = data + const bucketsWithNoncurrentData = byBucket.filter((bucket) => bucket.noncurrent > 0) + + return ( +
+
+

What’s driving this

+ {STORAGE_SIZE_SEGMENTS.map((segment) => ( +
+
+
+

{segment.name}

+
+

{formatBytes(totals[segment.totalsKey])}

+
+ ))} +
+

Total

+

{formatBytes(totals.current + totals.noncurrent)}

+
+
+ + {totals.noncurrent > 0 && ( + +

+ 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. +

+
+ )} + + {bucketsWithNoncurrentData.length > 0 && ( +
+

Noncurrent data by bucket

+ {bucketsWithNoncurrentData.map((bucket) => ( +
+

{bucket.bucket}

+

{formatBytes(bucket.noncurrent)}

+
+ ))} +

+ Retention is set per bucket, in the bucket’s settings. +

+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.colors.ts b/apps/studio/components/interfaces/Organization/Usage/Usage.colors.ts new file mode 100644 index 00000000000..5ec6e4432f6 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.colors.ts @@ -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 diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index 5ff78780113..bb3a010bf86 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -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: () => , + }), links: [ { name: 'Storage', diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx index b5bf8e2c3a0..05837a1f4b4 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageBarChart.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx index 9f22ae40884..1953650d40d 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageChartTooltips.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx index c0a9747ac31..7a9c5834811 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx @@ -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 diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 1821bae4b00..f2a63b86500 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -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: ({ diff --git a/apps/studio/data/storage/versioning/storage-retention-usage-query.ts b/apps/studio/data/storage/versioning/storage-retention-usage-query.ts new file mode 100644 index 00000000000..85d25ec76b2 --- /dev/null +++ b/apps/studio/data/storage/versioning/storage-retention-usage-query.ts @@ -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 { + 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> + +export const storageRetentionUsageQueryOptions = ({ orgSlug }: StorageRetentionUsageVariables) => + queryOptions({ + queryKey: storageKeys.retentionUsage(orgSlug), + queryFn: ({ signal }) => getStorageRetentionUsage({ orgSlug }, signal), + enabled: IS_PLATFORM && typeof orgSlug !== 'undefined', + })