Compare commits

..

20 Commits

Author SHA1 Message Date
vaidankarswapnil
f108ed4fbd Resolved test case issue 2021-09-16 22:49:50 +05:30
vaidankarswapnil
74095b6bc4 Update test snapshot issue 2021-09-16 15:32:47 +05:30
vaidankarswapnil
5a4b170857 Merge branch 'master' of https://github.com/Azure/cosmos-explorer into fix_a11y_database_throughput_radiobuttons 2021-09-16 12:36:25 +05:30
vaidankarswapnil
8ebd0f9b01 Fix a11y new collection throughput radiobutton issue 2021-09-16 12:36:13 +05:30
Karthik chakravarthy
d7997d716e Data pane expand issue (#1085)
* Data pane expand issue

* Data pane expand issue-1

* Data pane expand issue format

* Data pane expand issue formating
2021-09-15 19:50:36 -04:00
victor-meng
af0dc3094b Temporarily lower test coverage threshold (#1084) 2021-09-15 16:38:51 -07:00
victor-meng
665270296f Fix throughput cost estimate in add collection panel (#1070) 2021-09-15 13:05:55 -07:00
Asier Isayas
2d945c8231 allowing azure client secret to be null in dev mode (#1079)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2021-09-14 12:33:09 -04:00
Asier Isayas
8866976bb4 fixed hasFlag test (#1076)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2021-09-14 12:28:33 -04:00
Asier Isayas
d10f3c69f1 MongoClient Feature Flag (#1073)
Adding a feature flag for Mongo Client that allows a user to specify a mongo endpoint and an API so that users can test specific APIs locally.

Example:

https://localhost:1234/hostedExplorer.html?feature.mongoproxyendpoint=https://localhost:12901&feature.mongoProxyAPIs=createDocument|readDocument

The above link says to test APIs createDocument and readDocument on https://localhost:12901

Co-authored-by: artrejo <ato9000@users.noreply.github.com>
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2021-09-13 16:25:21 -04:00
kcheekuri
7e4f030547 Hidding container connection status behind the feature flag and initi… (#1019)
* Hidding container connection status behind the feature flag and initializing scratch issue

* maintaining connecting status UX part at notebooks context

* Changing scratch name to temporary and showing only after connected
2021-09-09 14:02:00 -04:00
siddjoshi-ms
05f46dd635 Sqlx approx cost bug fixes (#975)
* function naming changed

* bug fix: replacing multiple occurences of space correctly now
2021-09-08 14:04:31 -07:00
Meha Kaushik
65882ea831 Self-Server for GraphAPI Compute (#1017)
* Self-Server for GraphAPI Compute

* Update GraphAPICompute.json
2021-09-07 23:42:39 -07:00
kcheekuri
95c9b7ee31 Users/kcheekuri/aciconatinerpooling (#1008)
* initial changes for CP

* Added container unprovisioning

* Added postgreSQL terminal

* changed postgres terminal -> shell

* Initialize Container Request payload change

* added postgres button

* Added notebookServerInfo

* Feature flag enabling and integration with phoenix

* Remove postgre implementations

* fix issues

* fix format issues

* fix format issues-1

* fix format issues-2

* fix format issues-3

* fix format issues-4

* fix format issues-5

* connection status component

* connection status component-1

* connection status component-2

* connection status component-3

* address issues

* removal of ms

* removal of ms

* removal of ms-1

* removal of time after connected

* removal of time after connected

* removing unnecessary code

Co-authored-by: Srinath Narayanan <srnara@microsoft.com>
Co-authored-by: Bala Lakshmi Narayanasami <balalakshmin@microsoft.com>
2021-09-03 23:04:26 -07:00
Zachary Foster
39dd293fc1 Fetch aad token against tenant's authority (#1004) 2021-08-30 14:21:32 -05:00
victor-meng
8eeda41021 Move synapse link out of advanced section in add collection panel (#989) 2021-08-19 12:18:21 -07:00
Hardikkumar Nai
960cd9fc55 Resolve ESlint Controls (#990) 2021-08-19 12:16:35 -05:00
Hardikkumar Nai
9ec0ac9f54 Resolve ESLint Contracts (#986) 2021-08-19 12:15:52 -05:00
Steve Faulkner
b66aeb814a Polyfill Buffer (#988) 2021-08-18 21:12:40 -05:00
Tanuj Mittal
410f582378 Fix notebooksTemporarilyDown feature flag (#983) 2021-08-17 07:27:41 +05:30
56 changed files with 3526 additions and 1168 deletions

View File

@@ -21,16 +21,8 @@ src/Common/MongoUtility.ts
src/Common/NotificationsClientBase.ts
src/Common/QueriesClient.ts
src/Common/Splitter.ts
src/Config.ts
src/Contracts/ActionContracts.ts
src/Contracts/DataModels.ts
src/Contracts/Diagnostics.ts
src/Contracts/ExplorerContracts.ts
src/Contracts/Versions.ts
src/Contracts/ViewModels.ts
src/Controls/Heatmap/Heatmap.test.ts
src/Controls/Heatmap/Heatmap.ts
src/Controls/Heatmap/HeatmapDatatypes.ts
src/Definitions/datatables.d.ts
src/Definitions/gif.d.ts
src/Definitions/globals.d.ts
@@ -44,29 +36,10 @@ src/Definitions/png.d.ts
src/Definitions/svg.d.ts
src/Explorer/ComponentRegisterer.test.ts
src/Explorer/ComponentRegisterer.ts
src/Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.ts
src/Explorer/Controls/DiffEditor/DiffEditorComponent.ts
src/Explorer/Controls/DynamicList/DynamicList.test.ts
src/Explorer/Controls/DynamicList/DynamicListComponent.ts
src/Explorer/Controls/Editor/EditorComponent.ts
src/Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.ts
src/Explorer/Controls/InputTypeahead/InputTypeahead.ts
src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts
src/Explorer/Controls/Notebook/NotebookAppMessageHandler.ts
src/Explorer/Controls/ThroughputInput/ThroughputInputComponent.ts
src/Explorer/Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3.ts
src/Explorer/Controls/Toolbar/IToolbarAction.ts
src/Explorer/Controls/Toolbar/IToolbarDisplayable.ts
src/Explorer/Controls/Toolbar/IToolbarDropDown.ts
src/Explorer/Controls/Toolbar/IToolbarItem.ts
src/Explorer/Controls/Toolbar/IToolbarSeperator.ts
src/Explorer/Controls/Toolbar/IToolbarToggle.ts
src/Explorer/Controls/Toolbar/KeyCodes.ts
src/Explorer/Controls/Toolbar/Toolbar.ts
src/Explorer/Controls/Toolbar/ToolbarAction.ts
src/Explorer/Controls/Toolbar/ToolbarDropDown.ts
src/Explorer/Controls/Toolbar/ToolbarToggle.ts
src/Explorer/Controls/Toolbar/Utilities.ts
src/Explorer/DataSamples/ContainerSampleGenerator.test.ts
src/Explorer/DataSamples/ContainerSampleGenerator.ts
src/Explorer/DataSamples/DataSamplesUtil.test.ts
@@ -191,6 +164,4 @@ src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx
src/Explorer/Notebook/temp/inputs/connected-editors/codemirror.tsx
src/Explorer/Tree/ResourceTreeAdapter.tsx
__mocks__/monaco-editor.ts
src/Explorer/Tree/ResourceTree.tsx
src/Explorer/Tree/DatabasesResourceTree.tsx
src/Explorer/Tree/NotebooksResourceTree.tsx
src/Explorer/Tree/ResourceTree.tsx

View File

@@ -37,8 +37,8 @@ module.exports = {
global: {
branches: 25,
functions: 25,
lines: 30,
statements: 30,
lines: 29.5,
statements: 29.5,
},
},

View File

@@ -96,6 +96,7 @@ export class Flights {
public static readonly AutoscaleTest = "autoscaletest";
public static readonly PartitionKeyTest = "partitionkeytest";
public static readonly PKPartitionKeyTest = "pkpartitionkeytest";
public static readonly Phoenix = "phoenix";
}
export class AfecFeatures {
@@ -337,6 +338,12 @@ export enum ConflictOperationType {
Delete = "delete",
}
export enum ConnectionStatusType {
Connecting = "Connecting",
Connected = "Connected",
Failed = "Connection Failed",
}
export const EmulatorMasterKey =
//[SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="Well known public masterKey for emulator")]
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";

View File

@@ -3,8 +3,16 @@ import { resetConfigContext, updateConfigContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { updateUserContext } from "../UserContext";
import { deleteDocument, getEndpoint, queryDocuments, readDocument, updateDocument } from "./MongoProxyClient";
import {
deleteDocument,
getEndpoint,
getFeatureEndpointOrDefault,
queryDocuments,
readDocument,
updateDocument,
} from "./MongoProxyClient";
const databaseId = "testDB";
@@ -246,4 +254,31 @@ describe("MongoProxyClient", () => {
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
});
});
describe("getFeatureEndpointOrDefault", () => {
beforeEach(() => {
resetConfigContext();
updateConfigContext({
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
});
const params = new URLSearchParams({
"feature.mongoProxyEndpoint": "https://localhost:12901",
"feature.mongoProxyAPIs": "readDocument|createDocument",
});
const features = extractFeatures(params);
updateUserContext({
authType: AuthType.AAD,
features: features,
});
});
it("returns a local endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("readDocument");
expect(endpoint).toEqual("https://localhost:12901/api/mongo/explorer");
});
it("returns a production endpoint", () => {
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
});
});
});

View File

@@ -6,6 +6,7 @@ import * as DataModels from "../Contracts/DataModels";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { Collection } from "../Contracts/ViewModels";
import DocumentId from "../Explorer/Tree/DocumentId";
import { hasFlag } from "../Platform/Hosted/extractFeatures";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import { ApiType, HttpHeaders, HttpStatusCodes } from "./Constants";
@@ -78,7 +79,7 @@ export function queryDocuments(
: "",
};
const endpoint = getEndpoint() || "";
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
const headers = {
...defaultHeaders,
@@ -141,7 +142,8 @@ export function readDocument(
: "",
};
const endpoint = getEndpoint();
const endpoint = getFeatureEndpointOrDefault("readDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
method: "GET",
@@ -181,7 +183,7 @@ export function createDocument(
pk: collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
};
const endpoint = getEndpoint();
const endpoint = getFeatureEndpointOrDefault("createDocument");
return window
.fetch(`${endpoint}/resourcelist?${queryString.stringify(params)}`, {
@@ -225,7 +227,7 @@ export function updateDocument(
? documentId.partitionKeyProperty
: "",
};
const endpoint = getEndpoint();
const endpoint = getFeatureEndpointOrDefault("updateDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
@@ -266,7 +268,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
? documentId.partitionKeyProperty
: "",
};
const endpoint = getEndpoint();
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
return window
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
@@ -309,7 +311,7 @@ export function createMongoCollectionWithProxy(
autoPilotThroughput: params.autoPilotMaxThroughput?.toString(),
};
const endpoint = getEndpoint();
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
return window
.fetch(
@@ -333,8 +335,15 @@ export function createMongoCollectionWithProxy(
});
}
export function getEndpoint(): string {
let url = (configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT) + "/api/mongo/explorer";
export function getFeatureEndpointOrDefault(feature: string): string {
return hasFlag(userContext.features.mongoProxyAPIs, feature)
? getEndpoint(userContext.features.mongoProxyEndpoint)
: getEndpoint();
}
export function getEndpoint(customEndpoint?: string): string {
let url = customEndpoint ? customEndpoint : configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
url += "/api/mongo/explorer";
if (userContext.authType === AuthType.EncryptedToken) {
url = url.replace("api/mongo", "api/guest/mongo");

View File

@@ -54,6 +54,8 @@ export const ResourceTreeContainer: FunctionComponent<ResourceTreeContainerProps
</div>
{userContext.authType === AuthType.ResourceToken ? (
<ResourceTokenTree />
) : userContext.features.enableKoResourceTree ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
) : (
<ResourceTree container={container} />
)}

View File

@@ -1,3 +1,5 @@
import { ConnectionStatusType } from "../Common/Constants";
export interface DatabaseAccount {
id: string;
name: string;
@@ -496,3 +498,8 @@ export interface MemoryUsageInfo {
freeKB: number;
totalKB: number;
}
export interface ContainerConnectionInfo {
status: ConnectionStatusType;
//need to add ram and rom info
}

View File

@@ -22,8 +22,8 @@ describe("The Heatmap Control", () => {
};
let heatmap: Heatmap;
let theme: PortalTheme = 1;
const divElement: string = `<div id="${Heatmap.elementId}"></div>`;
const theme: PortalTheme = 1;
const divElement = `<div id="${Heatmap.elementId}"></div>`;
describe("drawHeatmap rendering", () => {
beforeEach(() => {
@@ -100,7 +100,7 @@ describe("iframe rendering when there is no data", () => {
});
it("should show a no data message with a dark theme", () => {
let data = {
const data = {
data: {
signature: "pcIframe",
data: {
@@ -111,7 +111,7 @@ describe("iframe rendering when there is no data", () => {
},
};
const divElement: string = `<div id="${Heatmap.elementId}"></div>`;
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);
@@ -120,7 +120,7 @@ describe("iframe rendering when there is no data", () => {
});
it("should show a no data message with a white theme", () => {
let data = {
const data = {
data: {
signature: "pcIframe",
data: {
@@ -131,7 +131,7 @@ describe("iframe rendering when there is no data", () => {
},
};
const divElement: string = `<div id="${Heatmap.elementId}"></div>`;
const divElement = `<div id="${Heatmap.elementId}"></div>`;
document.body.innerHTML = divElement;
handleMessage(data as MessageEvent);

View File

@@ -39,7 +39,7 @@ export class Heatmap {
}
}
private _getFontStyles(size: number = StyleConstants.MediumFontSize, color: string = "#838383"): FontSettings {
private _getFontStyles(size: number = StyleConstants.MediumFontSize, color = "#838383"): FontSettings {
return {
family: StyleConstants.DataExplorerFont,
size,
@@ -78,9 +78,9 @@ export class Heatmap {
// go thru all rows and create 2d matrix for heatmap...
for (let i = 0; i < rows.length; i++) {
output.yAxisPoints.push(rows[i]);
let dataPoints: number[] = [];
const dataPoints: number[] = [];
for (let a = 0; a < output.xAxisPoints.length; a++) {
let row: PartitionTimeStampToData = data[rows[i]];
const row: PartitionTimeStampToData = data[rows[i]];
dataPoints.push(row[output.xAxisPoints[a]]["Normalized Throughput"]);
}
output.dataPoints.push(dataPoints);
@@ -193,7 +193,7 @@ export class Heatmap {
this._getLayoutSettings(),
this._getChartDisplaySettings()
);
let plotDiv: any = document.getElementById(Heatmap.elementId);
const plotDiv: any = document.getElementById(Heatmap.elementId);
plotDiv.on("plotly_click", (data: any) => {
let timeSelected: string = data.points[0].x;
timeSelected = timeSelected.replace(" ", "T");
@@ -205,7 +205,7 @@ export class Heatmap {
break;
}
}
let output = [];
const output = [];
for (let i = 0; i < this._chartData.dataPoints.length; i++) {
output.push(this._chartData.dataPoints[i][xAxisIndex]);
}

View File

@@ -181,8 +181,7 @@ export const Dialog: FC = () => {
text: secondaryButtonText,
onClick: onSecondaryButtonClick,
}
: {};
: undefined;
return visible ? (
<FluentDialog {...dialogProps}>
{choiceGroupProps && <ChoiceGroup {...choiceGroupProps} />}

View File

@@ -1,45 +1,45 @@
import * as React from "react";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import { AutopilotDocumentation, hoursInAMonth } from "../../../Shared/Constants";
import { Urls, StyleConstants } from "../../../Common/Constants";
import {
getPriceCurrency,
getCurrencySign,
getAutoscalePricePerRu,
getMultimasterMultiplier,
computeRUUsagePriceHourly,
getPricePerRu,
estimatedCostDisclaimer,
} from "../../../Utils/PricingUtils";
import {
ITextFieldStyles,
DetailsList,
DetailsListLayoutMode,
DetailsRow,
ICheckboxStyles,
IStackProps,
IStackTokens,
IChoiceGroupStyles,
Link,
Text,
IMessageBarStyles,
ITextStyles,
IDetailsRowStyles,
IStackStyles,
IColumn,
IDetailsColumnStyles,
IDetailsListStyles,
IDetailsRowProps,
IDetailsRowStyles,
IDropdownStyles,
IMessageBarStyles,
ISeparatorStyles,
IStackProps,
IStackStyles,
IStackTokens,
ITextFieldStyles,
ITextStyles,
Link,
MessageBar,
MessageBarType,
Stack,
SelectionMode,
Spinner,
SpinnerSize,
DetailsList,
IColumn,
SelectionMode,
DetailsListLayoutMode,
IDetailsRowProps,
DetailsRow,
IDetailsColumnStyles,
Stack,
Text,
} from "@fluentui/react";
import { isDirtyTypes, isDirty } from "./SettingsUtils";
import * as React from "react";
import { StyleConstants, Urls } from "../../../Common/Constants";
import { AutopilotDocumentation, hoursInAMonth } from "../../../Shared/Constants";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import {
computeRUUsagePriceHourly,
estimatedCostDisclaimer,
getAutoscalePricePerRu,
getCurrencySign,
getMultimasterMultiplier,
getPriceCurrency,
getPricePerRu,
} from "../../../Utils/PricingUtils";
import { isDirty, isDirtyTypes } from "./SettingsUtils";
export interface EstimatedSpendingDisplayProps {
costType: JSX.Element;
@@ -223,14 +223,15 @@ export const getRuPriceBreakdown = (
multimasterEnabled: isMultimaster,
isAutoscale: isAutoscale,
});
const basePricePerRu: number = isAutoscale
? getAutoscalePricePerRu(serverId, getMultimasterMultiplier(numberOfRegions, isMultimaster))
: getPricePerRu(serverId);
const multimasterMultiplier = getMultimasterMultiplier(numberOfRegions, isMultimaster);
const pricePerRu: number = isAutoscale
? getAutoscalePricePerRu(serverId, multimasterMultiplier)
: getPricePerRu(serverId, multimasterMultiplier);
return {
hourlyPrice: hourlyPrice,
hourlyPrice,
dailyPrice: hourlyPrice * 24,
monthlyPrice: hourlyPrice * hoursInAMonth,
pricePerRu: basePricePerRu * getMultimasterMultiplier(numberOfRegions, isMultimaster),
pricePerRu,
currency: getPriceCurrency(serverId),
currencySign: getCurrencySign(serverId),
};

View File

@@ -34,6 +34,7 @@ exports[`SettingsComponent renders 1`] = `
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
@@ -101,6 +102,7 @@ exports[`SettingsComponent renders 1`] = `
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],

View File

@@ -6,6 +6,7 @@ import { userContext } from "../../../../UserContext";
import {
calculateEstimateNumber,
computeRUUsagePriceHourly,
estimatedCostDisclaimer,
getAutoscalePricePerRu,
getCurrencySign,
getMultimasterMultiplier,
@@ -42,11 +43,9 @@ export const CostEstimateText: FunctionComponent<CostEstimateTextProps> = ({
const currency: string = getPriceCurrency(serverId);
const currencySign: string = getCurrencySign(serverId);
const multiplier = getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
const pricePerRu = isAutoscale
? getAutoscalePricePerRu(serverId, multiplier) * multiplier
: getPricePerRu(serverId) * multiplier;
const pricePerRu = isAutoscale ? getAutoscalePricePerRu(serverId, multiplier) : getPricePerRu(serverId, multiplier);
const iconWithEstimatedCostDisclaimer: JSX.Element = <InfoTooltip>PricingUtils.estimatedCostDisclaimer</InfoTooltip>;
const iconWithEstimatedCostDisclaimer: JSX.Element = <InfoTooltip>{estimatedCostDisclaimer}</InfoTooltip>;
if (isAutoscale) {
return (

View File

@@ -21,12 +21,10 @@ describe("ThroughputInput Pane", () => {
});
it("should switch mode properly", () => {
wrapper.find('[aria-label="Manual mode"]').simulate("change");
expect(wrapper.find('[aria-label="Throughput header"]').at(0).text()).toBe(
"Container throughput (400 - unlimited RU/s)"
);
wrapper.find(".ms-ChoiceField-input").at(0).simulate("change");
expect(wrapper.find("#throughPut").at(0).text()).toBe("Container throughput (autoscale)");
wrapper.find('[aria-label="Autoscale mode"]').simulate("change");
expect(wrapper.find('[aria-label="Throughput header"]').at(0).text()).toBe("Container throughput (autoscale)");
wrapper.find(".ms-ChoiceField-input").at(1).simulate("change");
expect(wrapper.find("#throughPut").at(0).text()).toBe("Container throughput (400 - unlimited RU/s)");
});
});

View File

@@ -1,4 +1,15 @@
import { Checkbox, DirectionalHint, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react";
import {
Checkbox,
ChoiceGroup,
DirectionalHint,
IChoiceGroupOption,
Label,
Link,
Stack,
Text,
TextField,
TooltipHost,
} from "@fluentui/react";
import React, { FunctionComponent, useState } from "react";
import * as Constants from "../../../Common/Constants";
import { InfoTooltip } from "../../../Common/Tooltip/InfoTooltip";
@@ -90,7 +101,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
);
};
const handleOnChangeMode = (event: React.ChangeEvent<HTMLInputElement>, mode: string): void => {
const handleOnChangeMode = (_: React.ChangeEvent<HTMLInputElement>, mode: string): void => {
if (mode === "Autoscale") {
setThroughput(AutoPilotUtils.minAutoPilotThroughput);
setIsAutoScaleSelected(true);
@@ -103,39 +114,46 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
setIsAutoscale(false);
}
};
const choiceButtonStyles = {
flexContainer: [
{
selectors: {
".ms-ChoiceField-wrapper label": {
fontSize: 12,
paddingTop: 0,
},
".ms-ChoiceField": {
marginTop: 0,
},
},
},
],
};
const throughPutOptions: IChoiceGroupOption[] = [
{ key: "Autoscale", text: "Autoscale" },
{ key: "Manual", text: "Manual" },
];
return (
<div className="throughputInputContainer throughputInputSpacing">
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text aria-label="Throughput header" variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
<Label id="throughPut" aria-label={getThroughputLabelText()} style={{ lineHeight: "20px", fontWeight: 600 }}>
{getThroughputLabelText()}
</Text>
</Label>
<InfoTooltip>{PricingUtils.getRuToolTipText()}</InfoTooltip>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="throughputInputRadioBtn"
aria-label="Autoscale mode"
checked={isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
<Stack verticalAlign="center">
<ChoiceGroup
ariaLabelledBy="throughPut"
styles={choiceButtonStyles}
options={throughPutOptions}
defaultSelectedKey={throughPutOptions[0].key}
onChange={(_: React.ChangeEvent<HTMLInputElement>, options: IChoiceGroupOption) =>
handleOnChangeMode(_, options.key.toString())
}
required
/>
<span className="throughputInputRadioBtnLabel">Autoscale</span>
<input
className="throughputInputRadioBtn"
aria-label="Manual mode"
checked={!isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Manual")}
/>
<span className="throughputInputRadioBtnLabel">Manual</span>
</Stack>
{isAutoscaleSelected && (

View File

@@ -16,6 +16,7 @@ import { GitHubOAuthService } from "../GitHub/GitHubOAuthService";
import { useSidePanel } from "../hooks/useSidePanel";
import { useTabs } from "../hooks/useTabs";
import { IGalleryItem } from "../Juno/JunoClient";
import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
@@ -87,12 +88,13 @@ export default class Explorer {
};
private static readonly MaxNbDatabasesToAutoExpand = 5;
private phoenixClient: PhoenixClient;
constructor() {
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
});
this._isInitializingNotebooks = false;
this.phoenixClient = new PhoenixClient();
useNotebook.subscribe(
() => this.refreshCommandBarButtons(),
(state) => state.isNotebooksEnabledForAccount
@@ -343,19 +345,36 @@ export default class Explorer {
return;
}
this._isInitializingNotebooks = true;
if (userContext.features.phoenix) {
const provisionData = {
cosmosEndpoint: userContext.databaseAccount.properties.documentEndpoint,
resourceId: userContext.databaseAccount.id,
dbAccountName: userContext.databaseAccount.name,
aadToken: userContext.authorizationToken,
resourceGroup: userContext.resourceGroup,
subscriptionId: userContext.subscriptionId,
};
const connectionInfo = await this.phoenixClient.containerConnectionInfo(provisionData);
if (connectionInfo.data && connectionInfo.data.notebookServerUrl) {
useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.data.notebookServerUrl,
authToken: userContext.features.notebookServerToken || connectionInfo.data.notebookAuthToken,
});
}
} else {
await this.ensureNotebookWorkspaceRunning();
const connectionInfo = await listConnectionInfo(
userContext.subscriptionId,
userContext.resourceGroup,
databaseAccount.name,
"default"
);
await this.ensureNotebookWorkspaceRunning();
const connectionInfo = await listConnectionInfo(
userContext.subscriptionId,
userContext.resourceGroup,
databaseAccount.name,
"default"
);
useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint,
authToken: userContext.features.notebookServerToken || connectionInfo.authToken,
});
useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint,
authToken: userContext.features.notebookServerToken || connectionInfo.authToken,
});
}
useNotebook.getState().initializeNotebooksTree(this.notebookManager);
@@ -364,7 +383,7 @@ export default class Explorer {
this._isInitializingNotebooks = false;
}
public resetNotebookWorkspace() {
public resetNotebookWorkspace(): void {
if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookClient) {
handleError(
"Attempt to reset notebook workspace, but notebook is not enabled",
@@ -389,7 +408,6 @@ export default class Explorer {
if (!databaseAccount) {
return false;
}
try {
const { value: workspaces } = await listByDatabaseAccount(
userContext.subscriptionId,
@@ -906,7 +924,7 @@ export default class Explorer {
await this.notebookManager?.notebookContentClient.updateItemChildrenInPlace(item);
}
public openNotebookTerminal(kind: ViewModels.TerminalKind) {
public openNotebookTerminal(kind: ViewModels.TerminalKind): void {
let title: string;
switch (kind) {
@@ -1026,7 +1044,10 @@ export default class Explorer {
}
public async handleOpenFileAction(path: string): Promise<void> {
if (!(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) {
if (
userContext.features.phoenix === false &&
!(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))
) {
this._openSetupNotebooksPaneForQuickstart();
}
@@ -1072,10 +1093,13 @@ export default class Explorer {
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases();
await useNotebook.getState().refreshNotebooksEnabledStateForAccount();
const isNotebookEnabled: boolean =
userContext.authType !== AuthType.ResourceToken &&
((await this._containsDefaultNotebookWorkspace(userContext.databaseAccount)) ||
userContext.features.enableNotebooks);
let isNotebookEnabled = true;
if (!userContext.features.phoenix) {
isNotebookEnabled =
userContext.authType !== AuthType.ResourceToken &&
((await this._containsDefaultNotebookWorkspace(userContext.databaseAccount)) ||
userContext.features.enableNotebooks);
}
useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled);
useNotebook.getState().setIsShellEnabled(isNotebookEnabled && isPublicInternetAccessAllowed());

View File

@@ -9,6 +9,7 @@ import create, { UseStore } from "zustand";
import { StyleConstants } from "../../../Common/Constants";
import * as ViewModels from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs";
import { userContext } from "../../../UserContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode";
@@ -54,6 +55,14 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor);
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
if (
userContext.features.notebooksTemporarilyDown === false &&
userContext.features.phoenix === true &&
useTabs.getState().activeTab?.tabKind === ViewModels.CollectionTabKind.NotebookV2
) {
uiFabricControlButtons.unshift(CommandBarUtil.createConnectionStatus("connectionStatus"));
}
if (useTabs.getState().activeTab?.tabKind === ViewModels.CollectionTabKind.NotebookV2) {
uiFabricControlButtons.unshift(CommandBarUtil.createMemoryTracker("memoryTracker"));
}

View File

@@ -80,8 +80,9 @@ export function createStaticCommandBarButtons(
}
notebookButtons.push(createOpenTerminalButton(container));
notebookButtons.push(createNotebookWorkspaceResetButton(container));
if (userContext.features.phoenix === false) {
notebookButtons.push(createNotebookWorkspaceResetButton(container));
}
if (
(userContext.apiType === "Mongo" &&
useNotebook.getState().isShellEnabled &&

View File

@@ -13,6 +13,7 @@ import { StyleConstants } from "../../../Common/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { ConnectionStatus } from "./ConnectionStatusComponent";
import { MemoryTracker } from "./MemoryTrackerComponent";
/**
@@ -201,3 +202,10 @@ export const createMemoryTracker = (key: string): ICommandBarItemProps => {
onRender: () => <MemoryTracker />,
};
};
export const createConnectionStatus = (key: string): ICommandBarItemProps => {
return {
key,
onRender: () => <ConnectionStatus />,
};
};

View File

@@ -0,0 +1,79 @@
@import "../../../../less/Common/Constants";
.connectionStatusContainer {
cursor: default;
align-items: center;
margin: 0 9px;
border: 1px;
min-height: 44px;
> span {
padding-right: 12px;
font-size: 13px;
font-family: @DataExplorerFont;
color: @DefaultFontColor;
}
}
.connectionStatusFailed{
color: #bd1919;
}
.ring-container {
position: relative;
}
.ringringGreen {
border: 3px solid green;
border-radius: 30px;
height: 18px;
width: 18px;
position: absolute;
margin: .4285em 0em 0em 0.07477em;
animation: pulsate 3s ease-out;
animation-iteration-count: infinite;
opacity: 0.0
}
.ringringYellow{
border: 3px solid #ffbf00;
border-radius: 30px;
height: 18px;
width: 18px;
position: absolute;
margin: .4285em 0em 0em 0.07477em;
animation: pulsate 3s ease-out;
animation-iteration-count: infinite;
opacity: 0.0
}
.ringringRed{
border: 3px solid #bd1919;
border-radius: 30px;
height: 18px;
width: 18px;
position: absolute;
margin: .4285em 0em 0em 0.07477em;
animation: pulsate 3s ease-out;
animation-iteration-count: infinite;
opacity: 0.0
}
@keyframes pulsate {
0% {-webkit-transform: scale(0.1, 0.1); opacity: 0.0;}
15% {opacity: 0.8;}
25% {opacity: 0.6;}
45% {opacity: 0.4;}
70% {opacity: 0.3;}
100% {-webkit-transform: scale(.7, .7); opacity: 0.1;}
}
.locationGreenDot{
font-size: 20px;
margin-right: 0.07em;
color: green;
}
.locationYellowDot{
font-size: 20px;
margin-right: 0.07em;
color: #ffbf00;
}
.locationRedDot{
font-size: 20px;
margin-right: 0.07em;
color: #bd1919;
}

View File

@@ -0,0 +1,72 @@
import { Icon, ProgressIndicator, Stack, TooltipHost } from "@fluentui/react";
import * as React from "react";
import { ConnectionStatusType } from "../../../Common/Constants";
import { useNotebook } from "../../Notebook/useNotebook";
import "../CommandBar/ConnectionStatusComponent.less";
export const ConnectionStatus: React.FC = (): JSX.Element => {
const [second, setSecond] = React.useState("00");
const [minute, setMinute] = React.useState("00");
const [isActive, setIsActive] = React.useState(false);
const [counter, setCounter] = React.useState(0);
const [statusColor, setStatusColor] = React.useState("locationYellowDot");
const [statusColorAnimation, setStatusColorAnimation] = React.useState("ringringYellow");
const toolTipContent = "Hosted runtime status.";
React.useEffect(() => {
let intervalId: NodeJS.Timeout;
if (isActive) {
intervalId = setInterval(() => {
const secondCounter = counter % 60;
const minuteCounter = Math.floor(counter / 60);
const computedSecond: string = String(secondCounter).length === 1 ? `0${secondCounter}` : `${secondCounter}`;
const computedMinute: string = String(minuteCounter).length === 1 ? `0${minuteCounter}` : `${minuteCounter}`;
setSecond(computedSecond);
setMinute(computedMinute);
setCounter((counter) => counter + 1);
}, 1000);
}
return () => clearInterval(intervalId);
}, [isActive, counter]);
const stopTimer = () => {
setIsActive(false);
setCounter(0);
setSecond("00");
setMinute("00");
};
const connectionInfo = useNotebook((state) => state.connectionInfo);
if (!connectionInfo) {
return <></>;
}
if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connecting && isActive === false) {
setIsActive(true);
} else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connected && isActive === true) {
stopTimer();
setStatusColor("locationGreenDot");
setStatusColorAnimation("ringringGreen");
} else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Failed && isActive === true) {
stopTimer();
setStatusColor("locationRedDot");
setStatusColorAnimation("ringringRed");
}
return (
<TooltipHost content={toolTipContent}>
<Stack className="connectionStatusContainer" horizontal>
<div className="ring-container">
<div className={statusColorAnimation}></div>
<Icon iconName="LocationDot" className={statusColor} />
</div>
<span className={connectionInfo.status === ConnectionStatusType.Failed ? "connectionStatusFailed" : ""}>
{connectionInfo.status}
</span>
{connectionInfo.status === ConnectionStatusType.Connecting && isActive && (
<ProgressIndicator description={minute + ":" + second} />
)}
</Stack>
</TooltipHost>
);
};

View File

@@ -109,7 +109,7 @@ const formWebSocketURL = (serverConfig: NotebookServiceConfig, kernelId: string,
const q = params.toString();
const suffix = q !== "" ? `?${q}` : "";
const url = (serverConfig.endpoint || "") + `api/kernels/${kernelId}/channels${suffix}`;
const url = (serverConfig.endpoint.slice(0, -1) || "") + `api/kernels/${kernelId}/channels${suffix}`;
return url.replace(/^http(s)?/, "ws$1");
};

View File

@@ -56,7 +56,7 @@ export class NotebookContainerClient {
const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig();
try {
const response = await fetch(`${notebookServerEndpoint}/api/metrics/memory`, {
const response = await fetch(`${notebookServerEndpoint}api/metrics/memory`, {
method: "GET",
headers: {
Authorization: authToken,

View File

@@ -36,7 +36,10 @@ export class NotebookContentClient {
*
* @param parent parent folder
*/
public createNewNotebookFile(parent: NotebookContentItem, isGithubTree?: boolean): Promise<NotebookContentItem> {
public async createNewNotebookFile(
parent: NotebookContentItem,
isGithubTree?: boolean
): Promise<NotebookContentItem> {
if (!parent || parent.type !== NotebookContentItemType.Directory) {
throw new Error(`Parent must be a directory: ${parent}`);
}
@@ -99,7 +102,6 @@ export class NotebookContentClient {
if (!parent || parent.type !== NotebookContentItemType.Directory) {
throw new Error(`Parent must be a directory: ${parent}`);
}
const filepath = NotebookUtil.getFilePath(parent.path, name);
if (await this.checkIfFilepathExists(filepath)) {
throw new Error(`File already exists: ${filepath}`);

View File

@@ -28,6 +28,8 @@ interface NotebookState {
myNotebooksContentRoot: NotebookContentItem;
gitHubNotebooksContentRoot: NotebookContentItem;
galleryContentRoot: NotebookContentItem;
connectionInfo: DataModels.ContainerConnectionInfo;
notebookFolderName: string;
setIsNotebookEnabled: (isNotebookEnabled: boolean) => void;
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void;
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void;
@@ -36,6 +38,7 @@ interface NotebookState {
setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => void;
setIsShellEnabled: (isShellEnabled: boolean) => void;
setNotebookBasePath: (notebookBasePath: string) => void;
setNotebookFolderName: (notebookFolderName: string) => void;
refreshNotebooksEnabledStateForAccount: () => Promise<void>;
findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem, isGithubTree?: boolean) => void;
@@ -43,6 +46,7 @@ interface NotebookState {
deleteNotebookItem: (item: NotebookContentItem, isGithubTree?: boolean) => void;
initializeNotebooksTree: (notebookManager: NotebookManager) => Promise<void>;
initializeGitHubRepos: (pinnedRepos: IPinnedRepo[]) => void;
setConnectionInfo: (connectionInfo: DataModels.ContainerConnectionInfo) => void;
}
export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
@@ -65,6 +69,8 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
myNotebooksContentRoot: undefined,
gitHubNotebooksContentRoot: undefined,
galleryContentRoot: undefined,
connectionInfo: undefined,
notebookFolderName: undefined,
setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }),
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }),
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) =>
@@ -75,6 +81,7 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => set({ memoryUsageInfo }),
setIsShellEnabled: (isShellEnabled: boolean) => set({ isShellEnabled }),
setNotebookBasePath: (notebookBasePath: string) => set({ notebookBasePath }),
setNotebookFolderName: (notebookFolderName: string) => set({ notebookFolderName }),
refreshNotebooksEnabledStateForAccount: async (): Promise<void> => {
const { databaseAccount, authType } = userContext;
if (
@@ -168,8 +175,10 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
isGithubTree ? set({ gitHubNotebooksContentRoot: root }) : set({ myNotebooksContentRoot: root });
},
initializeNotebooksTree: async (notebookManager: NotebookManager): Promise<void> => {
const notebookFolderName = userContext.features.phoenix === true ? "Temporary Notebooks" : "My Notebooks";
set({ notebookFolderName });
const myNotebooksContentRoot = {
name: "My Notebooks",
name: get().notebookFolderName,
path: get().notebookBasePath,
type: NotebookContentItemType.Directory,
};
@@ -185,6 +194,7 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
type: NotebookContentItemType.Directory,
}
: undefined;
set({
myNotebooksContentRoot,
galleryContentRoot,
@@ -246,4 +256,5 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
set({ gitHubNotebooksContentRoot });
}
},
setConnectionInfo: (connectionInfo: DataModels.ContainerConnectionInfo) => set({ connectionInfo }),
}));

View File

@@ -550,6 +550,72 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack>
)}
{this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing">
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Analytical store
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.getAnalyticalStorageTooltipContent()}
>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="panelRadioBtn"
checked={this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Off</span>
</Stack>
{!this.isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.{" "}
<Link href="https://aka.ms/cosmosdb-synapselink" target="_blank">
Learn more
</Link>
</Text>
<DefaultButton
text="Enable"
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
</Stack>
)}
</Stack>
)}
{userContext.apiType !== "Tables" && (
<CollapsibleSectionComponent
title="Advanced"
@@ -604,72 +670,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
/>
)}
{this.shouldShowAnalyticalStoreOptions() && (
<Stack className="panelGroupSpacing">
<Stack horizontal>
<Text className="panelTextBold" variant="small">
Analytical store
</Text>
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={this.getAnalyticalStorageTooltipContent()}
>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="panelRadioBtn"
checked={this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Enable analytical store"
aria-checked={this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="enableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">On</span>
<input
className="panelRadioBtn"
checked={!this.state.enableAnalyticalStore}
disabled={!this.isSynapseLinkEnabled()}
aria-label="Disable analytical store"
aria-checked={!this.state.enableAnalyticalStore}
name="analyticalStore"
type="radio"
role="radio"
id="disableAnalyticalStoreBtn"
tabIndex={0}
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
/>
<span className="panelRadioBtnLabel">Off</span>
</Stack>
{!this.isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.{" "}
<Link href="https://aka.ms/cosmosdb-synapselink" target="_blank">
Learn more
</Link>
</Text>
<DefaultButton
text="Enable"
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
</Stack>
)}
</Stack>
)}
</Stack>
</CollapsibleSectionComponent>
)}

View File

@@ -5,6 +5,7 @@ import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils
import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService";
import { useSidePanel } from "../../../hooks/useSidePanel";
import { IPinnedRepo, JunoClient } from "../../../Juno/JunoClient";
import { userContext } from "../../../UserContext";
import * as GitHubUtils from "../../../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import Explorer from "../../Explorer";
@@ -75,6 +76,8 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
selectedLocation.owner,
selectedLocation.repo
)} - ${selectedLocation.branch}`;
} else if (selectedLocation.type === "MyNotebooks" && userContext.features.phoenix) {
destination = "My Notebooks Scratch";
}
clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${name} to ${destination}`);

View File

@@ -12,6 +12,7 @@ import {
import React, { FormEvent, FunctionComponent } from "react";
import { IPinnedRepo } from "../../../Juno/JunoClient";
import * as GitHubUtils from "../../../Utils/GitHubUtils";
import { useNotebook } from "../../Notebook/useNotebook";
import { ResourceTreeAdapter } from "../../Tree/ResourceTreeAdapter";
interface Location {
@@ -46,11 +47,10 @@ export const CopyNotebookPaneComponent: FunctionComponent<CopyNotebookPaneProps>
const getDropDownOptions = (): IDropdownOption[] => {
const options: IDropdownOption[] = [];
options.push({
key: "MyNotebooks-Item",
text: ResourceTreeAdapter.MyNotebooksTitle,
title: ResourceTreeAdapter.MyNotebooksTitle,
text: useNotebook.getState().notebookFolderName,
title: useNotebook.getState().notebookFolderName,
data: {
type: "MyNotebooks",
} as Location,

View File

@@ -23,6 +23,7 @@ exports[`GitHub Repos Panel should render Default properly 1`] = `
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],

View File

@@ -13,6 +13,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],

View File

@@ -1,341 +0,0 @@
import React from "react";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { useTabs } from "../../hooks/useTabs";
import { userContext } from "../../UserContext";
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook";
import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
interface DatabasesResourceTreeProps {
container: Explorer;
}
export const DatabasesResourceTree: React.FC<DatabasesResourceTreeProps> = ({
container,
}: DatabasesResourceTreeProps): JSX.Element => {
const databases = useDatabases((state) => state.databases);
const isNotebookEnabled = useNotebook((state) => state.isNotebookEnabled);
const gitHubNotebooksContentRoot = useNotebook((state) => state.gitHubNotebooksContentRoot);
const showScriptNodes = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
const refreshActiveTab = useTabs.getState().refreshActiveTab;
const buildDataTree = (): TreeNode => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
isExpanded: false,
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onClick: async (isExpanded) => {
useSelectedNode.getState().setSelectedNode(database);
// Rewritten version of expandCollapseDatabase():
if (isExpanded) {
database.collapseDatabase();
} else {
if (databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
}
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
};
if (database.isDatabaseShared()) {
databaseNode.children.push({
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection))
);
database.collections.subscribe((collections: ViewModels.Collection[]) => {
collections.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection))
);
});
return databaseNode;
});
return {
label: undefined,
isExpanded: true,
children: databaseTreeNodes,
};
};
const buildCollectionNode = (database: ViewModels.Database, collection: ViewModels.Collection): TreeNode => {
const children: TreeNode[] = [];
children.push({
label: collection.getLabel(),
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.Documents,
ViewModels.CollectionTabKind.Graph,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
});
if (isNotebookEnabled && userContext.apiType === "Mongo" && isPublicInternetAccessAllowed()) {
children.push({
label: "Schema (Preview)",
onClick: collection.onSchemaAnalyzerClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.SchemaAnalyzer]),
});
}
if (userContext.apiType !== "Cassandra" || !isServerlessAccount()) {
children.push({
label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.CollectionSettingsV2,
]),
});
}
const schemaNode: TreeNode = buildSchemaNode(collection);
if (schemaNode) {
children.push(schemaNode);
}
if (showScriptNodes) {
children.push(buildStoredProcedureNode(collection));
children.push(buildUserDefinedFunctionsNode(collection));
children.push(buildTriggerNode(collection));
}
// This is a rewrite of showConflicts
const showConflicts =
userContext?.databaseAccount?.properties.enableMultipleWriteLocations &&
collection.rawDataModel &&
!!collection.rawDataModel.conflictResolutionPolicy;
if (showConflicts) {
children.push({
label: "Conflicts",
onClick: collection.onConflictsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Conflicts]),
});
}
return {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: false,
children: children,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
onExpanded: () => {
if (showScriptNodes) {
collection.loadStoredProcedures();
collection.loadUserDefinedFunctions();
collection.loadTriggers();
}
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
};
};
const buildStoredProcedureNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Stored Procedures",
children: collection.storedProcedures().map((sp: StoredProcedure) => ({
label: sp.id(),
onClick: sp.open.bind(sp),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.StoredProcedures,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createStoreProcedureContextMenuItems(container, sp),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildUserDefinedFunctionsNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({
label: udf.id(),
onClick: udf.open.bind(udf),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.UserDefinedFunctions,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createUserDefinedFunctionContextMenuItems(container, udf),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildTriggerNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Triggers",
children: collection.triggers().map((trigger: Trigger) => ({
label: trigger.id(),
onClick: trigger.open.bind(trigger),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Triggers]),
contextMenu: ResourceTreeContextMenuButtonFactory.createTriggerContextMenuItems(container, trigger),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildSchemaNode = (collection: ViewModels.Collection): TreeNode => {
if (collection.analyticalStorageTtl() === undefined) {
return undefined;
}
if (!collection.schema || !collection.schema.fields) {
return undefined;
}
return {
label: "Schema",
children: getSchemaNodes(collection.schema.fields),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Schema);
refreshActiveTab((tab: TabsBase) => tab.collection && tab.collection.rid === collection.rid);
},
};
};
const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode[] => {
const schema: any = {};
//unflatten
fields.forEach((field: DataModels.IDataField) => {
const path: string[] = field.path.split(".");
const fieldProperties = [field.dataType.name, `HasNulls: ${field.hasNulls}`];
let current: any = {};
path.forEach((name: string, pathIndex: number) => {
if (pathIndex === 0) {
if (schema[name] === undefined) {
if (pathIndex === path.length - 1) {
schema[name] = fieldProperties;
} else {
schema[name] = {};
}
}
current = schema[name];
} else {
if (current[name] === undefined) {
if (pathIndex === path.length - 1) {
current[name] = fieldProperties;
} else {
current[name] = {};
}
}
current = current[name];
}
});
});
const traverse = (obj: any): TreeNode[] => {
const children: TreeNode[] = [];
if (obj !== undefined && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => {
children.push({ label: key, children: traverse(value) });
});
} else if (Array.isArray(obj)) {
return [{ label: obj[0] }, { label: obj[1] }];
}
return children;
};
return traverse(schema);
};
return (
<AccordionItemComponent title={"DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={buildDataTree()} />
</AccordionItemComponent>
);
};

View File

@@ -1,436 +0,0 @@
import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react";
import * as React from "react";
import shallow from "zustand/shallow";
import DeleteIcon from "../../../images/delete.svg";
import GalleryIcon from "../../../images/GalleryIcon.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg";
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import PublishIcon from "../../../images/notebook/publish_content.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import { Areas, Notebook } from "../../Common/Constants";
import * as ViewModels from "../../Contracts/ViewModels";
import { useSidePanel } from "../../hooks/useSidePanel";
import { useTabs } from "../../hooks/useTabs";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { useDialog } from "../Controls/Dialog";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook";
import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
export const MyNotebooksTitle = "My Notebooks";
export const GitHubReposTitle = "GitHub repos";
interface NotebooksResourceTreeProps {
container: Explorer;
}
export const NotebooksResourceTree: React.FC<NotebooksResourceTreeProps> = ({
container,
}: NotebooksResourceTreeProps): JSX.Element => {
const {
isNotebookEnabled,
myNotebooksContentRoot,
galleryContentRoot,
gitHubNotebooksContentRoot,
updateNotebookItem,
} = useNotebook(
(state) => ({
isNotebookEnabled: state.isNotebookEnabled,
myNotebooksContentRoot: state.myNotebooksContentRoot,
galleryContentRoot: state.galleryContentRoot,
gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot,
updateNotebookItem: state.updateNotebookItem,
}),
shallow
);
const activeTab = useTabs((state) => state.activeTab);
const pseudoDirPath = "PsuedoDir";
const buildGalleryCallout = (): JSX.Element => {
if (
LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) &&
LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed)
) {
return undefined;
}
const calloutProps: ICalloutProps = {
calloutMaxWidth: 350,
ariaLabel: "New gallery",
role: "alertdialog",
gapSpace: 0,
target: ".galleryHeader",
directionalHint: DirectionalHint.leftTopEdge,
onDismiss: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
},
setInitialFocus: true,
};
const openGalleryProps: ILinkProps = {
onClick: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
container.openGallery();
},
};
return (
<Callout {...calloutProps}>
<Stack tokens={{ childrenGap: 10, padding: 20 }}>
<Text variant="xLarge" block>
New gallery
</Text>
<Text block>
Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other
contributors.
</Text>
<Link {...openGalleryProps}>Open gallery</Link>
</Stack>
</Callout>
);
};
const buildNotebooksTree = (): TreeNode => {
const notebooksTree: TreeNode = {
label: undefined,
isExpanded: true,
children: [],
};
if (userContext.features.notebooksTemporarilyDown) {
notebooksTree.children.push(buildNotebooksTemporarilyDownTree());
} else {
if (galleryContentRoot) {
notebooksTree.children.push(buildGalleryNotebooksTree());
}
if (myNotebooksContentRoot) {
notebooksTree.children.push(buildMyNotebooksTree());
}
if (container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
// collapse all other notebook nodes
notebooksTree.children.forEach((node) => (node.isExpanded = false));
notebooksTree.children.push(buildGitHubNotebooksTree());
}
}
return notebooksTree;
};
const buildNotebooksTemporarilyDownTree = (): TreeNode => {
return {
label: Notebook.temporarilyDownMsg,
className: "clickDisabled",
};
};
const buildGalleryNotebooksTree = (): TreeNode => {
return {
label: "Gallery",
iconSrc: GalleryIcon,
className: "notebookHeader galleryHeader",
onClick: () => container.openGallery(),
isSelected: () => activeTab?.tabKind === ViewModels.CollectionTabKind.Gallery,
};
};
const buildMyNotebooksTree = (): TreeNode => {
const myNotebooksTree: TreeNode = buildNotebookDirectoryNode(
myNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
}
);
myNotebooksTree.isExpanded = true;
myNotebooksTree.isAlphaSorted = true;
// Remove "Delete" menu item from context menu
myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter((menuItem) => menuItem.label !== "Delete");
return myNotebooksTree;
};
const buildGitHubNotebooksTree = (): TreeNode => {
const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode(
gitHubNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
},
true
);
gitHubNotebooksTree.contextMenu = [
{
label: "Manage GitHub settings",
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Manage GitHub settings",
<GitHubReposPanel
explorer={container}
gitHubClientProp={container.notebookManager.gitHubClient}
junoClientProp={container.notebookManager.junoClient}
/>
),
},
{
label: "Disconnect from GitHub",
onClick: () => {
TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, {
dataExplorerArea: Areas.Notebook,
});
container.notebookManager?.gitHubOAuthService.logout();
},
},
];
gitHubNotebooksTree.isExpanded = true;
gitHubNotebooksTree.isAlphaSorted = true;
return gitHubNotebooksTree;
};
const buildChildNodes = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode[] => {
if (!item || !item.children) {
return [];
} else {
return item.children.map((item) => {
const result =
item.type === NotebookContentItemType.Directory
? buildNotebookDirectoryNode(item, onFileClick, isGithubTree)
: buildNotebookFileNode(item, onFileClick, isGithubTree);
result.timestamp = item.timestamp;
return result;
});
}
};
const buildNotebookFileNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode => {
return {
label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
className: "notebookHeader",
onClick: () => onFileClick(item),
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: createFileContextMenu(container, item, isGithubTree),
data: item,
};
};
const createFileContextMenu = (
container: Explorer,
item: NotebookContentItem,
isGithubTree?: boolean
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined
);
},
},
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => copyNotebook(container, item),
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => container.downloadFile(item),
},
];
if (item.type === NotebookContentItemType.Notebook) {
items.push({
label: "Publish to gallery",
iconSrc: PublishIcon,
onClick: async () => {
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
source: Source.ResourceTreeMenu,
});
const content = await container.readFile(item);
if (content) {
await container.publishNotebook(item.name, content);
}
},
});
}
// "Copy to ..." isn't needed if github locations are not available
if (!container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
items = items.filter((item) => item.label !== "Copy to ...");
}
return items;
};
const copyNotebook = async (container: Explorer, item: NotebookContentItem) => {
const content = await container.readFile(item);
if (content) {
container.copyNotebook(item.name, content);
}
};
const createDirectoryContextMenu = (
container: Explorer,
item: NotebookContentItem,
isGithubTree?: boolean
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Refresh",
iconSrc: RefreshIcon,
onClick: () => loadSubitems(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}?"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined
);
},
},
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "New Directory",
iconSrc: NewNotebookIcon,
onClick: () => container.onCreateDirectory(item, isGithubTree),
},
{
label: "New Notebook",
iconSrc: NewNotebookIcon,
onClick: () => container.onNewNotebookClicked(item, isGithubTree),
},
{
label: "Upload File",
iconSrc: NewNotebookIcon,
onClick: () => container.openUploadFilePanel(item),
},
];
// For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File"
if (GitHubUtils.fromContentUri(item.path)) {
items = items.filter(
(item) =>
item.label !== "Delete" &&
item.label !== "Rename" &&
item.label !== "New Directory" &&
item.label !== "Upload File"
);
}
return items;
};
const buildNotebookDirectoryNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode => {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: () => {
if (!item.children) {
loadSubitems(item, isGithubTree);
}
},
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item, isGithubTree) : undefined,
data: item,
children: buildChildNodes(item, onFileClick, isGithubTree),
};
};
const loadSubitems = async (item: NotebookContentItem, isGithubTree?: boolean): Promise<void> => {
const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item);
updateNotebookItem(updatedItem, isGithubTree);
};
return isNotebookEnabled ? (
<AccordionItemComponent title={"NOTEBOOKS"}>
<TreeComponent className="notebookResourceTree" rootNode={buildNotebooksTree()} />
{buildGalleryCallout()}
</AccordionItemComponent>
) : (
<></>
);
};

View File

@@ -1,18 +1,777 @@
import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react";
import * as React from "react";
import { AccordionComponent } from "../Controls/Accordion/AccordionComponent";
import shallow from "zustand/shallow";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import DeleteIcon from "../../../images/delete.svg";
import GalleryIcon from "../../../images/GalleryIcon.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg";
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import PublishIcon from "../../../images/notebook/publish_content.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { Areas, ConnectionStatusType, Notebook } from "../../Common/Constants";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { useSidePanel } from "../../hooks/useSidePanel";
import { useTabs } from "../../hooks/useTabs";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { useDialog } from "../Controls/Dialog";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer";
import { DatabasesResourceTree } from "./DatabasesResourceTree";
import { NotebooksResourceTree } from "./NotebooksResourceTree";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook";
import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
export const MyNotebooksTitle = "My Notebooks";
export const GitHubReposTitle = "GitHub repos";
interface ResourceTreeProps {
container: Explorer;
}
export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => {
return (
<AccordionComponent>
<DatabasesResourceTree container={container} />
<NotebooksResourceTree container={container} />
</AccordionComponent>
const databases = useDatabases((state) => state.databases);
const {
isNotebookEnabled,
myNotebooksContentRoot,
galleryContentRoot,
gitHubNotebooksContentRoot,
updateNotebookItem,
} = useNotebook(
(state) => ({
isNotebookEnabled: state.isNotebookEnabled,
myNotebooksContentRoot: state.myNotebooksContentRoot,
galleryContentRoot: state.galleryContentRoot,
gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot,
updateNotebookItem: state.updateNotebookItem,
}),
shallow
);
const { activeTab, refreshActiveTab } = useTabs();
const showScriptNodes = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
const pseudoDirPath = "PsuedoDir";
const buildGalleryCallout = (): JSX.Element => {
if (
LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) &&
LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed)
) {
return undefined;
}
const calloutProps: ICalloutProps = {
calloutMaxWidth: 350,
ariaLabel: "New gallery",
role: "alertdialog",
gapSpace: 0,
target: ".galleryHeader",
directionalHint: DirectionalHint.leftTopEdge,
onDismiss: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
},
setInitialFocus: true,
};
const openGalleryProps: ILinkProps = {
onClick: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
container.openGallery();
},
};
return (
<Callout {...calloutProps}>
<Stack tokens={{ childrenGap: 10, padding: 20 }}>
<Text variant="xLarge" block>
New gallery
</Text>
<Text block>
Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other
contributors.
</Text>
<Link {...openGalleryProps}>Open gallery</Link>
</Stack>
</Callout>
);
};
const buildNotebooksTree = (): TreeNode => {
const notebooksTree: TreeNode = {
label: undefined,
isExpanded: true,
children: [],
};
if (userContext.features.notebooksTemporarilyDown) {
notebooksTree.children.push(buildNotebooksTemporarilyDownTree());
} else {
if (galleryContentRoot) {
notebooksTree.children.push(buildGalleryNotebooksTree());
}
if (myNotebooksContentRoot && useNotebook.getState().connectionInfo.status == ConnectionStatusType.Connected) {
notebooksTree.children.push(buildMyNotebooksTree());
}
if (container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
// collapse all other notebook nodes
notebooksTree.children.forEach((node) => (node.isExpanded = false));
notebooksTree.children.push(buildGitHubNotebooksTree(true));
}
}
return notebooksTree;
};
const buildNotebooksTemporarilyDownTree = (): TreeNode => {
return {
label: Notebook.temporarilyDownMsg,
className: "clickDisabled",
};
};
const buildGalleryNotebooksTree = (): TreeNode => {
return {
label: "Gallery",
iconSrc: GalleryIcon,
className: "notebookHeader galleryHeader",
onClick: () => container.openGallery(),
isSelected: () => activeTab?.tabKind === ViewModels.CollectionTabKind.Gallery,
};
};
const buildMyNotebooksTree = (): TreeNode => {
const myNotebooksTree: TreeNode = buildNotebookDirectoryNode(
myNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
}
);
myNotebooksTree.isExpanded = true;
myNotebooksTree.isAlphaSorted = true;
// Remove "Delete" menu item from context menu
myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter((menuItem) => menuItem.label !== "Delete");
return myNotebooksTree;
};
const buildGitHubNotebooksTree = (isConnected: boolean): TreeNode => {
const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode(
gitHubNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
},
true
);
const manageGitContextMenu: TreeNodeMenuItem[] = [
{
label: "Manage GitHub settings",
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Manage GitHub settings",
<GitHubReposPanel
explorer={container}
gitHubClientProp={container.notebookManager.gitHubClient}
junoClientProp={container.notebookManager.junoClient}
/>
),
},
{
label: "Disconnect from GitHub",
onClick: () => {
TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, {
dataExplorerArea: Areas.Notebook,
});
container.notebookManager?.gitHubOAuthService.logout();
},
},
];
const connectGitContextMenu: TreeNodeMenuItem[] = [
{
label: "Connect to GitHub",
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Connect to GitHub",
<GitHubReposPanel
explorer={container}
gitHubClientProp={container.notebookManager.gitHubClient}
junoClientProp={container.notebookManager.junoClient}
/>
),
},
];
gitHubNotebooksTree.contextMenu = isConnected ? manageGitContextMenu : connectGitContextMenu;
gitHubNotebooksTree.isExpanded = true;
gitHubNotebooksTree.isAlphaSorted = true;
return gitHubNotebooksTree;
};
const buildChildNodes = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode[] => {
if (!item || !item.children) {
return [];
} else {
return item.children.map((item) => {
const result =
item.type === NotebookContentItemType.Directory
? buildNotebookDirectoryNode(item, onFileClick, isGithubTree)
: buildNotebookFileNode(item, onFileClick, isGithubTree);
result.timestamp = item.timestamp;
return result;
});
}
};
const buildNotebookFileNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode => {
return {
label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
className: "notebookHeader",
onClick: () => onFileClick(item),
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: createFileContextMenu(container, item, isGithubTree),
data: item,
};
};
const createFileContextMenu = (
container: Explorer,
item: NotebookContentItem,
isGithubTree?: boolean
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined
);
},
},
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => copyNotebook(container, item),
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => container.downloadFile(item),
},
];
if (item.type === NotebookContentItemType.Notebook) {
items.push({
label: "Publish to gallery",
iconSrc: PublishIcon,
onClick: async () => {
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
source: Source.ResourceTreeMenu,
});
const content = await container.readFile(item);
if (content) {
await container.publishNotebook(item.name, content);
}
},
});
}
// "Copy to ..." isn't needed if github locations are not available
if (!container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
items = items.filter((item) => item.label !== "Copy to ...");
}
return items;
};
const copyNotebook = async (container: Explorer, item: NotebookContentItem) => {
const content = await container.readFile(item);
if (content) {
container.copyNotebook(item.name, content);
}
};
const createDirectoryContextMenu = (
container: Explorer,
item: NotebookContentItem,
isGithubTree?: boolean
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Refresh",
iconSrc: RefreshIcon,
onClick: () => loadSubitems(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}?"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined
);
},
},
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "New Directory",
iconSrc: NewNotebookIcon,
onClick: () => container.onCreateDirectory(item, isGithubTree),
},
{
label: "New Notebook",
iconSrc: NewNotebookIcon,
onClick: () => container.onNewNotebookClicked(item, isGithubTree),
},
{
label: "Upload File",
iconSrc: NewNotebookIcon,
onClick: () => container.openUploadFilePanel(item),
},
];
// For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File"
if (GitHubUtils.fromContentUri(item.path)) {
items = items.filter(
(item) =>
item.label !== "Delete" &&
item.label !== "Rename" &&
item.label !== "New Directory" &&
item.label !== "Upload File"
);
}
return items;
};
const buildNotebookDirectoryNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean
): TreeNode => {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: () => {
if (!item.children) {
loadSubitems(item, isGithubTree);
}
},
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item, isGithubTree) : undefined,
data: item,
children: buildChildNodes(item, onFileClick, isGithubTree),
};
};
const buildDataTree = (): TreeNode => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
isExpanded: false,
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onClick: async (isExpanded) => {
useSelectedNode.getState().setSelectedNode(database);
// Rewritten version of expandCollapseDatabase():
if (isExpanded) {
database.collapseDatabase();
} else {
if (databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
}
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
};
if (database.isDatabaseShared()) {
databaseNode.children.push({
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection))
);
database.collections.subscribe((collections: ViewModels.Collection[]) => {
collections.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection))
);
});
return databaseNode;
});
return {
label: undefined,
isExpanded: true,
children: databaseTreeNodes,
};
};
const buildCollectionNode = (database: ViewModels.Database, collection: ViewModels.Collection): TreeNode => {
const children: TreeNode[] = [];
children.push({
label: collection.getLabel(),
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.Documents,
ViewModels.CollectionTabKind.Graph,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
});
if (
isNotebookEnabled &&
userContext.apiType === "Mongo" &&
isPublicInternetAccessAllowed() &&
!userContext.features.notebooksTemporarilyDown
) {
children.push({
label: "Schema (Preview)",
onClick: collection.onSchemaAnalyzerClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.SchemaAnalyzer]),
});
}
if (userContext.apiType !== "Cassandra" || !isServerlessAccount()) {
children.push({
label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.CollectionSettingsV2,
]),
});
}
const schemaNode: TreeNode = buildSchemaNode(collection);
if (schemaNode) {
children.push(schemaNode);
}
if (showScriptNodes) {
children.push(buildStoredProcedureNode(collection));
children.push(buildUserDefinedFunctionsNode(collection));
children.push(buildTriggerNode(collection));
}
// This is a rewrite of showConflicts
const showConflicts =
userContext?.databaseAccount?.properties.enableMultipleWriteLocations &&
collection.rawDataModel &&
!!collection.rawDataModel.conflictResolutionPolicy;
if (showConflicts) {
children.push({
label: "Conflicts",
onClick: collection.onConflictsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Conflicts]),
});
}
return {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: false,
children: children,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
onExpanded: () => {
if (showScriptNodes) {
collection.loadStoredProcedures();
collection.loadUserDefinedFunctions();
collection.loadTriggers();
}
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
};
};
const buildStoredProcedureNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Stored Procedures",
children: collection.storedProcedures().map((sp: StoredProcedure) => ({
label: sp.id(),
onClick: sp.open.bind(sp),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.StoredProcedures,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createStoreProcedureContextMenuItems(container, sp),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildUserDefinedFunctionsNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({
label: udf.id(),
onClick: udf.open.bind(udf),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.UserDefinedFunctions,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createUserDefinedFunctionContextMenuItems(container, udf),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildTriggerNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Triggers",
children: collection.triggers().map((trigger: Trigger) => ({
label: trigger.id(),
onClick: trigger.open.bind(trigger),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Triggers]),
contextMenu: ResourceTreeContextMenuButtonFactory.createTriggerContextMenuItems(container, trigger),
})),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
},
};
};
const buildSchemaNode = (collection: ViewModels.Collection): TreeNode => {
if (collection.analyticalStorageTtl() === undefined) {
return undefined;
}
if (!collection.schema || !collection.schema.fields) {
return undefined;
}
return {
label: "Schema",
children: getSchemaNodes(collection.schema.fields),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Schema);
refreshActiveTab((tab: TabsBase) => tab.collection && tab.collection.rid === collection.rid);
},
};
};
const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode[] => {
const schema: any = {};
//unflatten
fields.forEach((field: DataModels.IDataField) => {
const path: string[] = field.path.split(".");
const fieldProperties = [field.dataType.name, `HasNulls: ${field.hasNulls}`];
let current: any = {};
path.forEach((name: string, pathIndex: number) => {
if (pathIndex === 0) {
if (schema[name] === undefined) {
if (pathIndex === path.length - 1) {
schema[name] = fieldProperties;
} else {
schema[name] = {};
}
}
current = schema[name];
} else {
if (current[name] === undefined) {
if (pathIndex === path.length - 1) {
current[name] = fieldProperties;
} else {
current[name] = {};
}
}
current = current[name];
}
});
});
const traverse = (obj: any): TreeNode[] => {
const children: TreeNode[] = [];
if (obj !== undefined && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => {
children.push({ label: key, children: traverse(value) });
});
} else if (Array.isArray(obj)) {
return [{ label: obj[0] }, { label: obj[1] }];
}
return children;
};
return traverse(schema);
};
const loadSubitems = async (item: NotebookContentItem, isGithubTree?: boolean): Promise<void> => {
const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item);
updateNotebookItem(updatedItem, isGithubTree);
};
const dataRootNode = buildDataTree();
if (isNotebookEnabled) {
return (
<>
<AccordionComponent>
<AccordionItemComponent title={"DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={"NOTEBOOKS"}>
<TreeComponent className="notebookResourceTree" rootNode={buildNotebooksTree()} />
</AccordionItemComponent>
</AccordionComponent>
{buildGalleryCallout()}
</>
);
}
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
};

View File

@@ -45,6 +45,7 @@ import UserDefinedFunction from "./UserDefinedFunction";
export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
public static readonly MyNotebooksScratchTitle = "My Notebooks Scratch";
public static readonly GitHubReposTitle = "GitHub repos";
private static readonly DataTitle = "DATA";
@@ -130,9 +131,8 @@ export class ResourceTreeAdapter implements ReactAdapter {
path: "Gallery",
type: NotebookContentItemType.File,
};
this.myNotebooksContentRoot = {
name: ResourceTreeAdapter.MyNotebooksTitle,
name: useNotebook.getState().notebookFolderName,
path: useNotebook.getState().notebookBasePath,
type: NotebookContentItemType.Directory,
};
@@ -146,16 +146,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
})
);
}
if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
this.gitHubNotebooksContentRoot = {
name: ResourceTreeAdapter.GitHubReposTitle,
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory,
};
} else {
this.gitHubNotebooksContentRoot = undefined;
}
this.gitHubNotebooksContentRoot = {
name: ResourceTreeAdapter.GitHubReposTitle,
path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory,
};
return Promise.all(refreshTasks);
}

View File

@@ -0,0 +1,57 @@
{
"GraphAPIDescription": "Provision a Graph API Compute for your Azure Cosmos DB account.",
"GraphAPICompute": "Graph GraphAPI Compute",
"Provisioned": "Provisioned",
"Deprovisioned": "Deprovisioned",
"Compute": "Compute",
"GremlinV2": "GremlinV2",
"LearnAboutCompute": "Learn more about GraphAPI Compute.",
"DeprovisioningDetailsText": "Learn more about deprovisioning the GraphAPI Compute.",
"ComputePricing": "Learn more about GraphAPI Compute pricing.",
"SKUs": "SKUs",
"SKUsPlaceHolder": "Select SKUs",
"NumberOfInstances": "Number of instances",
"CosmosD4s": "Cosmos.D4s (General Purpose Cosmos Compute with 4 vCPUs, 16 GB Memory)",
"CosmosD8s": "Cosmos.D8s (General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory)",
"CosmosD16s": "Cosmos.D16s (General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory)",
"CosmosD32s": "Cosmos.D32s (General Purpose Cosmos Compute with 32 vCPUs, 128 GB Memory)",
"CreateMessage": "Graph GraphAPI Compute resource is being created.",
"CreateInitializeTitle": "Provisioning resource",
"CreateInitializeMessage": "GraphAPI Compute resource will be provisioned.",
"CreateSuccessTitle": "Resource provisioned",
"CreateSuccesseMessage": "GraphAPI Compute resource provisioned.",
"CreateFailureTitle": "Failed to provision resource",
"CreateFailureMessage": "GraphAPI Compute resource provisioning failed.",
"UpdateMessage": "GraphAPI Compute resource is being updated.",
"UpdateInitializeTitle": "Updating resource",
"UpdateInitializeMessage": "GraphAPI Compute resource will be updated.",
"UpdateSuccessTitle": "Resource updated",
"UpdateSuccesseMessage": "GraphAPI Compute resource updated.",
"UpdateFailureTitle": "Failed to update resource",
"UpdateFailureMessage": "GraphAPI Compute resource updation failed.",
"DeleteMessage": "GraphAPI Compute resource is being deleted.",
"DeleteInitializeTitle": "Deleting resource",
"DeleteInitializeMessage": "GraphAPI Compute resource will be deleted.",
"DeleteSuccessTitle": "Resource deleted",
"DeleteSuccesseMessage": "GraphAPI Compute resource deleted.",
"DeleteFailureTitle": "Failed to delete resource",
"DeleteFailureMessage": "GraphAPI Compute resource deletion failed.",
"CannotSave": "Cannot save the changes to the GraphAPI Compute resource at the moment.",
"GraphAccountEndpoint": "Graph Account Endpoint",
"CosmosD4Details": "General Purpose Cosmos Compute with 4 vCPUs, 16 GB Memory",
"CosmosD8Details": "General Purpose Cosmos Compute with 8 vCPUs, 32 GB Memory",
"CosmosD16Details": "General Purpose Cosmos Compute with 16 vCPUs, 64 GB Memory",
"ApproximateCost": "Approximate Cost Per Hour",
"CostText": "Hourly cost of the GraphAPI Compute resource depends on the SKU selection, number of instances per region, and number of regions.",
"ConnectionString": "Connection String",
"ConnectionStringText": "To use the GraphAPI Compute, use the connection string shown in ",
"KeysBlade": "the keys blade.",
"MetricsString": "Metrics",
"MetricsText": "Monitor the CPU and memory usage for the GraphAPI Compute instances in ",
"MetricsBlade": "the metrics blade.",
"MonitorUsage": "Monitor Usage",
"ResizingDecisionText": "Number of instances has to be 1 during provisioning. Instances can only be incremented by 1 at once. ",
"ResizingDecisionLink": "Learn more about GraphAPI Compute sizing.",
"WarningBannerOnUpdate": "Adding or modifying GraphAPI Compute instances may affect your bill.",
"WarningBannerOnDelete": "After deprovisioning the GraphAPI Compute, you will not be able to connect to the Graph API account."
}

View File

@@ -37,6 +37,7 @@ import "./Explorer/Controls/TreeComponent/treeComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less";
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
import { NotificationConsole } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";

View File

@@ -0,0 +1,85 @@
import { ConnectionStatusType, HttpHeaders, HttpStatusCodes } from "../Common/Constants";
import { configContext } from "../ConfigContext";
import { ContainerConnectionInfo } from "../Contracts/DataModels";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
export interface IPhoenixResponse<T> {
status: number;
data: T;
}
export interface IPhoenixConnectionInfoResult {
readonly notebookAuthToken?: string;
readonly notebookServerUrl?: string;
}
export interface IProvosionData {
cosmosEndpoint: string;
resourceId: string;
dbAccountName: string;
aadToken: string;
resourceGroup: string;
subscriptionId: string;
}
export class PhoenixClient {
public async containerConnectionInfo(
provisionData: IProvosionData
): Promise<IPhoenixResponse<IPhoenixConnectionInfoResult>> {
try {
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Connecting,
};
useNotebook.getState().setConnectionInfo(connectionStatus);
const response = await window.fetch(`${this.getPhoenixContainerPoolingEndPoint()}/provision`, {
method: "POST",
headers: PhoenixClient.getHeaders(),
body: JSON.stringify(provisionData),
});
let data: IPhoenixConnectionInfoResult;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
if (data && data.notebookServerUrl) {
connectionStatus.status = ConnectionStatusType.Connected;
useNotebook.getState().setConnectionInfo(connectionStatus);
}
} else {
connectionStatus.status = ConnectionStatusType.Failed;
useNotebook.getState().setConnectionInfo(connectionStatus);
}
return {
status: response.status,
data,
};
} catch (error) {
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Failed,
};
useNotebook.getState().setConnectionInfo(connectionStatus);
console.error(error);
throw error;
}
}
public static getPhoenixEndpoint(): string {
const phoenixEndpoint = userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT;
if (configContext.allowedJunoOrigins.indexOf(new URL(phoenixEndpoint).origin) === -1) {
const error = `${phoenixEndpoint} not allowed as juno endpoint`;
console.error(error);
throw new Error(error);
}
return phoenixEndpoint;
}
public getPhoenixContainerPoolingEndPoint(): string {
return `${PhoenixClient.getPhoenixEndpoint()}/api/containerpooling`;
}
private static getHeaders(): HeadersInit {
const authorizationHeader = getAuthorizationHeader();
return {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
};
}
}

View File

@@ -1,4 +1,4 @@
import { extractFeatures } from "./extractFeatures";
import { extractFeatures, hasFlag } from "./extractFeatures";
describe("extractFeatures", () => {
it("correctly detects feature flags in a case insensitive manner", () => {
@@ -14,9 +14,25 @@ describe("extractFeatures", () => {
});
const features = extractFeatures(params);
expect(features.notebookServerUrl).toBe(url);
expect(features.notebookServerToken).toBe(token);
expect(features.enableNotebooks).toBe(notebooksEnabled);
});
});
describe("hasFlag", () => {
it("correctly determines if value has flag", () => {
const desiredFlag = "readDocument";
const singleFlagValue = "readDocument";
const multipleFlagValues = "readDocument|createDocument";
const differentFlagValue = "createDocument";
expect(hasFlag(singleFlagValue, desiredFlag)).toBe(true);
expect(hasFlag(multipleFlagValues, desiredFlag)).toBe(true);
expect(hasFlag(differentFlagValue, desiredFlag)).toBe(false);
expect(hasFlag(multipleFlagValues, (undefined as unknown) as string)).toBe(false);
expect(hasFlag((undefined as unknown) as string, desiredFlag)).toBe(false);
expect(hasFlag((undefined as unknown) as string, (undefined as unknown) as string)).toBe(false);
});
});

View File

@@ -11,11 +11,13 @@ export type Features = {
autoscaleDefault: boolean;
partitionKeyDefault: boolean;
partitionKeyDefault2: boolean;
phoenix: boolean;
readonly enableSDKoperations: boolean;
readonly enableSpark: boolean;
readonly enableTtl: boolean;
readonly executeSproc: boolean;
readonly enableAadDataPlane: boolean;
readonly enableKoResourceTree: boolean;
readonly hostedDataExplorer: boolean;
readonly junoEndpoint?: string;
readonly livyEndpoint?: string;
@@ -27,6 +29,8 @@ export type Features = {
readonly pr?: string;
readonly showMinRUSurvey: boolean;
readonly ttl90Days: boolean;
readonly mongoProxyEndpoint?: string;
readonly mongoProxyAPIs?: string;
readonly notebooksTemporarilyDown: boolean;
};
@@ -58,8 +62,11 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enableSDKoperations: "true" === get("enablesdkoperations"),
enableSpark: "true" === get("enablespark"),
enableTtl: "true" === get("enablettl"),
enableKoResourceTree: "true" === get("enablekoresourcetree"),
executeSproc: "true" === get("dataexplorerexecutesproc"),
hostedDataExplorer: "true" === get("hosteddataexplorerenabled"),
mongoProxyEndpoint: get("mongoproxyendpoint"),
mongoProxyAPIs: get("mongoproxyapis"),
junoEndpoint: get("junoendpoint"),
livyEndpoint: get("livyendpoint"),
notebookBasePath: get("notebookbasepath"),
@@ -73,6 +80,16 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
autoscaleDefault: "true" === get("autoscaledefault"),
partitionKeyDefault: "true" === get("partitionkeytest"),
partitionKeyDefault2: "true" === get("pkpartitionkeytest"),
notebooksTemporarilyDown: "true" === get("notebooksTemporarilyDown", "true"),
notebooksTemporarilyDown: "true" === get("notebookstemporarilydown", "true"),
phoenix: "true" === get("phoenix"),
};
}
export function hasFlag(flags: string, desiredFlag: string): boolean {
if (!flags || !desiredFlag) {
return false;
}
const features = flags.split("|");
return features.find((feature) => feature === desiredFlag) ? true : false;
}

View File

@@ -0,0 +1,200 @@
import { configContext } from "../../ConfigContext";
import { userContext } from "../../UserContext";
import { armRequestWithoutPolling } from "../../Utils/arm/request";
import { selfServeTraceFailure, selfServeTraceStart, selfServeTraceSuccess } from "../SelfServeTelemetryProcessor";
import { RefreshResult } from "../SelfServeTypes";
import GraphAPICompute from "./GraphAPICompute";
import {
FetchPricesResponse,
RegionsResponse,
GraphAPIComputeServiceResource,
UpdateComputeRequestParameters,
} from "./GraphAPICompute.types";
const apiVersion = "2021-04-01-preview";
const gremlinV2 = "GremlinV2";
export enum ResourceStatus {
Running = "Running",
Creating = "Creating",
Updating = "Updating",
Deleting = "Deleting",
}
export interface ComputeResponse {
sku: string;
instances: number;
status: string;
endpoint: string;
}
export const getPath = (subscriptionId: string, resourceGroup: string, name: string): string => {
return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${name}/services/${gremlinV2}`;
};
export const updateComputeResource = async (sku: string, instances: number): Promise<string> => {
const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
const body: UpdateComputeRequestParameters = {
properties: {
instanceSize: sku,
instanceCount: instances,
serviceType: gremlinV2,
},
};
const telemetryData = { ...body, httpMethod: "PUT", selfServeClassName: GraphAPICompute.name };
const updateTimeStamp = selfServeTraceStart(telemetryData);
let armRequestResult;
try {
armRequestResult = await armRequestWithoutPolling({
host: configContext.ARM_ENDPOINT,
path,
method: "PUT",
apiVersion,
body,
});
selfServeTraceSuccess(telemetryData, updateTimeStamp);
} catch (e) {
const failureTelemetry = { ...body, e, selfServeClassName: GraphAPICompute.name };
selfServeTraceFailure(failureTelemetry, updateTimeStamp);
throw e;
}
return armRequestResult?.operationStatusUrl;
};
export const deleteComputeResource = async (): Promise<string> => {
const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
const telemetryData = { httpMethod: "DELETE", selfServeClassName: GraphAPICompute.name };
const deleteTimeStamp = selfServeTraceStart(telemetryData);
let armRequestResult;
try {
armRequestResult = await armRequestWithoutPolling({
host: configContext.ARM_ENDPOINT,
path,
method: "DELETE",
apiVersion,
});
selfServeTraceSuccess(telemetryData, deleteTimeStamp);
} catch (e) {
const failureTelemetry = { e, selfServeClassName: GraphAPICompute.name };
selfServeTraceFailure(failureTelemetry, deleteTimeStamp);
throw e;
}
return armRequestResult?.operationStatusUrl;
};
export const getComputeResource = async (): Promise<GraphAPIComputeServiceResource> => {
const path = getPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name);
const telemetryData = { httpMethod: "GET", selfServeClassName: GraphAPICompute.name };
const getResourceTimeStamp = selfServeTraceStart(telemetryData);
let armRequestResult;
try {
armRequestResult = await armRequestWithoutPolling<GraphAPIComputeServiceResource>({
host: configContext.ARM_ENDPOINT,
path,
method: "GET",
apiVersion,
});
selfServeTraceSuccess(telemetryData, getResourceTimeStamp);
} catch (e) {
const failureTelemetry = { e, selfServeClassName: GraphAPICompute.name };
selfServeTraceFailure(failureTelemetry, getResourceTimeStamp);
throw e;
}
return armRequestResult?.result;
};
export const getCurrentProvisioningState = async (): Promise<ComputeResponse> => {
try {
const response = await getComputeResource();
return {
sku: response.properties.instanceSize,
instances: response.properties.instanceCount,
status: response.properties.status,
endpoint: response.properties.GraphAPIComputeEndPoint,
};
} catch (e) {
return { sku: undefined, instances: undefined, status: undefined, endpoint: undefined };
}
};
export const refreshComputeProvisioning = async (): Promise<RefreshResult> => {
try {
const response = await getComputeResource();
if (response.properties.status === ResourceStatus.Running.toString()) {
return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined };
} else if (response.properties.status === ResourceStatus.Creating.toString()) {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "CreateMessage" };
} else if (response.properties.status === ResourceStatus.Deleting.toString()) {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "DeleteMessage" };
} else {
return { isUpdateInProgress: true, updateInProgressMessageTKey: "UpdateMessage" };
}
} catch {
//TODO differentiate between different failures
return { isUpdateInProgress: false, updateInProgressMessageTKey: undefined };
}
};
const getGeneralPath = (subscriptionId: string, resourceGroup: string, name: string): string => {
return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${name}`;
};
export const getReadRegions = async (): Promise<Array<string>> => {
try {
const readRegions = new Array<string>();
const response = await armRequestWithoutPolling<RegionsResponse>({
host: configContext.ARM_ENDPOINT,
path: getGeneralPath(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name),
method: "GET",
apiVersion: "2021-04-01-preview",
});
if (response.result.location !== undefined) {
readRegions.push(response.result.location.replace(" ", "").toLowerCase());
} else {
for (const location of response.result.locations) {
readRegions.push(location.locationName.replace(" ", "").toLowerCase());
}
}
return readRegions;
} catch (err) {
return new Array<string>();
}
};
const getFetchPricesPathForRegion = (subscriptionId: string): string => {
return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`;
};
export const getPriceMap = async (regions: Array<string>): Promise<Map<string, Map<string, number>>> => {
try {
const priceMap = new Map<string, Map<string, number>>();
for (const region of regions) {
const regionPriceMap = new Map<string, number>();
const response = await armRequestWithoutPolling<FetchPricesResponse>({
host: configContext.ARM_ENDPOINT,
path: getFetchPricesPathForRegion(userContext.subscriptionId),
method: "POST",
apiVersion: "2020-01-01-preview",
queryParams: {
filter:
"armRegionNameeq '" +
region +
"'andserviceFamilyeq 'Databases' and productName eq 'Azure Cosmos DB Dedicated Gateway - General Purpose'",
},
});
for (const item of response.result.Items) {
regionPriceMap.set(item.skuName, item.retailPrice);
}
priceMap.set(region, regionPriceMap);
}
return priceMap;
} catch (err) {
return undefined;
}
};

View File

@@ -0,0 +1,423 @@
import { IsDisplayable, OnChange, PropertyInfo, RefreshOptions, Values } from "../Decorators";
import { selfServeTrace } from "../SelfServeTelemetryProcessor";
import {
ChoiceItem,
Description,
DescriptionType,
Info,
InputType,
NumberUiType,
OnSaveResult,
RefreshResult,
SelfServeBaseClass,
SmartUiInput,
} from "../SelfServeTypes";
import { BladeType, generateBladeLink } from "../SelfServeUtils";
import {
deleteComputeResource,
getCurrentProvisioningState,
getPriceMap,
getReadRegions,
refreshComputeProvisioning,
updateComputeResource,
} from "./GraphAPICompute.rp";
const costPerHourDefaultValue: Description = {
textTKey: "CostText",
type: DescriptionType.Text,
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-pricing",
textTKey: "ComputePricing",
},
};
const connectionStringValue: Description = {
textTKey: "ConnectionStringText",
type: DescriptionType.Text,
link: {
href: generateBladeLink(BladeType.SqlKeys),
textTKey: "KeysBlade",
},
};
const metricsStringValue: Description = {
textTKey: "MetricsText",
type: DescriptionType.Text,
link: {
href: generateBladeLink(BladeType.Metrics),
textTKey: "MetricsBlade",
},
};
const CosmosD4s = "Cosmos.D4s";
const CosmosD8s = "Cosmos.D8s";
const CosmosD16s = "Cosmos.D16s";
const onSKUChange = (newValue: InputType, currentValues: Map<string, SmartUiInput>): Map<string, SmartUiInput> => {
currentValues.set("sku", { value: newValue });
currentValues.set("costPerHour", {
value: calculateCost(newValue as string, currentValues.get("instances").value as number),
});
return currentValues;
};
const onNumberOfInstancesChange = (
newValue: InputType,
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Map<string, SmartUiInput> => {
currentValues.set("instances", { value: newValue });
const ComputeOriginallyEnabled = baselineValues.get("enableCompute")?.value as boolean;
const baselineInstances = baselineValues.get("instances")?.value as number;
if (!ComputeOriginallyEnabled || baselineInstances !== newValue) {
currentValues.set("warningBanner", {
value: {
textTKey: "WarningBannerOnUpdate",
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-overview",
textTKey: "ComputePricing",
},
} as Description,
hidden: false,
});
} else {
currentValues.set("warningBanner", undefined);
}
currentValues.set("costPerHour", {
value: calculateCost(currentValues.get("sku").value as string, newValue as number),
});
return currentValues;
};
const onEnableComputeChange = (
newValue: InputType,
currentValues: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
): Map<string, SmartUiInput> => {
currentValues.set("enableCompute", { value: newValue });
const ComputeOriginallyEnabled = baselineValues.get("enableCompute")?.value as boolean;
if (ComputeOriginallyEnabled === newValue) {
currentValues.set("sku", baselineValues.get("sku"));
currentValues.set("instances", baselineValues.get("instances"));
currentValues.set("costPerHour", baselineValues.get("costPerHour"));
currentValues.set("warningBanner", baselineValues.get("warningBanner"));
currentValues.set("connectionString", baselineValues.get("connectionString"));
currentValues.set("metricsString", baselineValues.get("metricsString"));
return currentValues;
}
currentValues.set("warningBanner", undefined);
if (newValue === true) {
currentValues.set("warningBanner", {
value: {
textTKey: "WarningBannerOnUpdate",
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-pricing", //needs updating
textTKey: "ComputePricing",
},
} as Description,
hidden: false,
});
currentValues.set("costPerHour", {
value: calculateCost(baselineValues.get("sku").value as string, baselineValues.get("instances").value as number),
hidden: false,
});
} else {
currentValues.set("warningBanner", {
value: {
textTKey: "WarningBannerOnDelete",
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-overview", // needs updating
textTKey: "DeprovisioningDetailsText",
},
} as Description,
hidden: false,
});
currentValues.set("costPerHour", { value: costPerHourDefaultValue, hidden: true });
}
const sku = currentValues.get("sku");
const hideAttributes = newValue === undefined || !(newValue as boolean);
currentValues.set("sku", {
value: sku.value,
hidden: hideAttributes,
disabled: ComputeOriginallyEnabled,
});
currentValues.set("instances", {
value: 1,
hidden: hideAttributes,
disabled: true,
});
currentValues.set("connectionString", {
value: connectionStringValue,
hidden: !newValue || !ComputeOriginallyEnabled,
});
currentValues.set("metricsString", {
value: metricsStringValue,
hidden: !newValue || !ComputeOriginallyEnabled,
});
return currentValues;
};
const skuDropDownItems: ChoiceItem[] = [
{ labelTKey: "CosmosD4s", key: CosmosD4s },
{ labelTKey: "CosmosD8s", key: CosmosD8s },
{ labelTKey: "CosmosD16s", key: CosmosD16s },
];
const getSkus = async (): Promise<ChoiceItem[]> => {
return skuDropDownItems;
};
const NumberOfInstancesDropdownInfo: Info = {
messageTKey: "ResizingDecisionText",
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-size", // todo
textTKey: "ResizingDecisionLink",
},
};
const getInstancesMin = async (): Promise<number> => {
return 1;
};
const getInstancesMax = async (): Promise<number> => {
return 5;
};
const ApproximateCostDropDownInfo: Info = {
messageTKey: "CostText",
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-pricing", //todo
textTKey: "ComputePricing",
},
};
let priceMap: Map<string, Map<string, number>>;
let regions: Array<string>;
const calculateCost = (skuName: string, instanceCount: number): Description => {
try {
let costPerHour = 0;
for (const region of regions) {
const incrementalCost = priceMap.get(region).get(skuName.replace("Cosmos.", ""));
if (incrementalCost === undefined) {
throw new Error("Value not found in map.");
}
costPerHour += incrementalCost;
}
costPerHour *= instanceCount;
costPerHour = Math.round(costPerHour * 100) / 100;
return {
textTKey: `${costPerHour} USD`,
type: DescriptionType.Text,
};
} catch (err) {
return costPerHourDefaultValue;
}
};
@IsDisplayable()
@RefreshOptions({ retryIntervalInMs: 20000 })
export default class GraphAPICompute extends SelfServeBaseClass {
public onRefresh = async (): Promise<RefreshResult> => {
return await refreshComputeProvisioning();
};
public onSave = async (
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Promise<OnSaveResult> => {
selfServeTrace({ selfServeClassName: GraphAPICompute.name });
const ComputeCurrentlyEnabled = currentValues.get("enableCompute")?.value as boolean;
const ComputeOriginallyEnabled = baselineValues.get("enableCompute")?.value as boolean;
currentValues.set("warningBanner", undefined);
if (ComputeOriginallyEnabled) {
if (!ComputeCurrentlyEnabled) {
const operationStatusUrl = await deleteComputeResource();
return {
operationStatusUrl: operationStatusUrl,
portalNotification: {
initialize: {
titleTKey: "DeleteInitializeTitle",
messageTKey: "DeleteInitializeMessage",
},
success: {
titleTKey: "DeleteSuccessTitle",
messageTKey: "DeleteSuccesseMessage",
},
failure: {
titleTKey: "DeleteFailureTitle",
messageTKey: "DeleteFailureMessage",
},
},
};
} else {
const sku = currentValues.get("sku")?.value as string;
const instances = currentValues.get("instances").value as number;
const operationStatusUrl = await updateComputeResource(sku, instances);
return {
operationStatusUrl: operationStatusUrl,
portalNotification: {
initialize: {
titleTKey: "UpdateInitializeTitle",
messageTKey: "UpdateInitializeMessage",
},
success: {
titleTKey: "UpdateSuccessTitle",
messageTKey: "UpdateSuccesseMessage",
},
failure: {
titleTKey: "UpdateFailureTitle",
messageTKey: "UpdateFailureMessage",
},
},
};
}
} else {
const sku = currentValues.get("sku")?.value as string;
const instances = currentValues.get("instances").value as number;
const operationStatusUrl = await updateComputeResource(sku, instances);
return {
operationStatusUrl: operationStatusUrl,
portalNotification: {
initialize: {
titleTKey: "CreateInitializeTitle",
messageTKey: "CreateInitializeMessage",
},
success: {
titleTKey: "CreateSuccessTitle",
messageTKey: "CreateSuccesseMessage",
},
failure: {
titleTKey: "CreateFailureTitle",
messageTKey: "CreateFailureMessage",
},
},
};
}
};
public initialize = async (): Promise<Map<string, SmartUiInput>> => {
// Based on the RP call enableCompute will be true if it has not yet been enabled and false if it has.
const defaults = new Map<string, SmartUiInput>();
defaults.set("enableCompute", { value: false });
defaults.set("sku", { value: CosmosD4s, hidden: true });
defaults.set("instances", { value: 1, hidden: true });
defaults.set("costPerHour", undefined);
defaults.set("connectionString", undefined);
defaults.set("metricsString", {
value: undefined,
hidden: true,
});
regions = await getReadRegions();
priceMap = await getPriceMap(regions);
const response = await getCurrentProvisioningState();
if (response.status && response.status === "Creating") {
defaults.set("enableCompute", { value: true });
defaults.set("sku", { value: response.sku, disabled: true });
defaults.set("instances", { value: response.instances, disabled: true });
defaults.set("costPerHour", { value: calculateCost(response.sku, response.instances) });
defaults.set("connectionString", {
value: connectionStringValue,
hidden: true,
});
defaults.set("metricsString", {
value: metricsStringValue,
hidden: true,
});
} else if (response.status && response.status !== "Deleting") {
defaults.set("enableCompute", { value: true });
defaults.set("sku", { value: response.sku, disabled: true });
defaults.set("instances", { value: response.instances });
defaults.set("costPerHour", { value: calculateCost(response.sku, response.instances) });
defaults.set("connectionString", {
value: connectionStringValue,
hidden: false,
});
defaults.set("metricsString", {
value: metricsStringValue,
hidden: false,
});
}
defaults.set("warningBanner", undefined);
return defaults;
};
@Values({
isDynamicDescription: true,
})
warningBanner: string;
@Values({
description: {
textTKey: "GraphAPIDescription",
type: DescriptionType.Text,
link: {
href: "https://aka.ms/cosmos-db-dedicated-gateway-overview", //todo
textTKey: "LearnAboutCompute",
},
},
})
description: string;
@OnChange(onEnableComputeChange)
@Values({
labelTKey: "Compute",
trueLabelTKey: "Provisioned",
falseLabelTKey: "Deprovisioned",
})
enableCompute: boolean;
@OnChange(onSKUChange)
@Values({
labelTKey: "SKUs",
choices: getSkus,
placeholderTKey: "SKUsPlaceHolder",
})
sku: ChoiceItem;
@OnChange(onNumberOfInstancesChange)
@PropertyInfo(NumberOfInstancesDropdownInfo)
@Values({
labelTKey: "NumberOfInstances",
min: getInstancesMin,
max: getInstancesMax,
step: 1,
uiType: NumberUiType.Spinner,
})
instances: number;
@PropertyInfo(ApproximateCostDropDownInfo)
@Values({
labelTKey: "ApproximateCost",
isDynamicDescription: true,
})
costPerHour: string;
@Values({
labelTKey: "ConnectionString",
isDynamicDescription: true,
})
connectionString: string;
@Values({
labelTKey: "MonitorUsage",
description: metricsStringValue,
})
metricsString: string;
}

View File

@@ -0,0 +1,65 @@
export enum Regions {
NorthCentralUS = "NorthCentralUS",
WestUS = "WestUS",
EastUS2 = "EastUS2",
}
export interface AccountProps {
regions: Regions;
enableLogging: boolean;
accountName: string;
collectionThroughput: number;
dbThroughput: number;
}
export type GraphAPIComputeServiceResource = {
id: string;
name: string;
type: string;
properties: GraphAPIComputeServiceProps;
locations: GraphAPIComputeServiceLocations;
};
export type GraphAPIComputeServiceProps = {
serviceType: string;
creationTime: string;
status: string;
instanceSize: string;
instanceCount: number;
GraphAPIComputeEndPoint: string;
};
export type GraphAPIComputeServiceLocations = {
location: string;
status: string;
GraphAPIComputeEndpoint: string;
};
export type UpdateComputeRequestParameters = {
properties: UpdateComputeRequestProperties;
};
export type UpdateComputeRequestProperties = {
instanceSize: string;
instanceCount: number;
serviceType: string;
};
export type FetchPricesResponse = {
Items: Array<PriceItem>;
NextPageLink: string | undefined;
Count: number;
};
export type PriceItem = {
retailPrice: number;
skuName: string;
};
export type RegionsResponse = {
locations: Array<RegionItem>;
location: string;
};
export type RegionItem = {
locationName: string;
};

View File

@@ -50,6 +50,14 @@ const getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDes
await loadTranslations(sqlX.constructor.name);
return sqlX.toSelfServeDescriptor();
}
case SelfServeType.graphapicompute: {
const GraphAPICompute = await import(
/* webpackChunkName: "GraphAPICompute" */ "./GraphAPICompute/GraphAPICompute"
);
const graphAPICompute = new GraphAPICompute.default();
await loadTranslations(graphAPICompute.constructor.name);
return graphAPICompute.toSelfServeDescriptor();
}
default:
return undefined;
}

View File

@@ -31,6 +31,7 @@ export enum SelfServeType {
// Add your self serve types here
example = "example",
sqlx = "sqlx",
graphapicompute = "graphapicompute",
}
/**

View File

@@ -138,9 +138,9 @@ const getGeneralPath = (subscriptionId: string, resourceGroup: string, name: str
return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${name}`;
};
export const getReadRegions = async (): Promise<Array<string>> => {
export const getRegions = async (): Promise<Array<string>> => {
try {
const readRegions = new Array<string>();
const regions = new Array<string>();
const response = await armRequestWithoutPolling<RegionsResponse>({
host: configContext.ARM_ENDPOINT,
@@ -150,13 +150,13 @@ export const getReadRegions = async (): Promise<Array<string>> => {
});
if (response.result.location !== undefined) {
readRegions.push(response.result.location.replace(" ", "").toLowerCase());
regions.push(response.result.location.split(" ").join("").toLowerCase());
} else {
for (const location of response.result.locations) {
readRegions.push(location.locationName.replace(" ", "").toLowerCase());
regions.push(location.locationName.split(" ").join("").toLowerCase());
}
}
return readRegions;
return regions;
} catch (err) {
return new Array<string>();
}

View File

@@ -17,7 +17,7 @@ import {
deleteDedicatedGatewayResource,
getCurrentProvisioningState,
getPriceMap,
getReadRegions,
getRegions,
refreshDedicatedGatewayProvisioning,
updateDedicatedGatewayResource,
} from "./SqlX.rp";
@@ -324,7 +324,7 @@ export default class SqlX extends SelfServeBaseClass {
hidden: true,
});
regions = await getReadRegions();
regions = await getRegions();
priceMap = await getPriceMap(regions);
const response = await getCurrentProvisioningState();

View File

@@ -125,7 +125,8 @@ export class OfferPricing {
S3Price: 0.1344,
Standard: {
StartingPrice: 24 / hoursInAMonth, // per hour
PricePerRU: 0.00008,
SingleMasterPricePerRU: 0.00008,
MultiMasterPricePerRU: 0.00016,
PricePerGB: 0.25 / hoursInAMonth,
},
},
@@ -137,7 +138,8 @@ export class OfferPricing {
S3Price: 0.6,
Standard: {
StartingPrice: OfferPricing.MonthlyPricing.mooncake.Standard.StartingPrice / hoursInAMonth, // per hour
PricePerRU: 0.00051,
SingleMasterPricePerRU: 0.00051,
MultiMasterPricePerRU: 0.00102,
PricePerGB: OfferPricing.MonthlyPricing.mooncake.Standard.PricePerGB / hoursInAMonth,
},
},

View File

@@ -2,11 +2,11 @@ import * as Constants from "./Constants";
export function computeRUUsagePrice(serverId: string, requestUnits: number): string {
if (serverId === "mooncake") {
const ruCharge = requestUnits * Constants.OfferPricing.HourlyPricing.mooncake.Standard.PricePerRU;
const ruCharge = requestUnits * Constants.OfferPricing.HourlyPricing.mooncake.Standard.SingleMasterPricePerRU;
return calculateEstimateNumber(ruCharge) + " " + Constants.OfferPricing.HourlyPricing.mooncake.Currency;
}
const ruCharge = requestUnits * Constants.OfferPricing.HourlyPricing.default.Standard.PricePerRU;
const ruCharge = requestUnits * Constants.OfferPricing.HourlyPricing.default.Standard.SingleMasterPricePerRU;
return calculateEstimateNumber(ruCharge) + " " + Constants.OfferPricing.HourlyPricing.default.Currency;
}

View File

@@ -2,6 +2,7 @@ import { HttpStatusCodes } from "../Common/Constants";
import { useDialog } from "../Explorer/Controls/Dialog";
import { GalleryTab, SortBy } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer/Explorer";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "./GalleryUtils";
@@ -34,7 +35,7 @@ describe("GalleryUtils", () => {
expect(useDialog.getState().visible).toBe(true);
expect(useDialog.getState().dialogProps).toBeDefined();
expect(useDialog.getState().dialogProps.title).toBe("Download to My Notebooks");
expect(useDialog.getState().dialogProps.title).toBe(`Download to ${useNotebook.getState().notebookFolderName}`);
});
it("favoriteItem favorites item", async () => {

View File

@@ -10,6 +10,7 @@ import {
SortBy,
} from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import Explorer from "../Explorer/Explorer";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
@@ -223,11 +224,13 @@ export function downloadItem(
const name = data.name;
useDialog.getState().showOkCancelModalDialog(
"Download to My Notebooks",
`Download to ${useNotebook.getState().notebookFolderName}`,
`Download ${name} from gallery as a copy to your notebooks to run and/or edit the notebook.`,
"Download",
async () => {
const clearInProgressMessage = logConsoleProgress(`Downloading ${name} to My Notebooks`);
const clearInProgressMessage = logConsoleProgress(
`Downloading ${name} to ${useNotebook.getState().notebookFolderName}`
);
const startKey = traceStart(Action.NotebooksGalleryDownload, {
notebookId: data.id,
downloadCount: data.downloads,

View File

@@ -150,7 +150,7 @@ describe("PricingUtils Tests", () => {
expect(value).toBe(0.00012);
});
it("should return 0.00048 for default cloud, 1RU, 2 region, multimaster enabled", () => {
it("should return 0.00032 for default cloud, 1RU, 2 region, multimaster enabled", () => {
const value = PricingUtils.computeRUUsagePriceHourly({
serverId: "default",
requestUnits: 1,
@@ -158,9 +158,9 @@ describe("PricingUtils Tests", () => {
multimasterEnabled: true,
isAutoscale: false,
});
expect(value).toBe(0.00048);
expect(value).toBe(0.00032);
});
it("should return 0.00048 for default cloud, 1RU, 2 region, multimaster enabled, autoscale", () => {
it("should return 0.00032 for default cloud, 1RU, 2 region, multimaster enabled, autoscale", () => {
const value = PricingUtils.computeRUUsagePriceHourly({
serverId: "default",
requestUnits: 1,
@@ -168,7 +168,7 @@ describe("PricingUtils Tests", () => {
multimasterEnabled: true,
isAutoscale: true,
});
expect(value).toBe(0.00096);
expect(value).toBe(0.00032);
});
});
@@ -251,70 +251,47 @@ describe("PricingUtils Tests", () => {
});
describe("getPricePerRu()", () => {
it("should return 0.00008 for default clouds", () => {
const value = PricingUtils.getPricePerRu("default");
it("should return 0.00008 for single master default clouds", () => {
const value = PricingUtils.getPricePerRu("default", 1);
expect(value).toBe(0.00008);
});
it("should return 0.00051 for mooncake", () => {
const value = PricingUtils.getPricePerRu("mooncake");
it("should return 0.00016 for multi master default clouds", () => {
const value = PricingUtils.getPricePerRu("default", 2);
expect(value).toBe(0.00016);
});
it("should return 0.00051 for single master mooncake", () => {
const value = PricingUtils.getPricePerRu("mooncake", 1);
expect(value).toBe(0.00051);
});
it("should return 0.00102 for multi master mooncake", () => {
const value = PricingUtils.getPricePerRu("mooncake", 2);
expect(value).toBe(0.00102);
});
});
describe("getRegionMultiplier()", () => {
describe("without multimaster", () => {
it("should return 0 for undefined", () => {
const value = PricingUtils.getRegionMultiplier(undefined, false);
expect(value).toBe(0);
});
it("should return 0 for -1", () => {
const value = PricingUtils.getRegionMultiplier(-1, false);
expect(value).toBe(0);
});
it("should return 0 for 0", () => {
const value = PricingUtils.getRegionMultiplier(0, false);
expect(value).toBe(0);
});
it("should return 1 for 1", () => {
const value = PricingUtils.getRegionMultiplier(1, false);
expect(value).toBe(1);
});
it("should return 2 for 2", () => {
const value = PricingUtils.getRegionMultiplier(2, false);
expect(value).toBe(2);
});
it("should return 0 for undefined", () => {
const value = PricingUtils.getRegionMultiplier(undefined);
expect(value).toBe(0);
});
describe("with multimaster", () => {
it("should return 0 for undefined", () => {
const value = PricingUtils.getRegionMultiplier(undefined, true);
expect(value).toBe(0);
});
it("should return 0 for -1", () => {
const value = PricingUtils.getRegionMultiplier(-1, true);
expect(value).toBe(0);
});
it("should return 0 for 0", () => {
const value = PricingUtils.getRegionMultiplier(0, true);
expect(value).toBe(0);
});
it("should return 1 for 1", () => {
const value = PricingUtils.getRegionMultiplier(1, true);
expect(value).toBe(1);
});
it("should return 3 for 2", () => {
const value = PricingUtils.getRegionMultiplier(2, true);
expect(value).toBe(3);
});
it("should return 0 for -1", () => {
const value = PricingUtils.getRegionMultiplier(-1);
expect(value).toBe(0);
});
it("should return 0 for 0", () => {
const value = PricingUtils.getRegionMultiplier(0);
expect(value).toBe(0);
});
it("should return 1 for 1", () => {
const value = PricingUtils.getRegionMultiplier(1);
expect(value).toBe(1);
});
it("should return 2 for 2", () => {
const value = PricingUtils.getRegionMultiplier(2);
expect(value).toBe(2);
});
});
@@ -376,7 +353,7 @@ describe("PricingUtils Tests", () => {
true /* multimaster */
);
expect(value).toBe(
"Cost (USD): <b>$0.19 hourly / $4.61 daily / $140.16 monthly </b> (2 regions, 400RU/s, $0.00016/RU)<p style='padding: 10px 0px 0px 0px;'><em>*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account</em></p>"
"Cost (USD): <b>$0.13 hourly / $3.07 daily / $93.44 monthly </b> (2 regions, 400RU/s, $0.00016/RU)<p style='padding: 10px 0px 0px 0px;'><em>*This cost is an estimate and may vary based on the regions where your account is deployed and potential discounts applied to your account</em></p>"
);
});
@@ -424,7 +401,7 @@ describe("PricingUtils Tests", () => {
true /* multimaster */,
false
);
expect(value).toBe("I acknowledge the estimated $4.61 daily cost for the throughput above.");
expect(value).toBe("I acknowledge the estimated $3.07 daily cost for the throughput above.");
});
it("should return 'I acknowledge the estimated $1.54 daily cost for the throughput above.' for 400RU/s on default cloud, 2 region, without multimaster", () => {

View File

@@ -34,26 +34,18 @@ export function getRuToolTipText(): string {
* Otherwise, return numberOfRegions
* @param numberOfRegions
*/
export function getRegionMultiplier(numberOfRegions: number, multimasterEnabled: boolean): number {
export function getRegionMultiplier(numberOfRegions: number): number {
const normalizedNumberOfRegions: number = normalizeNumber(numberOfRegions);
if (normalizedNumberOfRegions <= 0) {
return 0;
}
if (numberOfRegions === 1) {
return numberOfRegions;
}
if (multimasterEnabled) {
return numberOfRegions + 1;
}
return numberOfRegions;
}
export function getMultimasterMultiplier(numberOfRegions: number, multimasterEnabled: boolean): number {
const regionMultiplier: number = getRegionMultiplier(numberOfRegions, multimasterEnabled);
const regionMultiplier: number = getRegionMultiplier(numberOfRegions);
const multimasterMultiplier: number = !multimasterEnabled ? 1 : regionMultiplier > 1 ? 2 : 1;
return multimasterMultiplier;
@@ -66,10 +58,12 @@ export function computeRUUsagePriceHourly({
multimasterEnabled,
isAutoscale,
}: ComputeRUUsagePriceHourlyArgs): number {
const regionMultiplier: number = getRegionMultiplier(numberOfRegions, multimasterEnabled);
const regionMultiplier: number = getRegionMultiplier(numberOfRegions);
const multimasterMultiplier: number = getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
const pricePerRu = isAutoscale ? getAutoscalePricePerRu(serverId, multimasterMultiplier) : getPricePerRu(serverId);
const ruCharge = requestUnits * pricePerRu * multimasterMultiplier * regionMultiplier;
const pricePerRu = isAutoscale
? getAutoscalePricePerRu(serverId, multimasterMultiplier)
: getPricePerRu(serverId, multimasterMultiplier);
const ruCharge = requestUnits * pricePerRu * regionMultiplier;
return Number(ruCharge.toFixed(5));
}
@@ -149,12 +143,16 @@ export function getAutoscalePricePerRu(serverId: string, mmMultiplier: number):
}
}
export function getPricePerRu(serverId: string): number {
export function getPricePerRu(serverId: string, mmMultiplier: number): number {
if (serverId === "mooncake") {
return Constants.OfferPricing.HourlyPricing.mooncake.Standard.PricePerRU;
return mmMultiplier > 1
? Constants.OfferPricing.HourlyPricing.mooncake.Standard.MultiMasterPricePerRU
: Constants.OfferPricing.HourlyPricing.mooncake.Standard.SingleMasterPricePerRU;
}
return Constants.OfferPricing.HourlyPricing.default.Standard.PricePerRU;
return mmMultiplier > 1
? Constants.OfferPricing.HourlyPricing.default.Standard.MultiMasterPricePerRU
: Constants.OfferPricing.HourlyPricing.default.Standard.SingleMasterPricePerRU;
}
export function getAutoPilotV3SpendHtml(maxAutoPilotThroughputSet: number, isDatabaseThroughput: boolean): string {
@@ -188,9 +186,7 @@ export function getEstimatedAutoscaleSpendHtml(
const monthlyPrice: number = hourlyPrice * Constants.hoursInAMonth;
const currency: string = getPriceCurrency(serverId);
const currencySign: string = getCurrencySign(serverId);
const pricePerRu =
getAutoscalePricePerRu(serverId, getMultimasterMultiplier(regions, multimaster)) *
getMultimasterMultiplier(regions, multimaster);
const pricePerRu = getAutoscalePricePerRu(serverId, getMultimasterMultiplier(regions, multimaster));
return (
`Estimated monthly cost (${currency}): <b>` +
@@ -219,7 +215,7 @@ export function getEstimatedSpendHtml(
const monthlyPrice: number = hourlyPrice * Constants.hoursInAMonth;
const currency: string = getPriceCurrency(serverId);
const currencySign: string = getCurrencySign(serverId);
const pricePerRu = getPricePerRu(serverId) * getMultimasterMultiplier(regions, multimaster);
const pricePerRu = getPricePerRu(serverId, getMultimasterMultiplier(regions, multimaster));
return (
`Cost (${currency}): <b>` +

View File

@@ -56,6 +56,7 @@ export function useAADAuth(): ReturnType {
});
setTenantId(response.tenantId);
setAccount(response.account);
localStorage.setItem("cachedTenantId", response.tenantId);
},
[account, tenantId]
);

View File

@@ -98,9 +98,11 @@ async function configureHostedWithAAD(config: AAD): Promise<Explorer> {
const msalInstance = getMsalInstance();
const cachedAccount = msalInstance.getAllAccounts()?.[0];
msalInstance.setActiveAccount(cachedAccount);
const cachedTenantId = localStorage.getItem("cachedTenantId");
const aadTokenResponse = await msalInstance.acquireTokenSilent({
forceRefresh: true,
scopes: [hrefEndpoint],
authority: `${configContext.AAD_ENDPOINT}${cachedTenantId}`,
});
aadToken = aadTokenResponse.accessToken;
}
@@ -337,6 +339,9 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) {
userContext.features.partitionKeyDefault2 = true;
}
if (inputs.flights.indexOf(Flights.Phoenix) !== -1) {
userContext.features.phoenix = true;
}
}
}

View File

@@ -103,7 +103,7 @@ module.exports = function (_env = {}, argv = {}) {
envVars.NODE_ENV = "development";
envVars.AZURE_CLIENT_ID = AZURE_CLIENT_ID;
envVars.AZURE_TENANT_ID = AZURE_TENANT_ID;
envVars.AZURE_CLIENT_SECRET = AZURE_CLIENT_SECRET;
envVars.AZURE_CLIENT_SECRET = AZURE_CLIENT_SECRET || null;
envVars.SUBSCRIPTION_ID = SUBSCRIPTION_ID;
envVars.RESOURCE_GROUP = RESOURCE_GROUP;
typescriptRule.use[0].options.compilerOptions = { target: "ES2018" };
@@ -113,6 +113,7 @@ module.exports = function (_env = {}, argv = {}) {
new CleanWebpackPlugin(),
new webpack.ProvidePlugin({
process: "process/browser",
Buffer: ["buffer", "Buffer"],
}),
new CreateFileWebpack({
path: "./dist",
@@ -229,6 +230,7 @@ module.exports = function (_env = {}, argv = {}) {
alias: {
process: "process/browser",
},
fallback: {
crypto: false,
fs: false,