diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index 7efa26803a2..317ada53d89 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -536,7 +536,7 @@ class AppAnnotationService: if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True) annotation_quota_limit = features.annotation_quota_limit - if annotation_quota_limit.limit < len(result) + annotation_quota_limit.size: + if 0 < annotation_quota_limit.limit < len(result) + annotation_quota_limit.size: raise ValueError("The number of annotations exceeds the limit of your subscription.") # async job job_id = str(uuid.uuid4()) diff --git a/api/tests/unit_tests/services/test_annotation_service.py b/api/tests/unit_tests/services/test_annotation_service.py index 8e5c305fa34..ac2a69b0f40 100644 --- a/api/tests/unit_tests/services/test_annotation_service.py +++ b/api/tests/unit_tests/services/test_annotation_service.py @@ -719,6 +719,29 @@ class TestAppAnnotationServiceBatchImport: "uuid-3", [{"question": "q1", "answer": "a1"}], app.id, TENANT_ID, current_user.id ) + @pytest.mark.parametrize("limit", [0, 2], ids=["unlimited", "exactly-at-limit"]) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + def test_import_with_available_quota_enqueues_job( + self, sqlite_session: Session, current_user: Account, limit: int + ) -> None: + app = _persist_app(sqlite_session) + features = SimpleNamespace(annotation_quota_limit=SimpleNamespace(limit=limit, size=1)) + with ( + patch.object(annotation_service_module.FeatureService, "get_features", return_value=features), + patch.object(annotation_service_module, "batch_import_annotations_task") as task, + patch.object(annotation_service_module, "redis_client"), + config_overrides_context(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1), + ): + result = AppAnnotationService.batch_import_app_annotations( + app.id, _file(b"question,answer\nq,a\n"), sqlite_session + ) + + assert result["job_status"] == "waiting" + assert result["record_count"] == 1 + task.delay.assert_called_once_with( + result["job_id"], [{"question": "q", "answer": "a"}], app.id, TENANT_ID, current_user.id + ) + @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) def test_unexpected_error_cleans_active_job( self, sqlite_session: Session, current_user: Account, caplog: pytest.LogCaptureFixture diff --git a/web/app/components/billing/__tests__/query-state.spec.tsx b/web/app/components/billing/__tests__/query-state.spec.tsx index 07b3084caee..1f26eba6520 100644 --- a/web/app/components/billing/__tests__/query-state.spec.tsx +++ b/web/app/components/billing/__tests__/query-state.spec.tsx @@ -1,10 +1,13 @@ -import { act, render, screen, within } from '@testing-library/react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ModalContextProvider } from '@/context/modal-context-provider' import { consoleQuery } from '@/service/client' import { createConsoleQueryClient, createConsoleQueryWrapper, seedFeatures, } from '@/test/console/query-data' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import AnnotationUsage from '../annotation-full/usage' import Billing from '../billing-page' @@ -75,3 +78,73 @@ it('renders annotation usage only from returned data and preserves zero as an un expect(within(annotation).getByText('4')).toBeInTheDocument() expect(within(annotation).getByText('billing.plansCommon.unlimited')).toBeInTheDocument() }) + +it.each([ + { limit: 0, usage: 0, percent: 100 }, + { limit: -1, usage: 25, percent: 0 }, + { limit: 100, usage: 25, percent: 25 }, + { limit: 100, usage: 100, percent: 100 }, +])( + 'renders event and API quotas with limit $limit and usage $usage', + async ({ limit, usage, percent }) => { + const { queryClient, wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: 'CLOUD' }, + features: { + trigger_event: { limit, usage }, + api_rate_limit: { limit, usage }, + apps: { size: 4, limit: 0 }, + }, + }) + queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryKey(), { + size: 256, + limit: 900, + usage_unknown: false, + }) + render(, { wrapper }) + + for (const name of ['billing.usagePage.triggerEvents', 'billing.plansCommon.apiRateLimit']) { + const quota = within(await screen.findByRole('group', { name })) + expect(quota.getByTestId('billing-quota-value')).toHaveTextContent( + `${usage}/${limit === -1 ? 'billing.plansCommon.unlimited' : limit}`, + ) + expect(quota.getByRole('meter', { name })).toHaveAttribute('aria-valuenow', String(percent)) + if (limit !== -1) + expect(quota.queryByText('billing.plansCommon.unlimited')).not.toBeInTheDocument() + } + + const apps = within(screen.getByRole('group', { name: 'billing.usagePage.buildApps' })) + expect(apps.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() + }, +) + +it('shows a dismissible limit dialog when the workspace receives a zero event quota', async () => { + localStorage.clear() + const user = userEvent.setup() + const { queryClient, wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: 'CLOUD' }, + currentWorkspace: { id: 'workspace-zero-quota' }, + }) + const { wrapper: NuqsWrapper } = createNuqsTestWrapper() + render( + + + Workspace + + , + { wrapper }, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await act(async () => { + seedFeatures(queryClient, { + billing: { subscription: { plan: 'professional' } }, + trigger_event: { limit: 0, usage: 0, reset_date: -1 }, + }) + }) + const dialog = await screen.findByRole('dialog', { name: 'billing.triggerLimitModal.title' }) + expect(within(dialog).getByTestId('billing-quota-value')).toHaveTextContent('0/0') + await user.click( + within(dialog).getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }), + ) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) +}) diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 783693472a4..6a4b83aadc0 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -19,7 +19,7 @@ import { NUM_INFINITE } from '../config' import { useEducationDiscount } from '../hooks/use-education-discount' import UpgradeBtn from '../upgrade-btn' import VectorSpaceInfo from '../usage-info/vector-space-info' -import { getResetInDaysFromDate, parseLimit, parseRateLimit } from '../utils' +import { getResetInDaysFromDate, parseLimit } from '../utils' import { Professional, Sandbox, Team } from './assets' type Props = Readonly<{ @@ -49,8 +49,8 @@ const PlanComp: FC = ({ loc }) => { ) const { isAboutToExpire = false, isEducationAccount = false } = educationStatus ?? {} const type = features.billing.subscription.plan - const triggerEventsLimit = parseRateLimit(features.trigger_event.limit) - const apiRateLimit = parseRateLimit(features.api_rate_limit.limit) + const triggerEventsLimit = features.trigger_event.limit + const apiRateLimit = features.api_rate_limit.limit const apiRateLimitReset = getResetInDaysFromDate(features.api_rate_limit.reset_date) const triggerEventsResetInDays = type === 'professional' && triggerEventsLimit !== NUM_INFINITE diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index d71507ca02c..3aed8589b50 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -52,11 +52,10 @@ const UsageInfo: FC = ({ const isBelowThreshold = !usageUnknown && storageMode && usage < storageThreshold const isSandboxFull = !usageUnknown && storageMode && isSandboxPlan && usage >= storageThreshold - // Single source of truth: sandbox full is visually clamped to 100%; all other - // determinate cases show the real percent capped at 100. Tone derives from - // this, so we never need a separate tone override. + // Zero count quotas have no remaining capacity; storage keeps its separate limit convention. + const isZeroQuota = !storageMode && total === 0 const rawPercent = total > 0 ? (usage / total) * 100 : 0 - const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100) + const effectivePercent = isSandboxFull || isZeroQuota ? 100 : Math.min(rawPercent, 100) const tone: MeterTone = effectivePercent >= 100 ? 'error' : effectivePercent >= 80 ? 'warning' : 'neutral' diff --git a/web/app/components/billing/utils/__tests__/index.spec.ts b/web/app/components/billing/utils/__tests__/index.spec.ts index e910ac13abd..d728b1bf8a9 100644 --- a/web/app/components/billing/utils/__tests__/index.spec.ts +++ b/web/app/components/billing/utils/__tests__/index.spec.ts @@ -2,7 +2,6 @@ import { getPlanVectorSpaceLimitMB, getResetInDaysFromDate, parseLimit, - parseRateLimit, parseVectorSpaceToMB, } from '../index' @@ -71,10 +70,5 @@ describe('billing utils', () => { expect(parseLimit(0)).toBe(-1) expect(parseLimit(10)).toBe(10) }) - it('preserves unlimited rate limits in either API representation', () => { - expect(parseRateLimit(0)).toBe(-1) - expect(parseRateLimit(-1)).toBe(-1) - expect(parseRateLimit(5000)).toBe(5000) - }) }) }) diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index ce8791d49ae..740da620893 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -23,19 +23,14 @@ export const getPlanVectorSpaceLimitMB = (planType: CloudPlan): number => { return parseVectorSpaceToMB(ALL_PLANS[planType].vectorSpace) } -// The API uses 0 for unlimited count quotas. +// App, member, document upload, and annotation quotas use 0 for unlimited. +// Event and API quotas use -1 for unlimited and must preserve 0 as zero capacity. export const parseLimit = (limit: number) => { if (limit === 0) return NUM_INFINITE return limit } -export const parseRateLimit = (limit: number) => { - if (limit === 0 || limit === -1) return NUM_INFINITE - - return limit -} - const normalizeResetDate = (resetDate: number) => { if (resetDate <= 0) return null diff --git a/web/context/hooks/use-trigger-events-limit-modal.ts b/web/context/hooks/use-trigger-events-limit-modal.ts index da90ee8ae41..0ea3aa44c1f 100644 --- a/web/context/hooks/use-trigger-events-limit-modal.ts +++ b/web/context/hooks/use-trigger-events-limit-modal.ts @@ -48,7 +48,7 @@ export const useTriggerEventsLimitModal = (): UseTriggerEventsLimitModalResult = currentWorkspaceId && quota && quota.plan !== 'team' && - quota.limit > 0 && + quota.limit >= 0 && quota.usage >= quota.limit ? `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${quota.plan}-${quota.limit}-${cycleTag}` : null