Remove Explorer.features (#563)

In addition this makes the URL-passed feature flags type safe
This commit is contained in:
Jordi Bunster 2021-03-22 12:04:06 -07:00 committed by GitHub
parent b1aeab6b84
commit f33ec09040
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 140 additions and 159 deletions

View File

@ -98,31 +98,6 @@ export class CapabilityNames {
public static readonly EnableServerless: string = "EnableServerless";
}
export class Features {
public static readonly cosmosdb = "cosmosdb";
public static readonly enableChangeFeedPolicy = "enablechangefeedpolicy";
public static readonly executeSproc = "dataexplorerexecutesproc";
public static readonly hostedDataExplorer = "hosteddataexplorerenabled";
public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint";
public static readonly notebookServerUrl = "notebookserverurl";
public static readonly notebookServerToken = "notebookservertoken";
public static readonly notebookBasePath = "notebookbasepath";
public static readonly canExceedMaximumValue = "canexceedmaximumvalue";
public static readonly enableFixedCollectionWithSharedThroughput = "enablefixedcollectionwithsharedthroughput";
public static readonly ttl90Days = "ttl90days";
public static readonly enableRightPanelV2 = "enablerightpanelv2";
public static readonly enableSchema = "enableschema";
public static readonly enableSDKoperations = "enablesdkoperations";
public static readonly showMinRUSurvey = "showminrusurvey";
public static readonly enableDatabaseSettingsTabV1 = "enabledbsettingsv1";
public static readonly selfServeType = "selfservetype";
public static readonly enableKOPanel = "enablekopanel";
public static readonly enableReactPane = "enablereactpane";
}
// flight names returned from the portal are always lowercase
export class Flights {
public static readonly SettingsV2 = "settingsv2";

View File

@ -376,7 +376,6 @@ export interface DataExplorerInputsFrame {
masterKey?: string;
hasWriteAccess?: boolean;
authorizationToken?: string;
features: { [key: string]: string };
csmEndpoint?: string;
dnsSuffix?: string;
serverId?: string;

View File

@ -139,9 +139,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.shouldShowIndexingPolicyEditor =
this.container && !this.container.isPreferredApiCassandra() && !this.container.isPreferredApiMongoDB();
this.changeFeedPolicyVisible = this.collection?.container.isFeatureEnabled(
Constants.Features.enableChangeFeedPolicy
);
this.changeFeedPolicyVisible = userContext.features.enableChangeFeedPolicy;
// Mongo container with system partition key still treat as "Fixed"
this.isFixedContainer =

View File

@ -12,7 +12,6 @@ import {
TextField,
} from "office-ui-fabric-react";
import React from "react";
import { Features } from "../../../../../Common/Constants";
import * as DataModels from "../../../../../Contracts/DataModels";
import { SubscriptionType } from "../../../../../Contracts/SubscriptionType";
import * as SharedConstants from "../../../../../Shared/Constants";
@ -465,7 +464,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
const href = `https://ncv.microsoft.com/vRBTO37jmO?ctx={"AzureSubscriptionId":"${userContext.subscriptionId}","CosmosDBAccountName":"${userContext.databaseAccount?.name}"}`;
const oneTBinKB = 1000000000;
const minRUperGB = 10;
const featureFlagEnabled = window.dataExplorer?.isFeatureEnabled(Features.showMinRUSurvey);
const featureFlagEnabled = userContext.features.showMinRUSurvey;
const collectionIsEligible =
userContext.subscriptionType !== SubscriptionType.Internal &&
this.props.usageSizeInKB > oneTBinKB &&

View File

@ -895,7 +895,6 @@ exports[`SettingsComponent renders 1`] = `
"validPartitionKeyValue": [Function],
"visible": [Function],
},
"features": [Function],
"flight": [Function],
"graphStylingPane": GraphStylingPane {
"container": [Circular],
@ -2092,7 +2091,6 @@ exports[`SettingsComponent renders 1`] = `
"validPartitionKeyValue": [Function],
"visible": [Function],
},
"features": [Function],
"flight": [Function],
"graphStylingPane": GraphStylingPane {
"container": [Circular],
@ -3302,7 +3300,6 @@ exports[`SettingsComponent renders 1`] = `
"validPartitionKeyValue": [Function],
"visible": [Function],
},
"features": [Function],
"flight": [Function],
"graphStylingPane": GraphStylingPane {
"container": [Circular],
@ -4499,7 +4496,6 @@ exports[`SettingsComponent renders 1`] = `
"validPartitionKeyValue": [Function],
"visible": [Function],
},
"features": [Function],
"flight": [Function],
"graphStylingPane": GraphStylingPane {
"container": [Circular],

View File

@ -329,7 +329,7 @@ export default class Explorer {
this.isNotebookEnabled(
userContext.authType !== AuthType.ResourceToken &&
((await this._containsDefaultNotebookWorkspace(this.databaseAccount())) ||
this.isFeatureEnabled(Constants.Features.enableNotebooks))
userContext.features.enableNotebooks)
);
TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, {
@ -351,7 +351,7 @@ export default class Explorer {
this.isSparkEnabledForAccount() &&
this.arcadiaWorkspaces() &&
this.arcadiaWorkspaces().length > 0) ||
this.isFeatureEnabled(Constants.Features.enableSpark)
userContext.features.enableSpark
);
if (this.isSparkEnabled()) {
appInsights.trackEvent(
@ -375,7 +375,6 @@ export default class Explorer {
});
this.memoryUsageInfo = ko.observable<DataModels.MemoryUsageInfo>();
this.features = ko.observable();
this.queriesClient = new QueriesClient(this);
this.resourceTokenDatabaseId = ko.observable<string>();
@ -387,11 +386,9 @@ export default class Explorer {
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
this.canExceedMaximumValue = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
);
this.canExceedMaximumValue = ko.computed<boolean>(() => userContext.features.canExceedMaximumValue);
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
this.isSchemaEnabled = ko.computed<boolean>(() => userContext.features.enableSchema);
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
@ -471,7 +468,7 @@ export default class Explorer {
});
this.isFixedCollectionWithSharedThroughputSupported = ko.computed(() => {
if (this.isFeatureEnabled(Constants.Features.enableFixedCollectionWithSharedThroughput)) {
if (userContext.features.enableFixedCollectionWithSharedThroughput) {
return true;
}
@ -530,9 +527,7 @@ export default class Explorer {
() =>
configContext.platform === Platform.Portal && !this.isRunningOnNationalCloud() && !this.isPreferredApiGraph()
);
this.isRightPanelV2Enabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableRightPanelV2)
);
this.isRightPanelV2Enabled = ko.computed<boolean>(() => userContext.features.enableRightPanelV2);
this.defaultExperience.subscribe((defaultExperience: string) => {
if (
defaultExperience &&
@ -883,42 +878,29 @@ export default class Explorer {
});
// Override notebook server parameters from URL parameters
const featureSubcription = this.features.subscribe((features) => {
const serverInfo = this.notebookServerInfo();
if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) {
serverInfo.notebookServerEndpoint = features[Constants.Features.notebookServerUrl];
}
if (userContext.features.notebookServerUrl && userContext.features.notebookServerToken) {
this.notebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl,
authToken: userContext.features.notebookServerToken,
});
}
if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) {
serverInfo.authToken = features[Constants.Features.notebookServerToken];
}
this.notebookServerInfo(serverInfo);
this.notebookServerInfo.valueHasMutated();
if (userContext.features.notebookBasePath) {
this.notebookBasePath(userContext.features.notebookBasePath);
}
if (this.isFeatureEnabled(Constants.Features.notebookBasePath)) {
this.notebookBasePath(features[Constants.Features.notebookBasePath]);
}
if (this.isFeatureEnabled(Constants.Features.livyEndpoint)) {
this.sparkClusterConnectionInfo({
userName: undefined,
password: undefined,
endpoints: [
{
endpoint: features[Constants.Features.livyEndpoint],
kind: DataModels.SparkClusterEndpointKind.Livy,
},
],
});
this.sparkClusterConnectionInfo.valueHasMutated();
}
if (this.isFeatureEnabled(Constants.Features.enableSDKoperations)) {
updateUserContext({ useSDKOperations: true });
}
featureSubcription.dispose();
});
if (userContext.features.livyEndpoint) {
this.sparkClusterConnectionInfo({
userName: undefined,
password: undefined,
endpoints: [
{
endpoint: userContext.features.livyEndpoint,
kind: DataModels.SparkClusterEndpointKind.Livy,
},
],
});
}
}
public openEnableSynapseLinkDialog(): void {
@ -1002,20 +984,6 @@ export default class Explorer {
return this.selectedNode() == null;
}
public isFeatureEnabled(feature: string): boolean {
const features = this.features();
if (!features) {
return false;
}
if (feature in features && features[feature]) {
return true;
}
return false;
}
public logConsoleData(consoleData: ConsoleData): void {
this.setNotificationConsoleData(consoleData);
}
@ -1258,12 +1226,12 @@ export default class Explorer {
throw error;
} finally {
// Overwrite with feature flags
if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) {
connectionInfo.notebookServerEndpoint = this.features()[Constants.Features.notebookServerUrl];
if (userContext.features.notebookServerUrl) {
connectionInfo.notebookServerEndpoint = userContext.features.notebookServerUrl;
}
if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) {
connectionInfo.authToken = this.features()[Constants.Features.notebookServerToken];
if (userContext.features.notebookServerToken) {
connectionInfo.authToken = userContext.features.notebookServerToken;
}
this.notebookServerInfo(connectionInfo);
@ -1413,7 +1381,6 @@ export default class Explorer {
if (inputs.defaultCollectionThroughput) {
this.collectionCreationDefaults = inputs.defaultCollectionThroughput;
}
this.features(inputs.features);
this.databaseAccount(databaseAccount);
this.subscriptionType(inputs.subscriptionType ?? SharedConstants.CollectionCreation.DefaultSubscriptionType);
this.hasWriteAccess(inputs.hasWriteAccess ?? true);
@ -2367,7 +2334,7 @@ export default class Explorer {
public onNewCollectionClicked(): void {
if (this.isPreferredApiCassandra()) {
this.cassandraAddCollectionPane.open();
} else if (this.isFeatureEnabled(Constants.Features.enableReactPane)) {
} else if (userContext.features.enableReactPane) {
this.openAddCollectionPanel();
} else {
this.addCollectionPane.open(this.selectedDatabaseId());
@ -2501,7 +2468,7 @@ export default class Explorer {
}
public openDeleteCollectionConfirmationPane(): void {
this.isFeatureEnabled(Constants.Features.enableKOPanel)
userContext.features.enableKOPanel
? this.deleteCollectionConfirmationPane.open()
: this.openSidePanel(
"Delete Collection",

View File

@ -994,7 +994,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
this.container.openEnableSynapseLinkDialog();
}
public ttl90DaysEnabled: () => boolean = () => this.container.isFeatureEnabled(Constants.Features.ttl90Days);
public ttl90DaysEnabled: () => boolean = () => userContext.features.ttl90Days;
public isValid(): boolean {
// TODO add feature flag that disables validation for customers with custom accounts
@ -1202,7 +1202,7 @@ export default class AddCollectionPane extends ContextualPaneBase {
if (this.isAnalyticalStorageOn()) {
// TODO: always default to 90 days once the backend hotfix is deployed
return this.container.isFeatureEnabled(Constants.Features.ttl90Days)
return userContext.features.ttl90Days
? Constants.AnalyticalStorageTtl.Days90
: Constants.AnalyticalStorageTtl.Infinite;
}

View File

@ -905,7 +905,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
if (this.state.enableAnalyticalStore) {
// TODO: always default to 90 days once the backend hotfix is deployed
return this.props.explorer.isFeatureEnabled(Constants.Features.ttl90Days)
return userContext.features.ttl90Days
? Constants.AnalyticalStorageTtl.Days90
: Constants.AnalyticalStorageTtl.Infinite;
}

View File

@ -882,8 +882,6 @@ export default class DocumentsTab extends TabsBase {
buttons.push(DocumentsTab._createUploadButton(this.collection.container));
}
const features = this.collection.container.features() || {};
return buttons;
}

View File

@ -58,9 +58,7 @@ export default class Database implements ViewModels.Database {
});
const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
const useDatabaseSettingsTabV1: boolean = this.container.isFeatureEnabled(
Constants.Features.enableDatabaseSettingsTabV1
);
const useDatabaseSettingsTabV1 = userContext.features.enableDatabaseSettingsTabV1;
const tabKind: ViewModels.CollectionTabKind = useDatabaseSettingsTabV1
? ViewModels.CollectionTabKind.DatabaseSettings
: ViewModels.CollectionTabKind.DatabaseSettingsV2;

View File

@ -3,13 +3,14 @@ import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import { deleteStoredProcedure } from "../../Common/dataAccess/deleteStoredProcedure";
import { executeStoredProcedure } from "../../Common/dataAccess/executeStoredProcedure";
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import Explorer from "../Explorer";
import StoredProcedureTab from "../Tabs/StoredProcedureTab";
import TabsBase from "../Tabs/TabsBase";
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
const sampleStoredProcedureBody: string = `// SAMPLE STORED PROCEDURE
function sample(prefix) {
@ -56,7 +57,7 @@ export default class StoredProcedure {
this.rid = data._rid;
this.id = ko.observable(data.id);
this.body = ko.observable(data.body as string);
this.isExecuteEnabled = this.container.isFeatureEnabled(Constants.Features.executeSproc);
this.isExecuteEnabled = userContext.features.executeSproc;
}
public static create(source: ViewModels.Collection, event: MouseEvent) {

View File

@ -1,17 +1,22 @@
import { extractFeatures } from "./extractFeatures";
describe("extractFeatures", () => {
it("correctly detects feature flags", () => {
// Search containing non-features, with Camelcase keys and uri encoded values
const params = new URLSearchParams(
"?platform=Hosted&feature.notebookserverurl=https%3A%2F%2Flocalhost%3A10001%2F12345%2Fnotebook&feature.notebookServerToken=token&feature.enablenotebooks=true&key=mykey"
);
it("correctly detects feature flags in a case insensitive manner", () => {
const url = "https://localhost:10001/12345/notebook";
const token = "super secret";
const notebooksEnabled = false;
const params = new URLSearchParams({
platform: "Hosted",
"feature.NOTEBOOKSERVERURL": url,
"feature.NoTeBooKServerToken": token,
"feature.NotAFeature": "nope",
"feature.ENABLEnotebooks": notebooksEnabled.toString(),
});
const features = extractFeatures(params);
expect(features).toEqual({
notebookserverurl: "https://localhost:10001/12345/notebook",
notebookservertoken: "token",
enablenotebooks: "true",
});
expect(features.notebookServerUrl).toBe(url);
expect(features.notebookServerToken).toBe(token);
expect(features.enableNotebooks).toBe(notebooksEnabled);
});
});

View File

@ -1,14 +1,56 @@
export function extractFeatures(params?: URLSearchParams): { [key: string]: string } {
export type Features = {
readonly canExceedMaximumValue: boolean;
readonly cosmosdb: boolean;
readonly enableChangeFeedPolicy: boolean;
readonly enableDatabaseSettingsTabV1: boolean;
readonly enableFixedCollectionWithSharedThroughput: boolean;
readonly enableKOPanel: boolean;
readonly enableNotebooks: boolean;
readonly enableReactPane: boolean;
readonly enableRightPanelV2: boolean;
readonly enableSchema: boolean;
readonly enableSDKoperations: boolean;
readonly enableSpark: boolean;
readonly enableTtl: boolean;
readonly executeSproc: boolean;
readonly hostedDataExplorer: boolean;
readonly livyEndpoint?: string;
readonly notebookBasePath?: string;
readonly notebookServerToken?: string;
readonly notebookServerUrl?: string;
readonly selfServeType?: string;
readonly showMinRUSurvey: boolean;
readonly ttl90Days: boolean;
};
export function extractFeatures(params?: URLSearchParams): Features {
params = params || new URLSearchParams(window.parent.location.search);
const featureParamRegex = /feature.(.*)/i;
const features: { [key: string]: string } = {};
params.forEach((value: string, param: string) => {
if (featureParamRegex.test(param)) {
const matches: string[] = param.match(featureParamRegex);
if (matches.length > 0) {
features[matches[1].toLowerCase()] = value;
}
}
});
return features;
const downcased = new URLSearchParams();
params.forEach((value, key) => downcased.append(key.toLocaleLowerCase(), value));
const get = (key: string) => downcased.get("feature." + key.toLocaleLowerCase()) ?? undefined;
return {
canExceedMaximumValue: "true" === get("canexceedmaximumvalue"),
cosmosdb: "true" === get("cosmosdb"),
enableChangeFeedPolicy: "true" === get("enablechangefeedpolicy"),
enableDatabaseSettingsTabV1: "true" === get("enabledbsettingsv1"),
enableFixedCollectionWithSharedThroughput: "true" === get("enablefixedcollectionwithsharedthroughput"),
enableKOPanel: "true" === get("enablekopanel"),
enableNotebooks: "true" === get("enablenotebooks"),
enableReactPane: "true" === get("enablereactpane"),
enableRightPanelV2: "true" === get("enablerightpanelv2"),
enableSchema: "true" === get("enableschema"),
enableSDKoperations: "true" === get("enablesdkoperations"),
enableSpark: "true" === get("enablespark"),
enableTtl: "true" === get("enablettl"),
executeSproc: "true" === get("dataexplorerexecutesproc"),
hostedDataExplorer: "true" === get("hosteddataexplorerenabled"),
livyEndpoint: get("livyendpoint"),
notebookBasePath: get("notebookbasepath"),
notebookServerToken: get("notebookservertoken"),
notebookServerUrl: get("notebookserverurl"),
selfServeType: get("selfservetype"),
showMinRUSurvey: "true" === get("showminrusurvey"),
ttl90Days: "true" === get("ttl90days"),
};
}

View File

@ -2,34 +2,44 @@ import { AuthType } from "./AuthType";
import { DatabaseAccount } from "./Contracts/DataModels";
import { SubscriptionType } from "./Contracts/SubscriptionType";
import { DefaultAccountExperienceType } from "./DefaultAccountExperienceType";
import { extractFeatures, Features } from "./Platform/Hosted/extractFeatures";
interface UserContext {
authType?: AuthType;
masterKey?: string;
subscriptionId?: string;
resourceGroup?: string;
databaseAccount?: DatabaseAccount;
endpoint?: string;
accessToken?: string;
authorizationToken?: string;
resourceToken?: string;
defaultExperience?: DefaultAccountExperienceType;
useSDKOperations?: boolean;
subscriptionType?: SubscriptionType;
quotaId?: string;
readonly authType?: AuthType;
readonly masterKey?: string;
readonly subscriptionId?: string;
readonly resourceGroup?: string;
readonly databaseAccount?: DatabaseAccount;
readonly endpoint?: string;
readonly accessToken?: string;
readonly authorizationToken?: string;
readonly resourceToken?: string;
readonly useSDKOperations: boolean;
readonly defaultExperience?: DefaultAccountExperienceType;
readonly subscriptionType?: SubscriptionType;
readonly quotaId?: string;
// API Type is not yet provided by ARM. You need to manually inspect all the capabilities+kind so we abstract that logic in userContext
// This is coming in a future Cosmos ARM API version as a prperty on databaseAccount
apiType?: ApiType;
isTryCosmosDBSubscription?: boolean;
portalEnv?: PortalEnv;
readonly apiType?: ApiType;
readonly isTryCosmosDBSubscription?: boolean;
readonly portalEnv?: PortalEnv;
readonly features: Features;
}
type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra";
export type PortalEnv = "localhost" | "blackforest" | "fairfax" | "mooncake" | "prod" | "dev";
const userContext: UserContext = { isTryCosmosDBSubscription: false, portalEnv: "prod" };
const features = extractFeatures();
const { enableSDKoperations: useSDKOperations } = features;
function updateUserContext(newContext: UserContext): void {
const userContext: UserContext = {
isTryCosmosDBSubscription: false,
portalEnv: "prod",
features,
useSDKOperations,
};
function updateUserContext(newContext: Partial<UserContext>): void {
Object.assign(userContext, newContext);
Object.assign(userContext, { apiType: apiType(userContext.databaseAccount) });
}

View File

@ -18,7 +18,6 @@ import {
ResourceToken,
} from "../HostedExplorerChildFrame";
import { emulatorAccount } from "../Platform/Emulator/emulatorAccount";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { parseResourceTokenConnectionString } from "../Platform/Hosted/Helpers/ResourceTokenUtils";
import {
getDatabaseAccountKindFromExperience,
@ -101,7 +100,6 @@ async function configureHostedWithAAD(config: AAD, explorerParams: ExplorerParam
resourceGroup,
masterKey: keys.primaryMasterKey,
authorizationToken: `Bearer ${config.authorizationToken}`,
features: extractFeatures(),
});
return explorer;
}
@ -128,7 +126,6 @@ function configureHostedWithConnectionString(config: ConnectionString, explorerP
explorer.configure({
databaseAccount,
masterKey: config.masterKey,
features: extractFeatures(),
});
return explorer;
}
@ -157,10 +154,7 @@ function configureHostedWithResourceToken(config: ResourceToken, explorerParams:
if (parsedResourceToken.partitionKey) {
explorer.resourceTokenPartitionKey(parsedResourceToken.partitionKey);
}
explorer.configure({
databaseAccount,
features: extractFeatures(),
});
explorer.configure({ databaseAccount });
return explorer;
}
@ -181,7 +175,6 @@ function configureHostedWithEncryptedToken(config: EncryptedToken, explorerParam
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: {},
},
features: extractFeatures(),
});
return explorer;
}