From 93ef9e2cc2748f146b4ef41c023665acd868387f Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Fri, 17 Apr 2026 12:10:52 -0400 Subject: [PATCH] Default throughput bucket (#2460) * default throughput bucket * nit * show inactive buckets * add e2e tests for default throughput bucket * for test sql containers, use throughput of 4000 * remove container throughput on creation * added offer throughput * add default throughput bucket info link * add text localization * upgrade playwright * Fix flaky permissionsScreen test by using unrouteAll with ignoreErrors * fix: move container creation to beforeAll to reduce CI shard timeout * remove comment --------- Co-authored-by: Asier Isayas --- package-lock.json | 24 ++-- package.json | 2 +- src/Contracts/DataModels.ts | 1 + .../ThroughputBucketsComponent.test.tsx | 44 +++--- .../ThroughputBucketsComponent.tsx | 129 ++++++++++++++++-- src/Localization/en/Resources.json | 9 +- .../containercopy/permissionsScreen.spec.ts | 2 +- .../computedProperties.spec.ts | 4 +- .../scaleAndSettings/throughputbucket.spec.ts | 108 +++++++++++++++ test/testData.ts | 13 +- 10 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 test/sql/scaleAndSettings/throughputbucket.spec.ts diff --git a/package-lock.json b/package-lock.json index 9665339c8..7ed606991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,7 +130,7 @@ "@babel/preset-env": "7.24.7", "@babel/preset-react": "7.24.7", "@babel/preset-typescript": "7.24.7", - "@playwright/test": "1.55.1", + "@playwright/test": "1.59.1", "@testing-library/react": "11.2.3", "@types/applicationinsights-js": "1.0.7", "@types/codemirror": "0.0.56", @@ -8585,12 +8585,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", - "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "dependencies": { - "playwright": "1.55.1" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -23953,12 +23953,12 @@ "dev": true }, "node_modules/playwright": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", - "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "dependencies": { - "playwright-core": "1.55.1" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -23971,9 +23971,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", - "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index d952483f9..116612d6a 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "@babel/preset-env": "7.24.7", "@babel/preset-react": "7.24.7", "@babel/preset-typescript": "7.24.7", - "@playwright/test": "1.55.1", + "@playwright/test": "1.59.1", "@testing-library/react": "11.2.3", "@types/applicationinsights-js": "1.0.7", "@types/codemirror": "0.0.56", diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index ab179f0ee..0a51d721a 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -347,6 +347,7 @@ export interface Offer { export interface ThroughputBucket { id: number; maxThroughputPercentage: number; + isDefaultBucket?: boolean; } export interface SDKOfferDefinition extends Resource { diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.test.tsx index 2ad5c819e..08bc60e40 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.test.tsx @@ -76,11 +76,11 @@ describe("ThroughputBucketsComponent", () => { fireEvent.change(input, { target: { value: "70" } }); expect(mockOnBucketsChange).toHaveBeenCalledWith([ - { id: 1, maxThroughputPercentage: 70 }, - { id: 2, maxThroughputPercentage: 60 }, - { id: 3, maxThroughputPercentage: 100 }, - { id: 4, maxThroughputPercentage: 100 }, - { id: 5, maxThroughputPercentage: 100 }, + { id: 1, maxThroughputPercentage: 70, isDefaultBucket: false }, + { id: 2, maxThroughputPercentage: 60, isDefaultBucket: false }, + { id: 3, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 4, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 5, maxThroughputPercentage: 100, isDefaultBucket: false }, ]); }); @@ -102,11 +102,11 @@ describe("ThroughputBucketsComponent", () => { fireEvent.change(input2, { target: { value: "80" } }); expect(mockOnBucketsChange).toHaveBeenCalledWith([ - { id: 1, maxThroughputPercentage: 70 }, - { id: 2, maxThroughputPercentage: 80 }, - { id: 3, maxThroughputPercentage: 100 }, - { id: 4, maxThroughputPercentage: 100 }, - { id: 5, maxThroughputPercentage: 100 }, + { id: 1, maxThroughputPercentage: 70, isDefaultBucket: false }, + { id: 2, maxThroughputPercentage: 80, isDefaultBucket: false }, + { id: 3, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 4, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 5, maxThroughputPercentage: 100, isDefaultBucket: false }, ]); }); @@ -134,8 +134,8 @@ describe("ThroughputBucketsComponent", () => { , ); @@ -157,21 +157,21 @@ describe("ThroughputBucketsComponent", () => { fireEvent.click(toggles[0]); expect(mockOnBucketsChange).toHaveBeenCalledWith([ - { id: 1, maxThroughputPercentage: 100 }, - { id: 2, maxThroughputPercentage: 60 }, - { id: 3, maxThroughputPercentage: 100 }, - { id: 4, maxThroughputPercentage: 100 }, - { id: 5, maxThroughputPercentage: 100 }, + { id: 1, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 2, maxThroughputPercentage: 60, isDefaultBucket: false }, + { id: 3, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 4, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 5, maxThroughputPercentage: 100, isDefaultBucket: false }, ]); fireEvent.click(toggles[0]); expect(mockOnBucketsChange).toHaveBeenCalledWith([ - { id: 1, maxThroughputPercentage: 50 }, - { id: 2, maxThroughputPercentage: 60 }, - { id: 3, maxThroughputPercentage: 100 }, - { id: 4, maxThroughputPercentage: 100 }, - { id: 5, maxThroughputPercentage: 100 }, + { id: 1, maxThroughputPercentage: 50, isDefaultBucket: false }, + { id: 2, maxThroughputPercentage: 60, isDefaultBucket: false }, + { id: 3, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 4, maxThroughputPercentage: 100, isDefaultBucket: false }, + { id: 5, maxThroughputPercentage: 100, isDefaultBucket: false }, ]); }); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx index 6d2bb9eb2..f81b7c2f9 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx @@ -1,4 +1,16 @@ -import { Label, Slider, Stack, TextField, Toggle } from "@fluentui/react"; +import { + Dropdown, + Icon, + IDropdownOption, + Label, + Link, + Slider, + Stack, + Text, + TextField, + Toggle, + TooltipHost, +} from "@fluentui/react"; import { ThroughputBucket } from "Contracts/DataModels"; import { Keys, t } from "Localization"; import React, { FC, useEffect, useState } from "react"; @@ -9,6 +21,7 @@ const MAX_BUCKET_SIZES = 5; const DEFAULT_BUCKETS = Array.from({ length: MAX_BUCKET_SIZES }, (_, i) => ({ id: i + 1, maxThroughputPercentage: 100, + isDefaultBucket: false, })); export interface ThroughputBucketsComponentProps { @@ -24,19 +37,54 @@ export const ThroughputBucketsComponent: FC = ( onBucketsChange, onSaveableChange, }) => { + const NoDefaultThroughputSelectedKey: number = -1; const getThroughputBuckets = (buckets: ThroughputBucket[]): ThroughputBucket[] => { if (!buckets || buckets.length === 0) { return DEFAULT_BUCKETS; } const maxBuckets = Math.max(DEFAULT_BUCKETS.length, buckets.length); - const adjustedDefaultBuckets = Array.from({ length: maxBuckets }, (_, i) => ({ - id: i + 1, - maxThroughputPercentage: 100, + const adjustedDefaultBuckets: ThroughputBucket[] = Array.from( + { length: maxBuckets }, + (_, i) => + ({ + id: i + 1, + maxThroughputPercentage: 100, + isDefaultBucket: false, + }) as ThroughputBucket, + ); + + return adjustedDefaultBuckets.map((defaultBucket: ThroughputBucket) => { + const incoming: ThroughputBucket = buckets?.find((bucket) => bucket.id === defaultBucket.id); + + return { + ...defaultBucket, + ...incoming, + ...(incoming?.isDefaultBucket && { isDefaultBucket: true }), + }; + }); + }; + + const getThroughputBucketOptions = (): IDropdownOption[] => { + const noDefaultThroughputBucketSelected: IDropdownOption = { + key: NoDefaultThroughputSelectedKey, + text: t(Keys.controls.settings.throughputBuckets.noDefaultBucketSelected), + }; + + const throughputBucketOptions: IDropdownOption[] = throughputBuckets.map((bucket) => ({ + key: bucket.id, + text: t(Keys.controls.settings.throughputBuckets.bucketOptionLabel, { + id: String(bucket.id), + percentage: String(bucket.maxThroughputPercentage), + }), + disabled: bucket.maxThroughputPercentage === 100, + ...(bucket.maxThroughputPercentage === 100 && { + data: { + tooltip: t(Keys.controls.settings.throughputBuckets.bucketNotActive, { id: String(bucket.id) }), + }, + }), })); - return adjustedDefaultBuckets.map( - (defaultBucket) => buckets?.find((bucket) => bucket.id === defaultBucket.id) || defaultBucket, - ); + return [noDefaultThroughputBucketSelected, ...throughputBucketOptions]; }; const [throughputBuckets, setThroughputBuckets] = useState(getThroughputBuckets(currentBuckets)); @@ -53,7 +101,13 @@ export const ThroughputBucketsComponent: FC = ( const handleBucketChange = (id: number, newValue: number) => { const updatedBuckets = throughputBuckets.map((bucket) => - bucket.id === id ? { ...bucket, maxThroughputPercentage: newValue } : bucket, + bucket.id === id + ? { + ...bucket, + maxThroughputPercentage: newValue, + isDefaultBucket: newValue === 100 ? false : bucket.isDefaultBucket, + } + : bucket, ); setThroughputBuckets(updatedBuckets); const settingsChanged = isDirty(updatedBuckets, throughputBuckets); @@ -64,6 +118,38 @@ export const ThroughputBucketsComponent: FC = ( handleBucketChange(id, checked ? 50 : 100); }; + const onDefaultBucketToggle = (id: number, checked: boolean): void => { + const updatedBuckets: ThroughputBucket[] = throughputBuckets.map((bucket) => + bucket.id === id ? { ...bucket, isDefaultBucket: checked } : { ...bucket, isDefaultBucket: false }, + ); + setThroughputBuckets(updatedBuckets); + const settingsChanged = isDirty(updatedBuckets, throughputBuckets); + settingsChanged && onBucketsChange(updatedBuckets); + }; + + const onRenderDefaultThroughputBucketLabel = (): JSX.Element => { + const tooltipContent = (): JSX.Element => ( + + {t(Keys.controls.settings.throughputBuckets.defaultBucketTooltip)}{" "} + + {t(Keys.controls.settings.throughputBuckets.defaultBucketTooltipLearnMore)} + + + ); + + return ( + + + + + + + ); + }; + return ( ))} + throughputbucket.isDefaultBucket)?.id || + NoDefaultThroughputSelectedKey + } + onChange={(_, option) => onDefaultBucketToggle(option.key as number, true)} + onRenderLabel={onRenderDefaultThroughputBucketLabel} + onRenderOption={(option: IDropdownOption) => { + const tooltip: string = option?.data?.tooltip; + if (!tooltip) { + return <>{option?.text}; + } + + return ( + + {option?.text} + + ); + }} + styles={{ root: { width: "50%" } }} + data-test="default-throughput-bucket-dropdown" + /> ); }; diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 7723aa709..14dd422c1 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -961,7 +961,14 @@ "bucketLabel": "Bucket {{id}}", "dataExplorerQueryBucket": " (Data Explorer Query Bucket)", "active": "Active", - "inactive": "Inactive" + "inactive": "Inactive", + "defaultBucketLabel": "Default Throughput Bucket", + "defaultBucketPlaceholder": "Select a default throughput bucket", + "defaultBucketTooltip": "The default throughput bucket is used for operations that do not specify a particular bucket.", + "defaultBucketTooltipLearnMore": "Learn more.", + "noDefaultBucketSelected": "No Default Throughput Bucket Selected", + "bucketOptionLabel": "Bucket {{id}} - {{percentage}}%", + "bucketNotActive": "Bucket {{id}} is not active." } } } diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts index f592bf4c7..a36dc0aaf 100644 --- a/test/sql/containercopy/permissionsScreen.spec.ts +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -20,7 +20,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { }); test.afterEach("Cleanup after each test", async () => { - await page.unroute(/.*/, (route) => route.continue()); + await page.unrouteAll({ behavior: "ignoreErrors" }); await page.close(); }); diff --git a/test/sql/scaleAndSettings/computedProperties.spec.ts b/test/sql/scaleAndSettings/computedProperties.spec.ts index d1f95e53e..cc3d33f17 100644 --- a/test/sql/scaleAndSettings/computedProperties.spec.ts +++ b/test/sql/scaleAndSettings/computedProperties.spec.ts @@ -23,7 +23,9 @@ test.describe("Computed Properties", () => { }); test.afterAll("Delete Test Database", async () => { - await context?.dispose(); + if (!process.env.CI) { + await context?.dispose(); + } }); test("Add valid computed property", async ({ page }) => { diff --git a/test/sql/scaleAndSettings/throughputbucket.spec.ts b/test/sql/scaleAndSettings/throughputbucket.spec.ts new file mode 100644 index 000000000..1753c9b2e --- /dev/null +++ b/test/sql/scaleAndSettings/throughputbucket.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Throughput bucket settings", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open Throughput Bucket Settings", async ({ browser }) => { + const page = await browser.newPage(); + explorer = await DataExplorer.open(page, TestAccount.SQL); + + // Click Scale & Settings and open Throughput Bucket Settings tab + await explorer.openScaleAndSettings(context); + const throughputBucketTab = explorer.frame.getByTestId("settings-tab-header/ThroughputBucketsTab"); + await throughputBucketTab.click(); + }); + + // Delete database only if not running in CI + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Activate throughput bucket #2", async () => { + // Activate bucket 2 + const bucket2Toggle = explorer.frame.getByTestId("bucket-2-active-toggle"); + await bucket2Toggle.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for collection ${context.container.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Activate throughput buckets #1 and #2", async () => { + // Activate bucket 1 + const bucket1Toggle = explorer.frame.getByTestId("bucket-1-active-toggle"); + await bucket1Toggle.click(); + + // Activate bucket 2 + const bucket2Toggle = explorer.frame.getByTestId("bucket-2-active-toggle"); + await bucket2Toggle.click(); + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for collection ${context.container.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Set throughput percentage for bucket #1", async () => { + // Set throughput percentage for bucket 1 (inactive) - Should be disabled + const bucket1PercentageInput = explorer.frame.getByTestId("bucket-1-percentage-input"); + expect(bucket1PercentageInput).toBeDisabled(); + + // Activate bucket 1 + const bucket1Toggle = explorer.frame.getByTestId("bucket-1-active-toggle"); + await bucket1Toggle.click(); + expect(bucket1PercentageInput).toBeEnabled(); + await bucket1PercentageInput.fill("40"); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for collection ${context.container.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Set default throughput bucket", async () => { + // There are no active throughput buckets so they all should be disabled + const defaultThroughputBucketDropdown = explorer.frame.getByTestId("default-throughput-bucket-dropdown"); + await defaultThroughputBucketDropdown.click(); + + const bucket1Option = explorer.frame.getByRole("option", { name: "Bucket 1" }); + expect(bucket1Option).toBeDisabled(); + + // Activate bucket 1 + const bucket1Toggle = explorer.frame.getByTestId("bucket-1-active-toggle"); + await bucket1Toggle.click(); + + // Open dropdown again + await defaultThroughputBucketDropdown.click(); + expect(bucket1Option).toBeEnabled(); + + // Select bucket 1 as default + await bucket1Option.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for collection ${context.container.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index 494ce2e5d..e9ba759f1 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -223,10 +223,15 @@ export async function createTestSQLContainer({ const { database } = await client.databases.createIfNotExists({ id: databaseId }); try { - const { container } = await database.containers.createIfNotExists({ - id: containerId, - partitionKey, - }); + const { container } = await database.containers.createIfNotExists( + { + id: containerId, + partitionKey, + }, + { + offerThroughput: 4000, + }, + ); if (includeTestData) { const batchCount = TestData.length / 100; for (let i = 0; i < batchCount; i++) {