diff --git a/web/app/components/billing/progress-bar/index.spec.tsx b/web/app/components/billing/progress-bar/index.spec.tsx index a9c91468de..4eb66dcf79 100644 --- a/web/app/components/billing/progress-bar/index.spec.tsx +++ b/web/app/components/billing/progress-bar/index.spec.tsx @@ -2,24 +2,61 @@ import { render, screen } from '@testing-library/react' import ProgressBar from './index' describe('ProgressBar', () => { - it('renders with provided percent and color', () => { - render() + describe('Normal Mode (determinate)', () => { + it('renders with provided percent and color', () => { + render() - const bar = screen.getByTestId('billing-progress-bar') - expect(bar).toHaveClass('bg-test-color') - expect(bar.getAttribute('style')).toContain('width: 42%') + const bar = screen.getByTestId('billing-progress-bar') + expect(bar).toHaveClass('bg-test-color') + expect(bar.getAttribute('style')).toContain('width: 42%') + }) + + it('caps width at 100% when percent exceeds max', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar') + expect(bar.getAttribute('style')).toContain('width: 100%') + }) + + it('uses the default color when no color prop is provided', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar') + expect(bar).toHaveClass('bg-components-progress-bar-progress-solid') + expect(bar.getAttribute('style')).toContain('width: 20%') + }) }) - it('caps width at 100% when percent exceeds max', () => { - render() + describe('Indeterminate Mode', () => { + it('should render indeterminate progress bar when indeterminate is true', () => { + render() - const bar = screen.getByTestId('billing-progress-bar') - expect(bar.getAttribute('style')).toContain('width: 100%') - }) + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toBeInTheDocument() + expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe') + }) - it('uses the default color when no color prop is provided', () => { - render() + it('should not render normal progress bar when indeterminate is true', () => { + render() - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF') + expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument() + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should render with default width (w-[30px]) when indeterminateFull is false', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-[30px]') + expect(bar).not.toHaveClass('w-full') + }) + + it('should render with full width (w-full) when indeterminateFull is true', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-full') + expect(bar).not.toHaveClass('w-[30px]') + }) }) }) diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx index c41fc53310..f16bd952ea 100644 --- a/web/app/components/billing/progress-bar/index.tsx +++ b/web/app/components/billing/progress-bar/index.tsx @@ -3,12 +3,27 @@ import { cn } from '@/utils/classnames' type ProgressBarProps = { percent: number color: string + indeterminate?: boolean + indeterminateFull?: boolean // For Sandbox users: full width stripe } const ProgressBar = ({ percent = 0, - color = '#2970FF', + color = 'bg-components-progress-bar-progress-solid', + indeterminate = false, + indeterminateFull = false, }: ProgressBarProps) => { + if (indeterminate) { + return ( +
+
+
+ ) + } + return (
describe('UsageInfo', () => { - it('renders the metric with a suffix unit and tooltip text', () => { - render( - , - ) + describe('Default Mode (non-storage)', () => { + it('renders the metric with a suffix unit and tooltip text', () => { + render( + , + ) - expect(screen.getByTestId('usage-icon')).toBeInTheDocument() - expect(screen.getByText('Apps')).toBeInTheDocument() - expect(screen.getByText('30')).toBeInTheDocument() - expect(screen.getByText('100')).toBeInTheDocument() - expect(screen.getByText('GB')).toBeInTheDocument() + expect(screen.getByTestId('usage-icon')).toBeInTheDocument() + expect(screen.getByText('Apps')).toBeInTheDocument() + expect(screen.getByText('30')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('GB')).toBeInTheDocument() + }) + + it('renders inline unit when unitPosition is inline', () => { + render( + , + ) + + expect(screen.getByText('100GB')).toBeInTheDocument() + }) + + it('shows reset hint text instead of the unit when resetHint is provided', () => { + const resetHint = 'Resets in 3 days' + render( + , + ) + + expect(screen.getByText(resetHint)).toBeInTheDocument() + expect(screen.queryByText('GB')).not.toBeInTheDocument() + }) + + it('displays unlimited text when total is infinite', () => { + render( + , + ) + + expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() + }) + + it('applies warning color when usage is close to the limit', () => { + render( + , + ) + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-warning-progress') + }) + + it('applies error color when usage exceeds the limit', () => { + render( + , + ) + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + + it('does not render the icon when hideIcon is true', () => { + render( + , + ) + + expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument() + }) }) - it('renders inline unit when unitPosition is inline', () => { - render( - , - ) + describe('Storage Mode', () => { + describe('Below Threshold', () => { + it('should render indeterminate progress bar when usage is below threshold', () => { + render( + , + ) - expect(screen.getByText('100GB')).toBeInTheDocument() - }) + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument() + }) - it('shows reset hint text instead of the unit when resetHint is provided', () => { - const resetHint = 'Resets in 3 days' - render( - , - ) + it('should display "< threshold" format when usage is below threshold (non-sandbox)', () => { + render( + , + ) - expect(screen.getByText(resetHint)).toBeInTheDocument() - expect(screen.queryByText('GB')).not.toBeInTheDocument() - }) + // Text "< 50" is rendered inside a single span + expect(screen.getByText(/< 50/)).toBeInTheDocument() + expect(screen.getByText('5120MB')).toBeInTheDocument() + }) - it('displays unlimited text when total is infinite', () => { - render( - , - ) + it('should display "< threshold unit" format when usage is below threshold (sandbox)', () => { + render( + , + ) - expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() - }) + // Text "< 50" is rendered inside a single span + expect(screen.getByText(/< 50/)).toBeInTheDocument() + // Unit "MB" appears in the display + expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1) + }) - it('applies warning color when usage is close to the limit', () => { - render( - , - ) + it('should render full-width indeterminate bar for sandbox users below threshold', () => { + render( + , + ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-full') + }) - it('applies error color when usage exceeds the limit', () => { - render( - , - ) + it('should render narrow indeterminate bar for non-sandbox users below threshold', () => { + render( + , + ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') - }) + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-[30px]') + }) + }) - it('does not render the icon when hideIcon is true', () => { - render( - , - ) + describe('Sandbox Full Capacity', () => { + it('should render error color progress bar when sandbox usage >= threshold', () => { + render( + , + ) - expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument() + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + + it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => { + render( + , + ) + + // First span: "50", Third span: "50 MB" + expect(screen.getByText('50')).toBeInTheDocument() + expect(screen.getByText(/50 MB/)).toBeInTheDocument() + expect(screen.getByText('/')).toBeInTheDocument() + }) + }) + + describe('Pro/Team Users Above Threshold', () => { + it('should render normal progress bar when usage >= threshold', () => { + render( + , + ) + + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + }) + + it('should display actual usage when usage >= threshold', () => { + render( + , + ) + + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('5120MB')).toBeInTheDocument() + }) + }) + + describe('Storage Tooltip', () => { + it('should render tooltip wrapper when storageTooltip is provided', () => { + const { container } = render( + , + ) + + // Tooltip wrapper should contain cursor-default class + const tooltipWrapper = container.querySelector('.cursor-default') + expect(tooltipWrapper).toBeInTheDocument() + }) + }) }) }) diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index 8f0c1bcbcc..f820b85eab 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import type { ComponentType, FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Tooltip from '@/app/components/base/tooltip' @@ -9,7 +9,7 @@ import ProgressBar from '../progress-bar' type Props = { className?: string - Icon: any + Icon: ComponentType<{ className?: string }> name: string tooltip?: string usage: number @@ -19,6 +19,11 @@ type Props = { resetHint?: string resetInDays?: number hideIcon?: boolean + // Props for the 50MB threshold display logic + storageMode?: boolean + storageThreshold?: number + storageTooltip?: string + isSandboxPlan?: boolean } const WARNING_THRESHOLD = 80 @@ -35,30 +40,141 @@ const UsageInfo: FC = ({ resetHint, resetInDays, hideIcon = false, + storageMode = false, + storageThreshold = 50, + storageTooltip, + isSandboxPlan = false, }) => { const { t } = useTranslation() + // Special display logic for usage below threshold (only in storage mode) + const isBelowThreshold = storageMode && usage < storageThreshold + // Sandbox at full capacity (usage >= threshold and it's sandbox plan) + const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold + const percent = usage / total * 100 - const color = percent >= 100 - ? 'bg-components-progress-error-progress' - : (percent >= WARNING_THRESHOLD ? 'bg-components-progress-warning-progress' : 'bg-components-progress-bar-progress-solid') + const getProgressColor = () => { + if (percent >= 100) + return 'bg-components-progress-error-progress' + if (percent >= WARNING_THRESHOLD) + return 'bg-components-progress-warning-progress' + return 'bg-components-progress-bar-progress-solid' + } + const color = getProgressColor() const isUnlimited = total === NUM_INFINITE let totalDisplay: string | number = isUnlimited ? t('plansCommon.unlimited', { ns: 'billing' }) : total if (!isUnlimited && unit && unitPosition === 'inline') totalDisplay = `${total}${unit}` const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix' const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('usagePage.resetsIn', { ns: 'billing', count: resetInDays }) : undefined) - const rightInfo = resetText - ? ( + + const renderRightInfo = () => { + if (resetText) { + return (
{resetText}
) - : (showUnit && ( + } + if (showUnit) { + return (
{unit}
- )) + ) + } + return null + } + + // Render usage display + const renderUsageDisplay = () => { + // Storage mode: special display logic + if (storageMode) { + // Sandbox user at full capacity + if (isSandboxFull) { + return ( +
+ + {storageThreshold} + + / + + {storageThreshold} + {' '} + {unit} + +
+ ) + } + // Usage below threshold - show "< 50 MB" or "< 50 / 5GB" + if (isBelowThreshold) { + return ( +
+ + < + {' '} + {storageThreshold} + + {!isSandboxPlan && ( + <> + / + {totalDisplay} + + )} + {isSandboxPlan && {unit}} +
+ ) + } + // Pro/Team users with usage >= threshold - show actual usage + return ( +
+ {usage} + / + {totalDisplay} +
+ ) + } + + // Default display (storageMode = false) + return ( +
+ {usage} + / + {totalDisplay} +
+ ) + } + + const renderWithTooltip = (children: React.ReactNode) => { + if (storageMode && storageTooltip) { + return ( + {storageTooltip}
} + asChild={false} + > +
{children}
+ + ) + } + return children + } + + // Render progress bar with optional tooltip wrapper + const renderProgressBar = () => { + const progressBar = ( + + ) + return renderWithTooltip(progressBar) + } + + const renderUsageWithTooltip = () => { + return renderWithTooltip(renderUsageDisplay()) + } return (
@@ -78,17 +194,10 @@ const UsageInfo: FC = ({ )}
-
- {usage} -
/
-
{totalDisplay}
-
- {rightInfo} + {renderUsageWithTooltip()} + {renderRightInfo()}
- + {renderProgressBar()}
) } diff --git a/web/app/components/billing/usage-info/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/vector-space-info.spec.tsx new file mode 100644 index 0000000000..a811cc9a09 --- /dev/null +++ b/web/app/components/billing/usage-info/vector-space-info.spec.tsx @@ -0,0 +1,305 @@ +import { render, screen } from '@testing-library/react' +import { defaultPlan } from '../config' +import { Plan } from '../type' +import VectorSpaceInfo from './vector-space-info' + +// Mock provider context with configurable plan +let mockPlanType = Plan.sandbox +let mockVectorSpaceUsage = 30 +let mockVectorSpaceTotal = 5120 + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { + ...defaultPlan, + type: mockPlanType, + usage: { + ...defaultPlan.usage, + vectorSpace: mockVectorSpaceUsage, + }, + total: { + ...defaultPlan.total, + vectorSpace: mockVectorSpaceTotal, + }, + }, + }), +})) + +describe('VectorSpaceInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset to default values + mockPlanType = Plan.sandbox + mockVectorSpaceUsage = 30 + mockVectorSpaceTotal = 5120 + }) + + describe('Rendering', () => { + it('should render vector space info component', () => { + render() + + expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument() + }) + + it('should apply custom className', () => { + render() + + const container = screen.getByText('billing.usagePage.vectorSpace').closest('.custom-class') + expect(container).toBeInTheDocument() + }) + }) + + describe('Sandbox Plan', () => { + beforeEach(() => { + mockPlanType = Plan.sandbox + mockVectorSpaceUsage = 30 + }) + + it('should render indeterminate progress bar when usage is below threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should render full-width indeterminate bar for sandbox users', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-full') + }) + + it('should display "< 50" format for sandbox below threshold', () => { + render() + + expect(screen.getByText(/< 50/)).toBeInTheDocument() + }) + }) + + describe('Sandbox Plan at Full Capacity', () => { + beforeEach(() => { + mockPlanType = Plan.sandbox + mockVectorSpaceUsage = 50 + }) + + it('should render error color progress bar when at full capacity', () => { + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + + it('should display "50 / 50 MB" format when at full capacity', () => { + render() + + expect(screen.getByText('50')).toBeInTheDocument() + expect(screen.getByText(/50 MB/)).toBeInTheDocument() + }) + }) + + describe('Professional Plan', () => { + beforeEach(() => { + mockPlanType = Plan.professional + mockVectorSpaceUsage = 30 + }) + + it('should render indeterminate progress bar when usage is below threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should render narrow indeterminate bar (not full width)', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-[30px]') + expect(bar).not.toHaveClass('w-full') + }) + + it('should display "< 50 / total" format when below threshold', () => { + render() + + expect(screen.getByText(/< 50/)).toBeInTheDocument() + // 5 GB = 5120 MB + expect(screen.getByText('5120MB')).toBeInTheDocument() + }) + }) + + describe('Professional Plan Above Threshold', () => { + beforeEach(() => { + mockPlanType = Plan.professional + mockVectorSpaceUsage = 100 + }) + + it('should render normal progress bar when usage >= threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + }) + + it('should display actual usage when above threshold', () => { + render() + + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('5120MB')).toBeInTheDocument() + }) + }) + + describe('Team Plan', () => { + beforeEach(() => { + mockPlanType = Plan.team + mockVectorSpaceUsage = 30 + }) + + it('should render indeterminate progress bar when usage is below threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should render narrow indeterminate bar (not full width)', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-[30px]') + expect(bar).not.toHaveClass('w-full') + }) + + it('should display "< 50 / total" format when below threshold', () => { + render() + + expect(screen.getByText(/< 50/)).toBeInTheDocument() + // 20 GB = 20480 MB + expect(screen.getByText('20480MB')).toBeInTheDocument() + }) + }) + + describe('Team Plan Above Threshold', () => { + beforeEach(() => { + mockPlanType = Plan.team + mockVectorSpaceUsage = 100 + }) + + it('should render normal progress bar when usage >= threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + }) + + it('should display actual usage when above threshold', () => { + render() + + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('20480MB')).toBeInTheDocument() + }) + }) + + describe('Pro/Team Plan Warning State', () => { + it('should show warning color when Professional plan usage approaches limit (80%+)', () => { + mockPlanType = Plan.professional + // 5120 MB * 80% = 4096 MB + mockVectorSpaceUsage = 4100 + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-warning-progress') + }) + + it('should show warning color when Team plan usage approaches limit (80%+)', () => { + mockPlanType = Plan.team + // 20480 MB * 80% = 16384 MB + mockVectorSpaceUsage = 16500 + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-warning-progress') + }) + }) + + describe('Pro/Team Plan Error State', () => { + it('should show error color when Professional plan usage exceeds limit', () => { + mockPlanType = Plan.professional + // Exceeds 5120 MB + mockVectorSpaceUsage = 5200 + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + + it('should show error color when Team plan usage exceeds limit', () => { + mockPlanType = Plan.team + // Exceeds 20480 MB + mockVectorSpaceUsage = 21000 + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + }) + + describe('Enterprise Plan (default case)', () => { + beforeEach(() => { + mockPlanType = Plan.enterprise + mockVectorSpaceUsage = 30 + // Enterprise plan uses total.vectorSpace from context + mockVectorSpaceTotal = 102400 // 100 GB = 102400 MB + }) + + it('should use total.vectorSpace from context for enterprise plan', () => { + render() + + // Enterprise plan should use the mockVectorSpaceTotal value (102400MB) + expect(screen.getByText('102400MB')).toBeInTheDocument() + }) + + it('should render indeterminate progress bar when usage is below threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should render narrow indeterminate bar (not full width) for enterprise', () => { + render() + + const bar = screen.getByTestId('billing-progress-bar-indeterminate') + expect(bar).toHaveClass('w-[30px]') + expect(bar).not.toHaveClass('w-full') + }) + + it('should display "< 50 / total" format when below threshold', () => { + render() + + expect(screen.getByText(/< 50/)).toBeInTheDocument() + expect(screen.getByText('102400MB')).toBeInTheDocument() + }) + }) + + describe('Enterprise Plan Above Threshold', () => { + beforeEach(() => { + mockPlanType = Plan.enterprise + mockVectorSpaceUsage = 100 + mockVectorSpaceTotal = 102400 // 100 GB + }) + + it('should render normal progress bar when usage >= threshold', () => { + render() + + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() + }) + + it('should display actual usage when above threshold', () => { + render() + + expect(screen.getByText('100')).toBeInTheDocument() + expect(screen.getByText('102400MB')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/billing/usage-info/vector-space-info.tsx b/web/app/components/billing/usage-info/vector-space-info.tsx index 11e3a6a1ae..e384ef4d9a 100644 --- a/web/app/components/billing/usage-info/vector-space-info.tsx +++ b/web/app/components/billing/usage-info/vector-space-info.tsx @@ -1,26 +1,44 @@ 'use client' import type { FC } from 'react' +import type { BasicPlan } from '../type' import { RiHardDrive3Line, } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' +import { Plan } from '../type' import UsageInfo from '../usage-info' +import { getPlanVectorSpaceLimitMB } from '../utils' type Props = { className?: string } +// Storage threshold in MB - usage below this shows as "< 50 MB" +const STORAGE_THRESHOLD_MB = getPlanVectorSpaceLimitMB(Plan.sandbox) + const VectorSpaceInfo: FC = ({ className, }) => { const { t } = useTranslation() const { plan } = useProviderContext() const { + type, usage, total, } = plan + + // Determine total based on plan type (in MB), derived from ALL_PLANS config + const getTotalInMB = () => { + const planLimit = getPlanVectorSpaceLimitMB(type as BasicPlan) + // For known plans, use the config value; otherwise fall back to API response + return planLimit > 0 ? planLimit : total.vectorSpace + } + + const totalInMB = getTotalInMB() + const isSandbox = type === Plan.sandbox + return ( = ({ name={t('usagePage.vectorSpace', { ns: 'billing' })} tooltip={t('usagePage.vectorSpaceTooltip', { ns: 'billing' }) as string} usage={usage.vectorSpace} - total={total.vectorSpace} + total={totalInMB} unit="MB" unitPosition="inline" + storageMode + storageThreshold={STORAGE_THRESHOLD_MB} + storageTooltip={t('usagePage.storageThresholdTooltip', { ns: 'billing' }) as string} + isSandboxPlan={isSandbox} /> ) } diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index e7192ec351..39fc0cd7b5 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -1,7 +1,33 @@ -import type { BillingQuota, CurrentPlanInfoBackend } from '../type' +import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type' import dayjs from 'dayjs' import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config' +/** + * Parse vectorSpace string from ALL_PLANS config and convert to MB + * @example "50MB" -> 50, "5GB" -> 5120, "20GB" -> 20480 + */ +export const parseVectorSpaceToMB = (vectorSpace: string): number => { + const match = vectorSpace.match(/^(\d+)(MB|GB)$/i) + if (!match) + return 0 + + const value = Number.parseInt(match[1], 10) + const unit = match[2].toUpperCase() + + return unit === 'GB' ? value * 1024 : value +} + +/** + * Get the vector space limit in MB for a given plan type from ALL_PLANS config + */ +export const getPlanVectorSpaceLimitMB = (planType: BasicPlan): number => { + const planInfo = ALL_PLANS[planType] + if (!planInfo) + return 0 + + return parseVectorSpaceToMB(planInfo.vectorSpace) +} + const parseLimit = (limit: number) => { if (limit === 0) return NUM_INFINITE diff --git a/web/app/components/billing/vector-space-full/index.spec.tsx b/web/app/components/billing/vector-space-full/index.spec.tsx index 0382ec0872..375ac54c22 100644 --- a/web/app/components/billing/vector-space-full/index.spec.tsx +++ b/web/app/components/billing/vector-space-full/index.spec.tsx @@ -21,6 +21,18 @@ vi.mock('../upgrade-btn', () => ({ default: () => , })) +// Mock utils to control threshold and plan limits +vi.mock('../utils', () => ({ + getPlanVectorSpaceLimitMB: (planType: string) => { + // Return 5 for sandbox (threshold) and 100 for team + if (planType === 'sandbox') + return 5 + if (planType === 'team') + return 100 + return 0 + }, +})) + describe('VectorSpaceFull', () => { const planMock = { type: 'team', @@ -52,6 +64,6 @@ describe('VectorSpaceFull', () => { render() expect(screen.getByText('8')).toBeInTheDocument() - expect(screen.getByText('10MB')).toBeInTheDocument() + expect(screen.getByText('100MB')).toBeInTheDocument() }) }) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 4f274115c8..e430ea6739 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1559,11 +1559,6 @@ "count": 3 } }, - "app/components/billing/usage-info/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/custom/custom-web-app-brand/index.spec.tsx": { "ts/no-explicit-any": { "count": 7 diff --git a/web/i18n/en-US/billing.json b/web/i18n/en-US/billing.json index 3242aa8e78..bfd82e1d67 100644 --- a/web/i18n/en-US/billing.json +++ b/web/i18n/en-US/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "Documents Upload Quota", "usagePage.perMonth": "per month", "usagePage.resetsIn": "Resets in {{count,number}} days", + "usagePage.storageThresholdTooltip": "Detailed usage is shown once storage exceeds 50 MB.", "usagePage.teamMembers": "Team Members", "usagePage.triggerEvents": "Trigger Events", "usagePage.vectorSpace": "Knowledge Data Storage", diff --git a/web/i18n/ja-JP/billing.json b/web/i18n/ja-JP/billing.json index bf2f496428..fe38244f75 100644 --- a/web/i18n/ja-JP/billing.json +++ b/web/i18n/ja-JP/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "ドキュメント・アップロード・クォータ", "usagePage.perMonth": "月あたり", "usagePage.resetsIn": "{{count,number}}日後にリセット", + "usagePage.storageThresholdTooltip": "ストレージ使用量が 50 MB を超えると、詳細な使用状況が表示されます。", "usagePage.teamMembers": "チームメンバー", "usagePage.triggerEvents": "トリガーイベント数", "usagePage.vectorSpace": "ナレッジベースのデータストレージ", diff --git a/web/i18n/zh-Hans/billing.json b/web/i18n/zh-Hans/billing.json index 6f976f620b..b3d06febc8 100644 --- a/web/i18n/zh-Hans/billing.json +++ b/web/i18n/zh-Hans/billing.json @@ -172,6 +172,7 @@ "usagePage.documentsUploadQuota": "文档上传配额", "usagePage.perMonth": "每月", "usagePage.resetsIn": "{{count,number}} 天后重置", + "usagePage.storageThresholdTooltip": "存储空间超过 50 MB 后,将显示详细使用情况。", "usagePage.teamMembers": "团队成员", "usagePage.triggerEvents": "触发器事件数", "usagePage.vectorSpace": "知识库数据存储空间", diff --git a/web/tailwind-common-config.ts b/web/tailwind-common-config.ts index 304be919fa..05aabfc2f1 100644 --- a/web/tailwind-common-config.ts +++ b/web/tailwind-common-config.ts @@ -139,6 +139,7 @@ const config = { 'billing-plan-card-premium-bg': 'var(--color-billing-plan-card-premium-bg)', 'billing-plan-card-enterprise-bg': 'var(--color-billing-plan-card-enterprise-bg)', 'knowledge-pipeline-creation-footer-bg': 'var(--color-knowledge-pipeline-creation-footer-bg)', + 'progress-bar-indeterminate-stripe': 'var(--color-progress-bar-indeterminate-stripe)', }, animation: { 'spin-slow': 'spin 2s linear infinite', diff --git a/web/themes/manual-dark.css b/web/themes/manual-dark.css index 867e2fe01d..eb33f93030 100644 --- a/web/themes/manual-dark.css +++ b/web/themes/manual-dark.css @@ -74,4 +74,5 @@ html[data-theme="dark"] { --color-billing-plan-card-premium-bg: linear-gradient(180deg, #F90 0%, rgba(255, 153, 0, 0.00) 100%); --color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%); --color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, rgba(34, 34, 37, 1) 4.89%, rgba(0, 0, 0, 0) 100%); + --color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #3A3A40, #3A3A40 2px, transparent 2px, transparent 5px); } diff --git a/web/themes/manual-light.css b/web/themes/manual-light.css index 3487153246..62ff4b2178 100644 --- a/web/themes/manual-light.css +++ b/web/themes/manual-light.css @@ -74,4 +74,5 @@ html[data-theme="light"] { --color-billing-plan-card-premium-bg: linear-gradient(180deg, #F90 0%, rgba(255, 153, 0, 0.00) 100%); --color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%); --color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, #FCFCFD 4.89%, rgba(255, 255, 255, 0.00) 100%); + --color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #D0D5DD, #D0D5DD 2px, transparent 2px, transparent 5px); }