diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 25d73d189..99db16f11 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -26,6 +26,7 @@ export interface DatabaseAccountExtendedProperties { isVirtualNetworkFilterEnabled?: boolean; ipRules?: IpRule[]; privateEndpointConnections?: unknown[]; + capacity?: { totalThroughputLimit: number }; } export interface DatabaseAccountResponseLocation { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 0094806c6..880a50b99 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -1,4 +1,5 @@ import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react"; +import { useDatabases } from "Explorer/useDatabases"; import * as React from "react"; import DiscardIcon from "../../../../images/discard.svg"; import SaveIcon from "../../../../images/save-cosmos.svg"; @@ -71,6 +72,7 @@ export interface SettingsComponentState { wasAutopilotOriginallySet: boolean; isScaleSaveable: boolean; isScaleDiscardable: boolean; + throughputError: string; timeToLive: TtlType; timeToLiveBaseline: TtlType; @@ -124,6 +126,7 @@ export class SettingsComponent extends React.Component { + if (database.offer()) { + const dbThroughput = database.offer().autoscaleMaxThroughput || database.offer().manualThroughput; + this.totalThroughputUsed += dbThroughput; + } + + (database.collections() || []).forEach((collection) => { + if (collection.offer()) { + const colThroughput = collection.offer().autoscaleMaxThroughput || collection.offer().manualThroughput; + this.totalThroughputUsed += colThroughput; + } + }); + }); } componentDidMount(): void { @@ -254,6 +273,10 @@ export class SettingsComponent extends React.Component - this.setState({ autoPilotThroughput: newThroughput }); + private onMaxAutoPilotThroughputChange = (newThroughput: number): void => { + let throughputError = ""; + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap - this.totalThroughputUsed < newThroughput - this.offer.autoscaleMaxThroughput) { + throughputError = `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ + this.totalThroughputUsed + newThroughput + } RU/s. Change total throughput limit in cost management.`; + } + this.setState({ autoPilotThroughput: newThroughput, throughputError }); + }; - private onThroughputChange = (newThroughput: number): void => this.setState({ throughput: newThroughput }); + private onThroughputChange = (newThroughput: number): void => { + let throughputError = ""; + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap - this.totalThroughputUsed < newThroughput - this.offer.manualThroughput) { + throughputError = `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ + this.totalThroughputUsed + newThroughput + } RU/s. Change total throughput limit in cost management.`; + } + this.setState({ throughput: newThroughput, throughputError }); + }; private onAutoPilotSelected = (isAutoPilotSelected: boolean): void => this.setState({ isAutoPilotSelected: isAutoPilotSelected }); @@ -893,6 +933,7 @@ export class SettingsComponent extends React.Component void; onScaleDiscardableChange: (isScaleDiscardable: boolean) => void; initialNotification: DataModels.Notification; + throughputError?: string; } export class ScaleComponent extends React.Component { @@ -189,6 +190,7 @@ export class ScaleComponent extends React.Component { onScaleDiscardableChange={this.props.onScaleDiscardableChange} getThroughputWarningMessage={this.getThroughputWarningMessage} usageSizeInKB={this.props.collection?.usageSizeInKB()} + throughputError={this.props.throughputError} /> ); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx index 609d16b2d..c958b5a6a 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx @@ -75,6 +75,7 @@ export interface ThroughputInputAutoPilotV3Props { onScaleDiscardableChange: (isScaleDiscardable: boolean) => void; getThroughputWarningMessage: () => JSX.Element; usageSizeInKB: number; + throughputError?: string; } interface ThroughputInputAutoPilotV3State { @@ -540,6 +541,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< value={this.overrideWithProvisionedThroughputSettings() ? "" : this.props.maxAutoPilotThroughput?.toString()} onChange={this.onAutoPilotThroughputChange} min={minAutoPilotThroughput} + errorMessage={this.props.throughputError} /> {!this.overrideWithProvisionedThroughputSettings() && this.getAutoPilotUsageCost()} {this.minRUperGBSurvey()} @@ -579,6 +581,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< } onChange={this.onThroughputChange} min={this.props.minimum} + errorMessage={this.props.throughputError} /> {this.state.exceedFreeTierThroughput && ( jest.fn(), setIsAutoscale: () => jest.fn(), + setIsThroughputCapExceeded: () => jest.fn(), onCostAcknowledgeChange: () => jest.fn(), }; describe("ThroughputInput Pane", () => { diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx index de8b7757a..1b5972d36 100644 --- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx @@ -1,5 +1,6 @@ import { Checkbox, DirectionalHint, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react"; -import React, { FunctionComponent, useState } from "react"; +import { useDatabases } from "Explorer/useDatabases"; +import React, { FunctionComponent, useEffect, useState } from "react"; import * as Constants from "../../../Common/Constants"; import { InfoTooltip } from "../../../Common/Tooltip/InfoTooltip"; import * as SharedConstants from "../../../Shared/Constants"; @@ -16,6 +17,7 @@ export interface ThroughputInputProps { showFreeTierExceedThroughputTooltip: boolean; setThroughputValue: (throughput: number) => void; setIsAutoscale: (isAutoscale: boolean) => void; + setIsThroughputCapExceeded: (isThroughputCapExceeded: boolean) => void; onCostAcknowledgeChange: (isAcknowledged: boolean) => void; } @@ -24,6 +26,7 @@ export const ThroughputInput: FunctionComponent = ({ showFreeTierExceedThroughputTooltip, setThroughputValue, setIsAutoscale, + setIsThroughputCapExceeded, isSharded, onCostAcknowledgeChange, }: ThroughputInputProps) => { @@ -31,10 +34,58 @@ export const ThroughputInput: FunctionComponent = ({ const [throughput, setThroughput] = useState(AutoPilotUtils.minAutoPilotThroughput); const [isCostAcknowledged, setIsCostAcknowledged] = useState(false); const [throughputError, setThroughputError] = useState(""); + const [totalThroughputUsed, setTotalThroughputUsed] = useState(0); setIsAutoscale(isAutoscaleSelected); setThroughputValue(throughput); + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + + useEffect(() => { + // throughput cap check for the initial state + let totalThroughput = 0; + (useDatabases.getState().databases || []).forEach((database) => { + if (database.offer()) { + const dbThroughput = database.offer().autoscaleMaxThroughput || database.offer().manualThroughput; + totalThroughput += dbThroughput; + } + + (database.collections() || []).forEach((collection) => { + if (collection.offer()) { + const colThroughput = collection.offer().autoscaleMaxThroughput || collection.offer().manualThroughput; + totalThroughput += colThroughput; + } + }); + }); + setTotalThroughputUsed(totalThroughput); + + if (throughputCap && throughputCap - totalThroughput < throughput) { + setThroughputError( + `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ + totalThroughputUsed + throughput + } RU/s. Change total throughput limit in cost management.` + ); + + setIsThroughputCapExceeded(true); + } + }, []); + + const checkThroughputCap = (newThroughput: number): boolean => { + if (throughputCap && throughputCap - totalThroughputUsed < newThroughput) { + setThroughputError( + `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ + totalThroughputUsed + newThroughput + } RU/s. Change total throughput limit in cost management.` + ); + setIsThroughputCapExceeded(true); + return false; + } + + setThroughputError(""); + setIsThroughputCapExceeded(false); + return true; + }; + const getThroughputLabelText = (): string => { let throughputHeaderText: string; if (isAutoscaleSelected) { @@ -60,11 +111,17 @@ export const ThroughputInput: FunctionComponent = ({ const newThroughput = parseInt(newInput); setThroughput(newThroughput); setThroughputValue(newThroughput); + if (!isSharded && newThroughput > 10000) { setThroughputError("Unsharded collections support up to 10,000 RUs"); - } else { - setThroughputError(""); + return; } + + if (!checkThroughputCap(newThroughput)) { + return; + } + + setThroughputError(""); }; const getAutoScaleTooltip = (): string => { @@ -96,11 +153,13 @@ export const ThroughputInput: FunctionComponent = ({ setIsAutoScaleSelected(true); setThroughputValue(AutoPilotUtils.minAutoPilotThroughput); setIsAutoscale(true); + checkThroughputCap(AutoPilotUtils.minAutoPilotThroughput); } else { setThroughput(SharedConstants.CollectionCreation.DefaultCollectionRUs400); setIsAutoScaleSelected(false); setThroughputValue(SharedConstants.CollectionCreation.DefaultCollectionRUs400); setIsAutoscale(false); + checkThroughputCap(SharedConstants.CollectionCreation.DefaultCollectionRUs400); } }; diff --git a/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap b/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap index 0798c01ef..d3a8ecf23 100644 --- a/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap +++ b/src/Explorer/Controls/ThroughputInput/__snapshots__/ThroughputInput.test.tsx.snap @@ -6,6 +6,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = ` isSharded={true} onCostAcknowledgeChange={[Function]} setIsAutoscale={[Function]} + setIsThroughputCapExceeded={[Function]} setThroughputValue={[Function]} showFreeTierExceedThroughputTooltip={true} > diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index 028b3c426..a5d6651d5 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -92,6 +92,7 @@ export interface AddCollectionPanelState { errorMessage: string; showErrorDetails: boolean; isExecuting: boolean; + isThroughputCapExceeded: boolean; } export class AddCollectionPanel extends React.Component { @@ -122,6 +123,7 @@ export class AddCollectionPanel extends React.Component (this.newDatabaseThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isNewDatabaseAutoscale = isAutoscale)} + setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => + this.setState({ isThroughputCapExceeded }) + } onCostAcknowledgeChange={(isAcknowledge: boolean) => (this.isCostAcknowledged = isAcknowledge)} /> )} @@ -480,6 +485,9 @@ export class AddCollectionPanel extends React.Component (this.collectionThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (this.isCollectionAutoscale = isAutoscale)} + setIsThroughputCapExceeded={(isThroughputCapExceeded: boolean) => + this.setState({ isThroughputCapExceeded }) + } onCostAcknowledgeChange={(isAcknowledged: boolean) => { this.isCostAcknowledged = isAcknowledged; }} @@ -676,7 +684,7 @@ export class AddCollectionPanel extends React.Component - + {this.state.isExecuting && } diff --git a/src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx b/src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx index 3ee59bd30..0c3568ecf 100644 --- a/src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx +++ b/src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx @@ -50,6 +50,7 @@ export const AddDatabasePanel: FunctionComponent = ({ ); const [formErrors, setFormErrors] = useState(""); const [isExecuting, setIsExecuting] = useState(false); + const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState(false); const isFreeTierAccount: boolean = userContext.databaseAccount?.properties?.enableFreeTier; @@ -166,6 +167,7 @@ export const AddDatabasePanel: FunctionComponent = ({ formError: formErrors, isExecuting, submitButtonText: "OK", + isSubmitButtonDisabled: isThroughputCapExceeded, onSubmit, }; @@ -236,6 +238,7 @@ export const AddDatabasePanel: FunctionComponent = ({ isSharded={databaseCreateNewShared} setThroughputValue={(newThroughput: number) => (throughput = newThroughput)} setIsAutoscale={(isAutoscale: boolean) => (isAutoscaleSelected = isAutoscale)} + setIsThroughputCapExceeded={(isCapExceeded: boolean) => setIsThroughputCapExceeded(isCapExceeded)} onCostAcknowledgeChange={(isAcknowledged: boolean) => (isCostAcknowledged = isAcknowledged)} /> )} diff --git a/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap b/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap index 9c4bbaa32..bf462a66c 100644 --- a/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap +++ b/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap @@ -4,6 +4,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = ` @@ -92,6 +93,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = ` isSharded={true} onCostAcknowledgeChange={[Function]} setIsAutoscale={[Function]} + setIsThroughputCapExceeded={[Function]} setThroughputValue={[Function]} /> diff --git a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx index 64ee3fb3f..9aa4faf6b 100644 --- a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx +++ b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx @@ -43,6 +43,7 @@ export const CassandraAddCollectionPane: FunctionComponent(false); const [isExecuting, setIsExecuting] = useState(); const [formError, setFormError] = useState(""); + const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState(false); const isFreeTierAccount: boolean = userContext.databaseAccount?.properties?.enableFreeTier; const addCollectionPaneOpenMessage = { @@ -149,6 +150,7 @@ export const CassandraAddCollectionPane: FunctionComponent (newKeySpaceThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (isNewKeySpaceAutoscale = isAutoscale)} + setIsThroughputCapExceeded={(isCapExceeded: boolean) => setIsThroughputCapExceeded(isCapExceeded)} onCostAcknowledgeChange={(isAcknowledged: boolean) => (isCostAcknowledged = isAcknowledged)} /> )} @@ -334,6 +337,7 @@ export const CassandraAddCollectionPane: FunctionComponent (tableThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (isTableAutoscale = isAutoscale)} + setIsThroughputCapExceeded={(isCapExceeded: boolean) => setIsThroughputCapExceeded(isCapExceeded)} onCostAcknowledgeChange={(isAcknowledged: boolean) => (isCostAcknowledged = isAcknowledged)} /> )} diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPane/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap b/src/Explorer/Panes/DeleteCollectionConfirmationPane/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap index de359f986..b31dc19d3 100644 --- a/src/Explorer/Panes/DeleteCollectionConfirmationPane/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPane/__snapshots__/DeleteCollectionConfirmationPane.test.tsx.snap @@ -369,18 +369,21 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
= ({ buttonLabel, + isButtonDisabled, }: PanelFooterProps): JSX.Element => (
- +
); diff --git a/src/Explorer/Panes/RightPaneForm/RightPaneForm.tsx b/src/Explorer/Panes/RightPaneForm/RightPaneForm.tsx index 26c356a0f..a444db02d 100644 --- a/src/Explorer/Panes/RightPaneForm/RightPaneForm.tsx +++ b/src/Explorer/Panes/RightPaneForm/RightPaneForm.tsx @@ -9,6 +9,7 @@ export interface RightPaneFormProps { onSubmit: () => void; submitButtonText: string; isSubmitButtonHidden?: boolean; + isSubmitButtonDisabled?: boolean; children?: ReactNode; } @@ -18,6 +19,7 @@ export const RightPaneForm: FunctionComponent = ({ onSubmit, submitButtonText, isSubmitButtonHidden = false, + isSubmitButtonDisabled = false, children, }: RightPaneFormProps) => { const handleOnSubmit = (event: React.FormEvent) => { @@ -30,7 +32,9 @@ export const RightPaneForm: FunctionComponent = ({
{formError && } {children} - {!isSubmitButtonHidden && } + {!isSubmitButtonHidden && ( + + )} {isExecuting && } diff --git a/src/Explorer/Panes/RightPaneForm/__snapshots__/RightPaneForm.test.tsx.snap b/src/Explorer/Panes/RightPaneForm/__snapshots__/RightPaneForm.test.tsx.snap index 3438c3398..bafc521fc 100644 --- a/src/Explorer/Panes/RightPaneForm/__snapshots__/RightPaneForm.test.tsx.snap +++ b/src/Explorer/Panes/RightPaneForm/__snapshots__/RightPaneForm.test.tsx.snap @@ -14,18 +14,21 @@ exports[`Right Pane Form should render Default properly 1`] = ` >
= []; - - let nextLink = `${configContext.ARM_ENDPOINT}/subscriptions/${subscriptionId}/providers/Microsoft.DocumentDB/databaseAccounts?api-version=2021-06-15`; + const apiVersion = userContext.features.enableThroughputCap ? "2021-10-15" : "2021-06-15"; + let nextLink = `${configContext.ARM_ENDPOINT}/subscriptions/${subscriptionId}/providers/Microsoft.DocumentDB/databaseAccounts?api-version=${apiVersion}`; while (nextLink) { const response: Response = await fetch(nextLink, { headers });