Merge remote-tracking branch 'origin/users/aisayas/sql-connection-string-login-zones' into users/aisayas/sql-connectionstring-login

This commit is contained in:
Asier Isayas
2026-08-27 13:06:03 -04:00
56 changed files with 1182 additions and 2791 deletions
-11
View File
@@ -1,14 +1,3 @@
export class EndpointsRegex {
public static readonly cassandra = [
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
"HostName=(.*).cassandra.cosmos.azure.com",
];
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com";
public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com";
}
export class ApiEndpoints {
public static runtimeProxy: string = "/api/RuntimeProxy";
public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy";
+33
View File
@@ -30,6 +30,13 @@ export interface ConfigContext {
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
allowedMongoProxyEndpoints: ReadonlyArray<string>;
allowedParentFrameOrigins: ReadonlyArray<string>;
SQL_DNS_ZONES: ReadonlyArray<string>;
MONGO_DNS_ZONES: ReadonlyArray<string>;
MONGO_COMPUTE_DNS_ZONES: ReadonlyArray<string>;
CASSANDRA_DNS_ZONES: ReadonlyArray<string>;
TABLE_DNS_ZONES: ReadonlyArray<string>;
DOCUMENT_ENDPOINT_ZONES: ReadonlyArray<string>;
GREMLIN_ENDPOINT_ZONES: ReadonlyArray<string>;
gitSha?: string;
proxyPath?: string;
AAD_ENDPOINT: string;
@@ -81,6 +88,32 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
], // Webpack injects this at build time
SQL_DNS_ZONES: [
"documents.azure.com",
"sql.cosmosdb.azure.com",
"sql.cosmos.azure.com",
"sqlx.cosmos.azure.com",
"documents-staging.windows-ppe.net",
"sql.cosmosdb.windows-ppe.net",
"sql.cosmos.windows-ppe.net",
"sqlx.cosmos.windows-ppe.net",
],
MONGO_DNS_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
MONGO_COMPUTE_DNS_ZONES: ["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"],
CASSANDRA_DNS_ZONES: [
"cassandra.cosmosdb.azure.com",
"cassandra.cosmos.azure.com",
"cassandra.cosmosdb.windows-ppe.net",
"cassandra.cosmos.windows-ppe.net",
],
TABLE_DNS_ZONES: [
"table.cosmosdb.azure.com",
"table.cosmos.azure.com",
"table.cosmosdb.windows-ppe.net",
"table.cosmos.windows-ppe.net",
],
DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
GREMLIN_ENDPOINT_ZONES: ["gremlin.cosmos.azure.com", "gremlin.cosmos.windows-ppe.net"],
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
AAD_ENDPOINT: "https://login.microsoftonline.com/",
+3 -10
View File
@@ -28,7 +28,6 @@ import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel";
import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";
import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel";
import StoredProcedure from "./Tree/StoredProcedure";
@@ -120,23 +119,17 @@ export const createCollectionContextMenuButton = (
iconSrc: HostedTerminalIcon,
onClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else {
selectedCollection && selectedCollection.onNewMongoShellClick();
}
},
label:
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
? t(Keys.contextMenu.openMongoShell)
: t(Keys.contextMenu.newShell),
label: userContext.features.enableCloudShell ? t(Keys.contextMenu.openMongoShell) : t(Keys.contextMenu.newShell),
});
}
if (
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
userContext.apiType === "Cassandra"
) {
if (userContext.features.enableCloudShell && userContext.apiType === "Cassandra") {
items.push({
iconSrc: HostedTerminalIcon,
onClick: () => {
@@ -109,25 +109,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -230,25 +220,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -455,25 +435,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -526,25 +496,15 @@ exports[`SettingsComponent renders 1`] = `
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -694,25 +654,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function],
"conflictResolutionPolicy": [Function],
"container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -765,25 +715,15 @@ exports[`SettingsComponent renders 1`] = `
}
explorer={
Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
+15 -15
View File
@@ -10,6 +10,7 @@ import { Capability, DatabaseAccount } from "../Contracts/DataModels";
import { updateUserContext, userContext } from "../UserContext";
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import Explorer from "./Explorer";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
const mockUpdate = update as jest.MockedFunction<typeof update>;
@@ -26,21 +27,6 @@ jest.mock("./Controls/Dialog", () => ({
},
}));
// Silence useNotebook subscription calls
jest.mock("./Notebook/useNotebook", () => ({
useNotebook: {
subscribe: jest.fn(),
getState: jest.fn().mockReturnValue(
new Proxy(
{},
{
get: () => jest.fn().mockResolvedValue(undefined),
},
),
),
},
}));
describe("Explorer.openEnableSynapseLinkDialog", () => {
let explorer: Explorer;
@@ -69,6 +55,7 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue(undefined);
useCommandBar.getState().setIsSynapseLinkUpdating(false);
explorer = new Explorer();
});
@@ -102,6 +89,19 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true);
});
it("should track Synapse Link update progress in the command bar store", async () => {
mockUpdate.mockImplementation(async () => {
expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(true);
return {} as Awaited<ReturnType<typeof update>>;
});
explorer.openEnableSynapseLinkDialog();
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(false);
});
});
describe("with targetAccountOverride", () => {
+5 -220
View File
@@ -1,9 +1,6 @@
import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { sendMessage } from "Common/MessageHandler";
import { stringifyError } from "Common/stringifyError";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import {
@@ -13,32 +10,27 @@ import {
scheduleRefreshFabricToken,
} from "Platform/Fabric/FabricUtil";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { featureRegistered } from "Utils/FeatureRegistrationUtils";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import * as ko from "knockout";
import React from "react";
import _ from "underscore";
import shallow from "zustand/shallow";
import { AuthType } from "../AuthType";
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
import * as Constants from "../Common/Constants";
import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants";
import { getErrorMessage, getErrorStack } from "../Common/ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
import { QueriesClient } from "../Common/QueriesClient";
import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases";
import * as DataModels from "../Contracts/DataModels";
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { UploadDetailsRecord } from "../Contracts/ViewModels";
import MetricScenario from "../Metrics/MetricEvents";
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings";
import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { updateUserContext, userContext } from "../UserContext";
import { getCollectionName, getUploadName } from "../Utils/APITypeUtils";
@@ -49,8 +41,6 @@ import { ReactTabKind, useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
import type NotebookManager from "./Notebook/NotebookManager";
import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
@@ -78,13 +68,7 @@ export default class Explorer {
// Tabs
public isTabsContentExpanded: ko.Observable<boolean>;
// Notebooks
public notebookManager?: NotebookManager;
private _isInitializingNotebooks: boolean;
private static readonly MaxNbDatabasesToAutoExpand = 5;
public phoenixClient: PhoenixClient;
/**
* Resolves when the initial refreshAllDatabases (including collection loading) completes.
@@ -95,13 +79,6 @@ export default class Explorer {
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
});
this._isInitializingNotebooks = false;
this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id);
useNotebook.subscribe(
() => this.refreshCommandBarButtons(),
(state) => state.isNotebooksEnabledForAccount,
);
this.queriesClient = new QueriesClient(this);
@@ -156,31 +133,8 @@ export default class Explorer {
startKey,
);
useNotebook.subscribe(
async () => this.initiateAndRefreshNotebookList(),
(state) => [state.isNotebookEnabled, state.isRefreshed],
shallow,
);
this.resourceTree = new ResourceTreeAdapter(this);
// Override notebook server parameters from URL parameters
if (
userContext.features.notebookServerUrl &&
validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) &&
userContext.features.notebookServerToken
) {
useNotebook.getState().setNotebookServerInfo({
notebookServerEndpoint: userContext.features.notebookServerUrl,
authToken: userContext.features.notebookServerToken,
forwardingId: undefined,
});
}
if (userContext.features.notebookBasePath) {
useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath);
}
if (isFabricMirrored()) {
useTabs.getState().closeReactTab(ReactTabKind.Home);
}
@@ -188,23 +142,6 @@ export default class Explorer {
this.refreshExplorer();
}
public async initiateAndRefreshNotebookList(): Promise<void> {
if (!this.notebookManager) {
const NotebookManager = (await import(/* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager"))
.default;
this.notebookManager = new NotebookManager();
this.notebookManager.initialize({
container: this,
resourceTree: this.resourceTree,
refreshCommandBarButtons: () => this.refreshCommandBarButtons(),
refreshNotebookList: () => this.refreshNotebookList(),
});
}
this.refreshCommandBarButtons();
this.refreshNotebookList();
}
public openEnableSynapseLinkDialog(targetAccountOverride?: DataModels.AccountOverride): void {
const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId;
const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup;
@@ -227,7 +164,7 @@ export default class Explorer {
const clearInProgressMessage = logConsoleProgress(
"Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account.",
);
useNotebook.getState().setIsSynapseLinkUpdating(true);
useCommandBar.getState().setIsSynapseLinkUpdating(true);
useDialog.getState().closeDialog();
try {
@@ -248,7 +185,7 @@ export default class Explorer {
logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`);
TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime);
} finally {
useNotebook.getState().setIsSynapseLinkUpdating(false);
useCommandBar.getState().setIsSynapseLinkUpdating(false);
}
},
@@ -431,7 +368,6 @@ export default class Explorer {
await (userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases());
await this.refreshNotebookList();
}
logConsoleInfo("Successfully refreshed databases");
@@ -442,108 +378,6 @@ export default class Explorer {
window.open(Constants.Urls.feedbackEmail, "_blank");
};
public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise<void> {
if (!databaseAccount) {
throw new Error("No database account specified");
}
if (this._isInitializingNotebooks) {
return;
}
this._isInitializingNotebooks = true;
this.refreshNotebookList();
this._isInitializingNotebooks = false;
}
public async allocateContainer(): Promise<void> {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
const isAllocating = useNotebook.getState().isAllocating;
if (
isAllocating === false &&
(notebookServerInfo === undefined ||
(notebookServerInfo && notebookServerInfo.notebookServerEndpoint === undefined))
) {
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Connecting,
};
useNotebook.getState().setConnectionInfo(connectionStatus);
let connectionInfo;
try {
TelemetryProcessor.traceStart(Action.PhoenixConnection, {
dataExplorerArea: Areas.Notebook,
});
useNotebook.getState().setIsAllocating(true);
const provisionData: IProvisionData = {
cosmosEndpoint: userContext?.databaseAccount?.properties?.documentEndpoint,
poolId: undefined,
};
connectionInfo = await this.phoenixClient.allocateContainer(provisionData);
if (!connectionInfo?.data?.phoenixServiceUrl) {
throw new Error(`PhoenixServiceUrl is invalid!`);
}
await this.setNotebookInfo(connectionInfo, connectionStatus);
TelemetryProcessor.traceSuccess(Action.PhoenixConnection, {
dataExplorerArea: Areas.Notebook,
});
} catch (error) {
TelemetryProcessor.traceFailure(Action.PhoenixConnection, {
dataExplorerArea: Areas.Notebook,
status: error.status,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
});
connectionStatus.status = ConnectionStatusType.Failed;
useNotebook.getState().resetContainerConnection(connectionStatus);
if (error?.status === HttpStatusCodes.Forbidden && error.message) {
useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`);
} else {
useDialog
.getState()
.showOkModalDialog(
"Connection Failed",
"We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.",
);
}
throw error;
} finally {
useNotebook.getState().setIsAllocating(false);
this.refreshCommandBarButtons();
this.refreshNotebookList();
this._isInitializingNotebooks = false;
}
}
}
public async setNotebookInfo(
connectionInfo: IResponse<IPhoenixServiceInfo>,
connectionStatus: DataModels.ContainerConnectionInfo,
): Promise<void> {
const containerData = {
forwardingId: connectionInfo.data.forwardingId,
dbAccountName: userContext.databaseAccount.name,
};
await this.phoenixClient.initiateContainerHeartBeat(true, containerData);
connectionStatus.status = ConnectionStatusType.Connected;
useNotebook.getState().setConnectionInfo(connectionStatus);
const noteBookServerInfo = {
notebookServerEndpoint:
(validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) &&
userContext.features.notebookServerUrl) ||
connectionInfo.data.phoenixServiceUrl,
authToken: userContext.features.notebookServerToken || connectionInfo.data.authToken,
forwardingId: connectionInfo.data.forwardingId,
};
useNotebook.getState().setNotebookServerInfo(noteBookServerInfo);
this.notebookManager?.notebookClient
.getMemoryUsage()
.then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo));
}
private getDeltaDatabases(
updatedDatabaseList: DataModels.Database[],
databases: ViewModels.Database[],
@@ -637,11 +471,6 @@ export default class Explorer {
}
}
private refreshNotebookList = (): Promise<void> => {
// Notebook authoring and listing have been removed.
return Promise.resolve();
};
public openNotebookTerminal(kind: ViewModels.TerminalKind): void {
this.connectToNotebookTerminal(kind);
}
@@ -742,24 +571,6 @@ export default class Explorer {
.openSidePanel("Input parameters", <ExecuteSprocParamsPane storedProcedure={storedProcedure} />);
}
public getDownloadModalContent(fileName: string): JSX.Element {
if (useNotebook.getState().isPhoenixNotebooks) {
return (
<>
<p>{Notebook.galleryNotebookDownloadContent1}</p>
<br />
<p>
{Notebook.galleryNotebookDownloadContent2}
<Link href={Notebook.cosmosNotebookGitDocumentationUrl} target="_blank">
{Notebook.learnMore}
</Link>
</p>
</>
);
}
return <p> Download {fileName} from gallery as a copy to your notebooks to run and/or edit the notebook. </p>;
}
public async refreshExplorer(): Promise<void> {
// Start DatabaseLoad scenario before fetching databases
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
@@ -768,8 +579,7 @@ export default class Explorer {
// Run independent initialization tasks in parallel:
// - Database loading (ARM/SDK calls for databases + collections)
// - Notebook enabled check (Phoenix + Portal backend — no dependency on databases)
// - Feature registration check (ARM call — no dependency on databases or notebooks)
// - Feature registration check (ARM call — no dependency on databases)
const databasesTask =
userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo"
? (async () => {
@@ -786,37 +596,12 @@ export default class Explorer {
})()
: Promise.resolve();
const notebooksTask = !isFabricNative()
? useNotebook.getState().refreshNotebooksEnabledStateForAccount()
: Promise.resolve();
const featureRegistrationTask =
userContext.authType === AuthType.AAD && userContext.apiType === "SQL" && !isFabricNative()
? featureRegistered(userContext.subscriptionId, "ThroughputBucketing")
: Promise.resolve(false);
const [, , throughputBucketsEnabled] = await Promise.all([databasesTask, notebooksTask, featureRegistrationTask]);
// Notebook initialization depends on refreshNotebooksEnabledStateForAccount completing above
// TODO: remove reference to isNotebookEnabled and isNotebooksEnabledForAccount
const isNotebookEnabled =
configContext.platform !== Platform.Fabric &&
(userContext.features.notebooksDownBanner ||
useNotebook.getState().isPhoenixNotebooks ||
useNotebook.getState().isPhoenixFeatures);
useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled);
useNotebook
.getState()
.setIsShellEnabled(useNotebook.getState().isPhoenixFeatures && isPublicInternetAccessAllowed());
TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, {
isNotebookEnabled,
dataExplorerArea: Constants.Areas.Notebook,
});
if (useNotebook.getState().isPhoenixNotebooks) {
await this.initNotebooks(userContext.databaseAccount);
}
const [, throughputBucketsEnabled] = await Promise.all([databasesTask, featureRegistrationTask]);
if (throughputBucketsEnabled) {
updateUserContext({ throughputBucketsEnabled });
@@ -5,14 +5,12 @@
*/
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { makeStyles, useFluent } from "@fluentui/react-components";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext";
import * as React from "react";
import create, { UseStore } from "zustand";
import { ConnectionStatusType } from "../../../Common/Constants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode";
@@ -28,6 +26,8 @@ export interface CommandBarStore {
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
isSynapseLinkUpdating: boolean;
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
@@ -35,6 +35,8 @@ export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
isSynapseLinkUpdating: false,
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }),
}));
const useStyles = makeStyles({
@@ -69,26 +71,25 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const selectedNodeState = useSelectedNode();
const buttons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden);
const isSynapseLinkUpdating = useCommandBar((state) => state.isSynapseLinkUpdating);
// targetDocument is used by referenced components
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { targetDocument } = useFluent();
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const styles = useStyles();
const { connectionInfo, isPhoenixNotebooks, isPhoenixFeatures } = useNotebook((state) => ({
connectionInfo: state.connectionInfo,
isPhoenixNotebooks: state.isPhoenixNotebooks,
isPhoenixFeatures: state.isPhoenixFeatures,
}));
// Subscribe to the store changes that affect button creation
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
// Memoize the expensive button creation
const staticButtons = React.useMemo(() => {
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated]);
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(
container,
selectedNodeState,
isSynapseLinkUpdating,
);
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated, isSynapseLinkUpdating]);
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
const buttons =
@@ -134,11 +135,6 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)");
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
// Add connection status if needed (using the hook values we got at the top level)
if ((isPhoenixNotebooks || isPhoenixFeatures) && connectionInfo?.status !== ConnectionStatusType.Connect) {
uiFabricControlButtons.unshift(CommandBarUtil.createConnectionStatus(container, "connectionStatus"));
}
const rootStyle = {
root: {
backgroundColor: "var(--colorNeutralBackground1)",
@@ -4,7 +4,6 @@ import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
@@ -37,6 +36,18 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(enableAzureSynapseLinkBtn).toBeDefined();
});
it("Button should be disabled while Synapse Link is updating", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(
mockExplorer,
selectedNodeState,
true,
);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
expect(enableAzureSynapseLinkBtn.disabled).toBe(true);
});
// TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the
// Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be
// unsupported in jest and needs to be tested with react-hooks-testing-library.
@@ -99,11 +110,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
});
afterEach(() => {
useNotebook.getState().setIsNotebookEnabled(false);
useNotebook.getState().setIsNotebooksEnabledForAccount(false);
});
it("Cassandra Api not available - button should be hidden", () => {
updateUserContext({
databaseAccount: {
@@ -127,7 +133,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(openCassandraShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
it("Cloud Shell is unavailable - button should be hidden", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
@@ -30,7 +30,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "../../Controls/Dialog";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane";
import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane";
@@ -43,6 +42,7 @@ let counter = 0;
export function createStaticCommandBarButtons(
container: Explorer,
selectedNodeState: SelectedNodeState,
isSynapseLinkUpdating = false,
): CommandButtonComponentProps[] {
if (userContext.authType === AuthType.ResourceToken) {
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
@@ -62,7 +62,7 @@ export function createStaticCommandBarButtons(
userContext.apiType !== "Tables" &&
userContext.apiType !== "Cassandra"
) {
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
const addSynapseLink = createOpenSynapseLinkDialogButton(container, isSynapseLinkUpdating);
if (addSynapseLink) {
addDivider();
buttons.push(addSynapseLink);
@@ -136,14 +136,13 @@ export function createContextCommandBarButtons(
const buttons: CommandButtonComponentProps[] = [];
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
const label =
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
const label = userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
const newMongoShellBtn: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) {
if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else {
selectedCollection && selectedCollection.onNewMongoShellClick();
@@ -157,7 +156,7 @@ export function createContextCommandBarButtons(
}
if (
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
userContext.features.enableCloudShell &&
!selectedNodeState.isDatabaseNodeOrNoneSelected() &&
userContext.apiType === "Cassandra"
) {
@@ -252,7 +251,10 @@ function areScriptsSupported(): boolean {
);
}
function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
function createOpenSynapseLinkDialogButton(
container: Explorer,
isSynapseLinkUpdating: boolean,
): CommandButtonComponentProps {
if (configContext.platform === Platform.Emulator) {
return undefined;
}
@@ -273,7 +275,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
onCommandClick: () => container.openEnableSynapseLinkDialog(),
commandButtonLabel: label,
hasPopup: false,
disabled: useNotebook.getState().isSynapseLinkUpdating,
disabled: isSynapseLinkUpdating,
ariaLabel: label,
};
}
@@ -514,14 +516,9 @@ function createOpenTerminalButtonByKind(
}
};
const label = `Open ${terminalFriendlyName()} Shell`;
const tooltip =
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
const tooltip = "Cloud Shell is not available for this account.";
const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled();
const disableButton =
(!useNotebook.getState().isNotebooksEnabledForAccount &&
!useNotebook.getState().isNotebookEnabled &&
!userContext.features.enableCloudShell) ||
isNativeAuthDisabled;
const disableButton = !userContext.features.enableCloudShell || isNativeAuthDisabled;
return {
iconSrc: HostedTerminalIcon,
iconAlt: label,
@@ -533,7 +530,7 @@ function createOpenTerminalButtonByKind(
});
return;
}
if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) {
if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(terminalKind);
}
},
@@ -17,9 +17,6 @@ import { configContext, Platform } from "../../../ConfigContext";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { ConnectionStatus } from "./ConnectionStatusComponent";
import { MemoryTracker } from "./MemoryTrackerComponent";
/**
* Convert our NavbarButtonConfig to UI Fabric buttons
@@ -258,20 +255,6 @@ export const createDivider = (key: string): ICommandBarItemProps => {
};
};
export const createMemoryTracker = (key: string): ICommandBarItemProps => {
return {
key,
onRender: () => <MemoryTracker />,
};
};
export const createConnectionStatus = (container: Explorer, key: string): ICommandBarItemProps => {
return {
key,
onRender: () => <ConnectionStatus container={container} />,
};
};
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {};
@@ -1,184 +0,0 @@
@import "../../../../less/Common/Constants";
.connectionStatusContainer {
cursor: default;
align-items: center;
border: 1px;
min-height: 44px;
> span {
padding-right: 12px;
font-size: 12px;
font-family: @DataExplorerFont;
color: @DefaultFontColor;
}
&:focus{
outline: 0px;
}
}
.commandReactBtn {
&:hover {
background-color: rgb(238, 247, 255);
color: rgb(32, 31, 30);
cursor: pointer;
}
&:focus{
outline: 1px dashed #605e5c;
}
}
.connectedReactBtn {
&:hover {
background-color: rgb(238, 247, 255);
color: rgb(32, 31, 30);
cursor: pointer;
}
&:focus{
outline: 0px;
}
}
.connectIcon{
margin: 0px 4px;
height: 18px;
width: 18px;
color: rgb(0, 120, 212);
}
.status {
position: relative;
display: block;
margin-right: 8px;
width: 1em;
height: 1em;
font-size: 9px!important;
padding: 0px!important;
border-radius: 0.5em;
}
.status::before,
.status::after {
position: absolute;
content: "";
}
.status::before {
top: 0;
left: 0;
width: 1em;
height: 1em;
background-color: rgba(#fff, 0.1);
border-radius: 100%;
opacity: 1;
transform: translate3d(0, 0, 0) scale(0);
}
.connected{
background-color: green;
box-shadow:
0 0 0 0em rgba(green, 0),
0em 0.05em 0.1em rgba(#000000, 0.2);
transform: translate3d(0, 0, 0) scale(1);
}
.connecting{
background-color:#ffbf00;
box-shadow:
0 0 0 0em rgba(#ffbf00, 0),
0em 0.05em 0.1em rgba(#000000, 0.2);
transform: translate3d(0, 0, 0) scale(1);
}
.failed{
background-color:#bd1919;
box-shadow:
0 0 0 0em rgba(#bd1919, 0),
0em 0.05em 0.1em rgba(#000000, 0.2);
transform: translate3d(0, 0, 0) scale(1);
}
.status.connecting.is-animating {
animation: status-outer-connecting 3000ms infinite;
}
.status.failed.is-animating {
animation: status-outer-failed 3000ms infinite;
}
.status.connected.is-animating {
animation: status-outer-connected 3000ms infinite;
}
@keyframes status-outer-connected {
0% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #008000, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2);
}
20% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.6), 0em 0.05em 0.1em rgba(0, 0, 0, 0.5);
}
40% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.5), 0em 0.05em 0.1em rgba(0, 0, 0, 0.4);
}
60% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3);
}
80% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1);
}
85% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0);
}
}
@keyframes status-outer-failed {
0% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #bd1919, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2);
}
20% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #c52d2d, 0em 0.05em 0.1em rgba(0, 0, 0, 0.5);
}
40% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #b47b7b, 0em 0.05em 0.1em rgba(0, 0, 0, 0.4);
}
60% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3);
}
80% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1);
}
85% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0);
}
}
@keyframes status-outer-connecting {
0% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #ffbf00, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2);
}
20% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em #f0dfad, 0em 0.05em 0.1em rgba(0, 0, 0, 0.5);
}
40% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(198, 243, 198, 0.5), 0em 0.05em 0.1em rgba(0, 0, 0, 0.4);
}
60% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(213, 241, 213, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3);
}
80% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1);
}
85% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0);
}
}
@@ -1,186 +0,0 @@
import {
FocusTrapCallout,
FocusZone,
FocusZoneTabbableElements,
FontWeights,
Icon,
mergeStyleSets,
ProgressIndicator,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import { ActionButton, DefaultButton } from "@fluentui/react/lib/Button";
import * as React from "react";
import "../../../../less/hostedexplorer.less";
import { ConnectionStatusType, ContainerStatusType, Notebook } from "../../../Common/Constants";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import "../CommandBar/ConnectionStatusComponent.less";
interface Props {
container: Explorer;
}
export const ConnectionStatus: React.FC<Props> = ({ container }: Props): JSX.Element => {
const connectionInfo = useNotebook((state) => state.connectionInfo);
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("");
const [toolTipContent, setToolTipContent] = React.useState("Connect to temporary workspace.");
const [isBarDismissed, setIsBarDismissed] = React.useState<boolean>(false);
const buttonId = useId("callout-button");
const containerInfo = useNotebook((state) => state.containerStatus);
const styles = mergeStyleSets({
callout: {
width: 320,
padding: "20px 24px",
},
title: {
marginBottom: 12,
fontWeight: FontWeights.semilight,
},
buttons: {
display: "flex",
justifyContent: "flex-end",
marginTop: 20,
},
});
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]);
React.useEffect(() => {
if (connectionInfo?.status === ConnectionStatusType.Reconnect) {
setToolTipContent("Click here to Reconnect to temporary workspace.");
} else if (connectionInfo?.status === ConnectionStatusType.Failed) {
setStatusColor("status failed is-animating");
setToolTipContent("Click here to Reconnect to temporary workspace.");
}
}, [connectionInfo.status]);
const stopTimer = () => {
setIsActive(false);
setCounter(0);
setSecond("00");
setMinute("00");
};
const memoryUsageInfo = useNotebook((state) => state.memoryUsageInfo);
const totalGB = memoryUsageInfo ? memoryUsageInfo.totalKB / Notebook.memoryGuageToGB : 0;
const usedGB = totalGB > 0 ? totalGB - memoryUsageInfo.freeKB / Notebook.memoryGuageToGB : 0;
if (
connectionInfo &&
(connectionInfo.status === ConnectionStatusType.Connect || connectionInfo.status === ConnectionStatusType.Reconnect)
) {
return (
<ActionButton className="commandReactBtn" onClick={() => container.allocateContainer()}>
<TooltipHost content={toolTipContent}>
<Stack className="connectionStatusContainer" horizontal>
<Icon iconName="ConnectVirtualMachine" className="connectIcon" />
<span>{connectionInfo.status}</span>
</Stack>
</TooltipHost>
</ActionButton>
);
}
if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connecting && isActive === false) {
stopTimer();
setIsActive(true);
setStatusColor("status connecting is-animating");
setToolTipContent("Connecting to temporary workspace.");
} else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connected && isActive === true) {
stopTimer();
setStatusColor("status connected is-animating");
setToolTipContent("Connected to temporary workspace.");
} else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Failed && isActive === true) {
stopTimer();
setStatusColor("status failed is-animating");
setToolTipContent("Click here to Reconnect to temporary workspace.");
}
return (
<>
<TooltipHost
content={
containerInfo?.status === ContainerStatusType.Active
? `Connected to temporary workspace. This temporary workspace will get disconnected in ${Math.round(
containerInfo.durationLeftInMinutes,
)} minutes.`
: toolTipContent
}
>
<ActionButton
id={buttonId}
className={connectionInfo.status === ConnectionStatusType.Failed ? "commandReactBtn" : "connectedReactBtn"}
onClick={(e: React.MouseEvent<HTMLSpanElement>) =>
connectionInfo.status === ConnectionStatusType.Failed ? container.allocateContainer() : e.preventDefault()
}
>
<Stack className="connectionStatusContainer" horizontal>
<i className={statusColor}></i>
<span className={connectionInfo.status === ConnectionStatusType.Failed ? "connectionStatusFailed" : ""}>
{connectionInfo.status}
</span>
{connectionInfo.status === ConnectionStatusType.Connecting && isActive && (
<ProgressIndicator description={minute + ":" + second} />
)}
{connectionInfo.status === ConnectionStatusType.Connected && !isActive && (
<ProgressIndicator
className={totalGB !== 0 && usedGB / totalGB > 0.8 ? "lowMemory" : ""}
description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
percentComplete={totalGB !== 0 ? usedGB / totalGB : 0}
/>
)}
</Stack>
{!isBarDismissed &&
containerInfo.status &&
containerInfo.status === ContainerStatusType.Active &&
Math.round(containerInfo.durationLeftInMinutes) <= Notebook.remainingTimeForAlert ? (
<FocusTrapCallout
role="alertdialog"
className={styles.callout}
gapSpace={0}
target={`#${buttonId}`}
onDismiss={() => setIsBarDismissed(true)}
setInitialFocus
>
<Text block variant="xLarge" className={styles.title}>
Remaining Time
</Text>
<Text block variant="small">
This temporary workspace will get disconnected in {Math.round(containerInfo.durationLeftInMinutes)}{" "}
minutes. To save your work permanently, save your notebooks to a GitHub repository or download the
notebooks to your local machine before the session ends.
</Text>
<FocusZone handleTabKey={FocusZoneTabbableElements.all} isCircularNavigation>
<Stack className={styles.buttons} gap={8} horizontal>
<DefaultButton onClick={() => setIsBarDismissed(true)}>Dimiss</DefaultButton>
</Stack>
</FocusZone>
</FocusTrapCallout>
) : undefined}
</ActionButton>
</TooltipHost>
</>
);
};
@@ -1,24 +0,0 @@
@import "../../../../less/Common/Constants";
.memoryTrackerContainer {
cursor: default;
align-items: center;
margin: 0 9px;
border: 1px;
> span {
padding-right: 12px;
font-size: 13px;
font-family: @DataExplorerFont;
color: @DefaultFontColor;
}
> .lowMemory {
.ms-ProgressIndicator-progressBar {
background-color: @SelectionHigh;
}
.ms-ProgressIndicator-itemDescription {
color: @SelectionHigh;
}
}
}
@@ -1,29 +0,0 @@
import { ProgressIndicator, Spinner, SpinnerSize, Stack } from "@fluentui/react";
import * as React from "react";
import { useNotebook } from "../../Notebook/useNotebook";
export const MemoryTracker: React.FC = (): JSX.Element => {
const memoryUsageInfo = useNotebook((state) => state.memoryUsageInfo);
if (!memoryUsageInfo) {
return (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<Spinner size={SpinnerSize.medium} />
</Stack>
);
}
const totalGB = memoryUsageInfo.totalKB / 1048576;
const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576;
return (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<ProgressIndicator
className={usedGB / totalGB > 0.8 ? "lowMemory" : ""}
description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
percentComplete={usedGB / totalGB}
/>
</Stack>
);
};
@@ -1,195 +0,0 @@
/**
* Notebook container related stuff
*/
import { useDialog } from "Explorer/Controls/Dialog";
import promiseRetry, { AbortError, Options } from "p-retry";
import { PhoenixClient } from "Phoenix/PhoenixClient";
import * as Constants from "../../Common/Constants";
import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook, PoolIdType } from "../../Common/Constants";
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import * as DataModels from "../../Contracts/DataModels";
import { IPhoenixServiceInfo, IProvisionData, IResponse } from "../../Contracts/DataModels";
import { userContext } from "../../UserContext";
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { useNotebook } from "./useNotebook";
export class NotebookContainerClient {
private clearReconnectionAttemptMessage? = () => {};
private isResettingWorkspace: boolean;
private phoenixClient: PhoenixClient;
private retryOptions: Options;
private scheduleTimerId: NodeJS.Timeout;
constructor(private onConnectionLost: () => void) {
this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id);
this.retryOptions = {
retries: Notebook.retryAttempts,
maxTimeout: Notebook.retryAttemptDelayMs,
minTimeout: Notebook.retryAttemptDelayMs,
};
this.initHeartbeat(Constants.Notebook.heartbeatDelayMs);
}
private initHeartbeat(delayMs: number): void {
this.scheduleHeartbeat(delayMs);
useNotebook.subscribe(
() => this.scheduleHeartbeat(delayMs),
(state) => state.notebookServerInfo,
);
}
private scheduleHeartbeat(delayMs: number) {
if (this.scheduleTimerId) {
clearInterval(this.scheduleTimerId);
}
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo?.notebookServerEndpoint) {
this.scheduleTimerId = setInterval(async () => {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (notebookServerInfo?.notebookServerEndpoint) {
const memoryUsageInfo = await this.getMemoryUsage();
useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo);
}
}, delayMs);
}
}
public async getMemoryUsage(): Promise<DataModels.MemoryUsageInfo> {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) {
const error = "No server endpoint detected";
Logger.logError(error, "NotebookContainerClient/getMemoryUsage");
return Promise.reject(error);
}
if (this.isResettingWorkspace) {
return undefined;
}
const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig();
try {
const runMemoryAsync = async () => {
return await this._getMemoryAsync(notebookServerEndpoint, authToken);
};
return await promiseRetry(runMemoryAsync, this.retryOptions);
} catch (error) {
Logger.logError(getErrorMessage(error), "NotebookContainerClient/getMemoryUsage");
if (!this.clearReconnectionAttemptMessage) {
this.clearReconnectionAttemptMessage = logConsoleProgress(
"Connection lost with Notebook server. Attempting to reconnect...",
);
}
this.onConnectionLost();
return undefined;
}
}
private async _getMemoryAsync(
notebookServerEndpoint: string,
authToken: string,
): Promise<DataModels.MemoryUsageInfo> {
if (this.shouldExecuteMemoryCall()) {
const response = await fetch(`${notebookServerEndpoint}api/metrics/memory`, {
method: "GET",
headers: {
Authorization: authToken,
"content-type": "application/json",
},
});
if (response.ok) {
if (this.clearReconnectionAttemptMessage) {
this.clearReconnectionAttemptMessage();
this.clearReconnectionAttemptMessage = undefined;
}
const memoryUsageInfo = await response.json();
if (memoryUsageInfo) {
return {
totalKB: memoryUsageInfo.total,
freeKB: memoryUsageInfo.free,
};
}
} else if (response.status === HttpStatusCodes.NotFound) {
throw new AbortError(response.statusText);
}
throw new Error(response.statusText);
} else {
return undefined;
}
}
private shouldExecuteMemoryCall(): boolean {
return (
useNotebook.getState().containerStatus?.status === Constants.ContainerStatusType.Active &&
useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected
);
}
public async resetWorkspace(): Promise<IResponse<IPhoenixServiceInfo>> {
this.isResettingWorkspace = true;
let response: IResponse<IPhoenixServiceInfo>;
try {
response = await this._resetWorkspace();
} catch (error) {
Promise.reject(error);
return response;
}
this.isResettingWorkspace = false;
return response;
}
private async _resetWorkspace(): Promise<IResponse<IPhoenixServiceInfo>> {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) {
const error = "No server endpoint detected";
Logger.logError(error, "NotebookContainerClient/resetWorkspace");
return Promise.reject(error);
}
try {
if (useNotebook.getState().isPhoenixNotebooks) {
const provisionData: IProvisionData = {
cosmosEndpoint: userContext.databaseAccount.properties.documentEndpoint,
poolId: PoolIdType.DefaultPoolId,
};
return await this.phoenixClient.resetContainer(provisionData);
}
return null;
} catch (error) {
Logger.logError(getErrorMessage(error), "NotebookContainerClient/resetWorkspace");
if (error?.status === HttpStatusCodes.Forbidden && error.message) {
useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`);
} else {
useDialog
.getState()
.showOkModalDialog(
"Connection Failed",
"We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.",
);
}
throw error;
}
}
private getNotebookServerConfig(): { notebookServerEndpoint: string; authToken: string } {
const notebookServerInfo = useNotebook.getState().notebookServerInfo;
const authToken: string = notebookServerInfo.authToken ? `Token ${notebookServerInfo.authToken}` : undefined;
return {
notebookServerEndpoint: notebookServerInfo.notebookServerEndpoint,
authToken,
};
}
private getHeaders(): HeadersInit {
const authorizationHeader = getAuthorizationHeader();
return {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
};
}
}
@@ -1,14 +0,0 @@
export interface NotebookContentItem {
name: string;
path: string;
type: NotebookContentItemType;
children?: NotebookContentItem[];
parent?: NotebookContentItem;
timestamp?: number;
}
export enum NotebookContentItemType {
Notebook,
File,
Directory,
}
-32
View File
@@ -1,32 +0,0 @@
/*
* Contains all notebook related stuff meant to be dynamically loaded by explorer
*/
import { JunoClient } from "../../Juno/JunoClient";
import { userContext } from "../../UserContext";
import Explorer from "../Explorer";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import { NotebookContainerClient } from "./NotebookContainerClient";
export interface NotebookManagerOptions {
container: Explorer;
resourceTree: ResourceTreeAdapter;
refreshCommandBarButtons: () => void;
refreshNotebookList: () => void;
}
export default class NotebookManager {
private params: NotebookManagerOptions;
public junoClient: JunoClient;
public notebookClient: NotebookContainerClient;
public initialize(params: NotebookManagerOptions): void {
this.params = params;
this.junoClient = new JunoClient();
this.notebookClient = new NotebookContainerClient(() =>
this.params.container.initNotebooks(userContext?.databaseAccount),
);
}
}
@@ -1,42 +0,0 @@
import { NotebookUtil } from "./NotebookUtil";
const fileName = "file";
const notebookName = "file.ipynb";
const folderPath = "folder";
const filePath = `${folderPath}/${fileName}`;
const notebookPath = `${folderPath}/${notebookName}`;
describe("NotebookUtil", () => {
describe("isNotebookFile", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.isNotebookFile(filePath)).toBeFalsy();
expect(NotebookUtil.isNotebookFile(notebookPath)).toBeTruthy();
});
});
describe("getFilePath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath);
});
});
describe("getParentPath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath);
});
});
describe("getName", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getName(filePath)).toEqual(fileName);
expect(NotebookUtil.getName(notebookPath)).toEqual(notebookName);
});
});
describe("replaceName", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.replaceName(filePath, "newName")).toEqual(filePath.replace(fileName, "newName"));
expect(NotebookUtil.replaceName(notebookPath, "newName")).toEqual(notebookPath.replace(notebookName, "newName"));
});
});
});
-81
View File
@@ -1,81 +0,0 @@
import * as StringUtils from "../../Utils/StringUtils";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
// Must match rx-jupyter' FileType
export type FileType = "directory" | "file" | "notebook";
// Utilities for notebooks
export class NotebookUtil {
/**
* It's a notebook file if the filename ends with .ipynb.
*/
public static isNotebookFile(notebookPath: string): boolean {
const fileName = NotebookUtil.getName(notebookPath);
return !!fileName && StringUtils.endsWith(fileName, ".ipynb");
}
/**
* Note: this does not connect the item to a parent in a tree.
* @param name
* @param path
*/
public static createNotebookContentItem(name: string, path: string, type: FileType): NotebookContentItem {
return {
name,
path,
type: NotebookUtil.getType(type),
timestamp: NotebookUtil.getCurrentTimestamp(),
};
}
/**
* Convert rx-jupyter type to our type
* @param type
*/
public static getType(type: FileType): NotebookContentItemType {
switch (type) {
case "directory":
return NotebookContentItemType.Directory;
case "notebook":
return NotebookContentItemType.Notebook;
case "file":
return NotebookContentItemType.File;
default:
throw new Error(`Unknown file type: ${type}`);
}
}
public static getCurrentTimestamp(): number {
return new Date().getTime();
}
public static getFilePath(path: string, fileName: string): string {
return `${path}/${fileName}`;
}
public static getParentPath(filepath: string): undefined | string {
const basename = NotebookUtil.getName(filepath);
if (basename) {
const parentPath = filepath.split(basename).shift();
if (parentPath) {
return parentPath.replace(/\/$/, ""); // no trailling slash
}
}
return undefined;
}
public static getName(path: string): undefined | string {
return path.split("/").pop();
}
public static replaceName(path: string, newName: string): string {
const contentName = path.split("/").pop();
if (!contentName) {
throw new Error(`Failed to extract name from path ${path}`);
}
const basePath = path.split(contentName).shift();
return `${basePath}${newName}`;
}
}
-248
View File
@@ -1,248 +0,0 @@
import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants";
import { ConnectionStatusType } from "../../Common/Constants";
import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels";
import { ContainerConnectionInfo, ContainerInfo } from "../../Contracts/DataModels";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
import { useTabs } from "../../hooks/useTabs";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
interface NotebookState {
isNotebookEnabled: boolean;
isNotebooksEnabledForAccount: boolean;
notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo;
sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo;
isSynapseLinkUpdating: boolean;
memoryUsageInfo: DataModels.MemoryUsageInfo;
isShellEnabled: boolean;
notebookBasePath: string;
isInitializingNotebooks: boolean;
myNotebooksContentRoot: NotebookContentItem;
galleryContentRoot: NotebookContentItem;
connectionInfo: ContainerConnectionInfo;
notebookFolderName: string;
isAllocating: boolean;
isRefreshed: boolean;
containerStatus: ContainerInfo;
isPhoenixNotebooks: boolean;
isPhoenixFeatures: boolean;
setIsNotebookEnabled: (isNotebookEnabled: boolean) => void;
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void;
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void;
setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) => void;
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void;
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) => void;
updateNotebookItem: (item: NotebookContentItem) => void;
deleteNotebookItem: (item: NotebookContentItem) => void;
initializeNotebooksTree: () => Promise<void>;
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void;
setIsAllocating: (isAllocating: boolean) => void;
resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void;
setIsRefreshed: (isAllocating: boolean) => void;
setContainerStatus: (containerStatus: ContainerInfo) => void;
getPhoenixStatus: () => Promise<void>;
setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => void;
setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => void;
}
export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
isNotebookEnabled: false,
isNotebooksEnabledForAccount: false,
notebookServerInfo: {
notebookServerEndpoint: undefined,
authToken: undefined,
forwardingId: undefined,
},
sparkClusterConnectionInfo: {
userName: undefined,
password: undefined,
endpoints: [],
},
isSynapseLinkUpdating: false,
memoryUsageInfo: undefined,
isShellEnabled: false,
notebookBasePath: Constants.Notebook.defaultBasePath,
isInitializingNotebooks: false,
myNotebooksContentRoot: undefined,
galleryContentRoot: undefined,
connectionInfo: {
status: ConnectionStatusType.Connect,
},
notebookFolderName: undefined,
isAllocating: false,
isRefreshed: false,
containerStatus: {
status: undefined,
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
},
isPhoenixNotebooks: undefined,
isPhoenixFeatures: undefined,
setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }),
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }),
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) =>
set({ notebookServerInfo }),
setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) =>
set({ sparkClusterConnectionInfo }),
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }),
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> => {
await get().getPhoenixStatus();
const { databaseAccount, authType } = userContext;
if (
authType === AuthType.EncryptedToken ||
authType === AuthType.ResourceToken ||
authType === AuthType.MasterKey
) {
set({ isNotebooksEnabledForAccount: false });
return;
}
const firstWriteLocation =
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
? databaseAccount?.location
: databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase();
const disallowedLocationsUri: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`;
const authorizationHeader = getAuthorizationHeader();
const startKey = TelemetryProcessor.traceStart(Action.RefreshNotebooksEnabled, {
dataExplorerArea: "Notebook",
});
try {
const response = await fetch(disallowedLocationsUri, {
method: "POST",
body: JSON.stringify({
resourceTypes: [Constants.ArmResourceTypes.notebookWorkspaces],
}),
headers: {
[authorizationHeader.header]: authorizationHeader.token,
[Constants.HttpHeaders.contentType]: "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to fetch disallowed locations");
}
const disallowedLocations: string[] = await response.json();
if (!disallowedLocations) {
Logger.logInfo("No disallowed locations found", "Explorer/isNotebooksEnabledForAccount");
set({ isNotebooksEnabledForAccount: true });
return;
}
// firstWriteLocation should not be disallowed
const isAccountInAllowedLocation = firstWriteLocation && disallowedLocations.indexOf(firstWriteLocation) === -1;
set({ isNotebooksEnabledForAccount: isAccountInAllowedLocation });
TelemetryProcessor.traceSuccess(Action.RefreshNotebooksEnabled, { isAccountInAllowedLocation }, startKey);
} catch (error) {
Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount");
set({ isNotebooksEnabledForAccount: false });
TelemetryProcessor.traceFailure(Action.RefreshNotebooksEnabled, { error: getErrorMessage(error) }, startKey);
}
},
findItem: (root: NotebookContentItem, item: NotebookContentItem): NotebookContentItem => {
const currentItem = root || get().myNotebooksContentRoot;
if (currentItem) {
if (currentItem.path === item.path && currentItem.name === item.name) {
return currentItem;
}
if (currentItem.children) {
for (const childItem of currentItem.children) {
const result = get().findItem(childItem, item);
if (result) {
return result;
}
}
}
}
return undefined;
},
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem): void => {
const root = cloneDeep(get().myNotebooksContentRoot);
const parentItem = get().findItem(root, parent);
item.parent = parentItem;
if (parentItem.children) {
parentItem.children.push(item);
} else {
parentItem.children = [item];
}
set({ myNotebooksContentRoot: root });
},
updateNotebookItem: (item: NotebookContentItem): void => {
const root = cloneDeep(get().myNotebooksContentRoot);
const parentItem = get().findItem(root, item.parent);
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
parentItem.children.push(item);
item.parent = parentItem;
set({ myNotebooksContentRoot: root });
},
deleteNotebookItem: (item: NotebookContentItem): void => {
const root = cloneDeep(get().myNotebooksContentRoot);
const parentItem = get().findItem(root, item.parent);
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
set({ myNotebooksContentRoot: root });
},
initializeNotebooksTree: async (): Promise<void> => {
const notebookFolderName = get().isPhoenixNotebooks ? "Temporary Notebooks" : "My Notebooks";
set({ notebookFolderName });
const myNotebooksContentRoot = {
name: get().notebookFolderName,
path: get().notebookBasePath,
type: NotebookContentItemType.Directory,
};
const galleryContentRoot = {
name: "Gallery",
path: "Gallery",
type: NotebookContentItemType.File,
};
set({
myNotebooksContentRoot,
galleryContentRoot,
});
},
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => set({ connectionInfo }),
setIsAllocating: (isAllocating: boolean) => set({ isAllocating }),
resetContainerConnection: (connectionStatus: ContainerConnectionInfo): void => {
useTabs.getState().closeAllNotebookTabs(true);
useNotebook.getState().setConnectionInfo(connectionStatus);
useNotebook.getState().setNotebookServerInfo(undefined);
useNotebook.getState().setIsAllocating(false);
useNotebook.getState().setContainerStatus({
status: undefined,
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
});
},
setIsRefreshed: (isRefreshed: boolean) => set({ isRefreshed }),
setContainerStatus: (containerStatus: ContainerInfo) => set({ containerStatus }),
getPhoenixStatus: async () => {
if (get().isPhoenixNotebooks === undefined || get().isPhoenixFeatures === undefined) {
// getDbAccountAllowedStatus has been deprecated; Phoenix features are no longer available.
set({ isPhoenixNotebooks: false });
set({ isPhoenixFeatures: false });
}
},
setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => set({ isPhoenixNotebooks: isPhoenixNotebooks }),
setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => set({ isPhoenixFeatures: isPhoenixFeatures }),
}));
@@ -145,25 +145,15 @@ exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
<AnalyticalStoreComponent
explorer={
Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"parameters": [Function],
@@ -43,7 +43,6 @@ import { getCollectionName } from "../../Utils/APITypeUtils";
import { useDialog } from "../Controls/Dialog";
import Explorer from "../Explorer";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
@@ -188,12 +187,6 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
React.useEffect(() => {
subscriptions.push(
{
dispose: useNotebook.subscribe(
() => setState({}),
(state) => state.isNotebookEnabled,
),
},
{ dispose: useSelectedNode.subscribe(() => setState({})) },
{
dispose: useCarousel.subscribe(
@@ -35,15 +35,6 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
jest.mock("Common/dataAccess/queryDocuments", () => ({
queryDocuments: jest.fn(() => ({
// Omit headers, because we can't mock a private field and we don't need to test it
@@ -19,15 +19,6 @@ import { act } from "react-dom/test-utils";
import * as ViewModels from "../../../Contracts/ViewModels";
import Explorer from "../../Explorer";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
jest.requireActual("Explorer/Controls/Editor/EditorReact");
const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__";
-1
View File
@@ -5,7 +5,6 @@ import {
TriggerDefinition,
UserDefinedFunctionDefinition,
} from "@azure/cosmos";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { isFabricMirrored } from "Platform/Fabric/FabricUtil";
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
-9
View File
@@ -6,15 +6,6 @@ import { updateUserContext, userContext } from "../../UserContext";
import Explorer from "../Explorer";
import Database from "./Database";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
const createMockContainer = (): Explorer => {
const mockContainer = new Explorer();
return mockContainer;
+2 -29
View File
@@ -18,12 +18,8 @@ import { userContext } from "UserContext";
import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react";
import { useEffect, useMemo } from "react";
import shallow from "zustand/shallow";
import { useDatabaseLoadScenario } from "../../Metrics/useMetricPhases";
import Explorer from "../Explorer";
import { useNotebook } from "../Notebook/useNotebook";
export const MyNotebooksTitle = "My Notebooks";
interface ResourceTreeProps {
explorer: Explorer;
@@ -40,13 +36,6 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
const [openItems, setOpenItems] = React.useState<TreeItemValue[]>([]);
const treeStyles = useTreeStyles();
const { isNotebookEnabled } = useNotebook(
(state) => ({
isNotebookEnabled: state.isNotebookEnabled,
}),
shallow,
);
// We intentionally avoid using a state selector here because we want to re-render the tree if the active tab changes.
const { refreshActiveTab } = useTabs();
@@ -65,24 +54,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
const databaseTreeNodes = useMemo(() => {
return userContext.authType === AuthType.ResourceToken
? createResourceTokenTreeNodes(resourceTokenCollection)
: createDatabaseTreeNodes(
explorer,
isNotebookEnabled,
databases,
refreshActiveTab,
searchText,
sortOrder,
pinnedDatabaseIds,
);
}, [
resourceTokenCollection,
databases,
isNotebookEnabled,
refreshActiveTab,
searchText,
sortOrder,
pinnedDatabaseIds,
]);
: createDatabaseTreeNodes(explorer, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds);
}, [resourceTokenCollection, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds]);
const headerNodes: TreeNode[] = isFabricMirrored()
? []
-148
View File
@@ -1,34 +1,20 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout";
import * as React from "react";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import DeleteIcon from "../../../images/delete.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 FileIcon from "../../../images/notebook/file-cosmos.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
import { useTabs } from "../../hooks/useTabs";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { useDialog } from "../Controls/Dialog";
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook";
import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
@@ -37,16 +23,10 @@ import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
private static readonly DataTitle = "DATA";
private static readonly NotebooksTitle = "NOTEBOOKS";
private static readonly PseudoDirPath = "PsuedoDir";
public parameters: ko.Observable<number>;
public myNotebooksContentRoot: NotebookContentItem;
public constructor(private container: Explorer) {
this.parameters = ko.observable(Date.now());
@@ -55,56 +35,15 @@ export class ResourceTreeAdapter implements ReactAdapter {
() => this.triggerRender(),
(state) => state.activeTab,
);
useNotebook.subscribe(
() => this.triggerRender(),
(state) => state.isNotebookEnabled,
);
useDatabases.subscribe(() => this.triggerRender());
this.triggerRender();
}
private traceMyNotebookTreeInfo() {
const myNotebooksTree = this.myNotebooksContentRoot;
if (myNotebooksTree.children) {
// Count 1st generation children (tree is lazy-loaded)
const nodeCounts = { files: 0, notebooks: 0, directories: 0 };
myNotebooksTree.children.forEach((treeNode) => {
switch ((treeNode as NotebookContentItem).type) {
case NotebookContentItemType.File:
nodeCounts.files++;
break;
case NotebookContentItemType.Directory:
nodeCounts.directories++;
break;
case NotebookContentItemType.Notebook:
nodeCounts.notebooks++;
break;
default:
break;
}
});
TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts });
}
}
public renderComponent(): JSX.Element {
const dataRootNode = this.buildDataTree();
return <LegacyTreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
}
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
this.myNotebooksContentRoot = {
name: useNotebook.getState().notebookFolderName,
path: useNotebook.getState().notebookBasePath,
type: NotebookContentItemType.Directory,
};
return Promise.all(refreshTasks);
}
private buildDataTree(): LegacyTreeNode {
const databaseTreeNodes: LegacyTreeNode[] = useDatabases
.getState()
@@ -403,93 +342,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
return traverse(schema);
}
private buildChildNodes(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean,
createFileContextMenu: boolean,
): LegacyTreeNode[] {
if (!item || !item.children) {
return [];
} else {
return item.children.map((item) => {
const result =
item.type === NotebookContentItemType.Directory
? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu)
: this.buildNotebookFileNode(item, onFileClick, createFileContextMenu);
result.timestamp = item.timestamp;
return result;
});
}
}
private buildNotebookFileNode(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createFileContextMenu: boolean,
): LegacyTreeNode {
return {
label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
className: "notebookHeader",
onClick: () => onFileClick(item),
isSelected: () => {
const activeTab = useTabs.getState().activeTab;
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 && this.createFileContextMenu(),
data: item,
};
}
private createFileContextMenu(): TreeNodeMenuItem[] {
return [];
}
private createDirectoryContextMenu(): TreeNodeMenuItem[] {
return [];
}
private buildNotebookDirectoryNode(
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean,
createFileContextMenu: boolean,
): LegacyTreeNode {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: undefined,
isSelected: () => {
const activeTab = useTabs.getState().activeTab;
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:
createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath
? this.createDirectoryContextMenu()
: undefined,
data: item,
children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu),
};
}
public triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
@@ -363,7 +363,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
]
`;
exports[`createDatabaseTreeNodes generates the correct tree structure for the Mongo API, with Notebooks and Phoenix features, on Emulator 1`] = `
exports[`createDatabaseTreeNodes generates the correct tree structure for the Mongo API, on Emulator 1`] = `
[
{
"children": [
+20 -36
View File
@@ -4,7 +4,6 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure";
@@ -363,7 +362,7 @@ describe("createDatabaseTreeNodes", () => {
},
} as never,
});
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
});
it("creates expected tree", () => {
@@ -371,11 +370,10 @@ describe("createDatabaseTreeNodes", () => {
});
});
it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
it.each<[string, Platform, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
[
"the SQL API, on Fabric read-only (mirrored)",
Platform.Fabric,
false,
{ capabilities: [], enableMultipleWriteLocations: true },
{
fabricContext: {
@@ -387,7 +385,6 @@ describe("createDatabaseTreeNodes", () => {
[
"the SQL API, on Fabric non read-only (native)",
Platform.Fabric,
false,
{ capabilities: [], enableMultipleWriteLocations: true },
{
fabricContext: {
@@ -399,7 +396,6 @@ describe("createDatabaseTreeNodes", () => {
[
"the SQL API, on Portal",
Platform.Portal,
false,
{ capabilities: [], enableMultipleWriteLocations: true },
{
fabricContext: undefined,
@@ -408,7 +404,6 @@ describe("createDatabaseTreeNodes", () => {
[
"the Cassandra API, serverless, on Hosted",
Platform.Hosted,
false,
{
capabilities: [
{ name: CapabilityNames.EnableCassandra, description: "" },
@@ -418,45 +413,34 @@ describe("createDatabaseTreeNodes", () => {
{ fabricContext: undefined },
],
[
"the Mongo API, with Notebooks and Phoenix features, on Emulator",
"the Mongo API, on Emulator",
Platform.Emulator,
true,
{
capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }],
},
{ fabricContext: undefined },
],
])(
"generates the correct tree structure for %s",
(_, platform, isNotebookEnabled, dbAccountProperties, userContext) => {
useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled });
updateConfigContext({ platform });
updateUserContext({
...userContext,
databaseAccount: {
properties: {
enableMultipleWriteLocations: true,
...dbAccountProperties,
},
} as unknown as DataModels.DatabaseAccount,
});
const nodes = createDatabaseTreeNodes(
explorer,
isNotebookEnabled,
useDatabases.getState().databases,
refreshActiveTab,
"",
);
expect(nodes).toMatchSnapshot();
},
);
])("generates the correct tree structure for %s", (_, platform, dbAccountProperties, userContext) => {
updateConfigContext({ platform });
updateUserContext({
...userContext,
databaseAccount: {
properties: {
enableMultipleWriteLocations: true,
...dbAccountProperties,
},
} as unknown as DataModels.DatabaseAccount,
});
const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
expect(nodes).toMatchSnapshot();
});
// The above tests focused on the tree structure. The below tests focus on some core behaviors of the nodes.
// They are not exhaustive, because exhaustive tests here require a lot of mocking and can become very brittle.
// The goal is to cover some key behaviors like loading child nodes, opening tabs/side panels, etc.
it("adds new collections to database as they appear", () => {
const nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
const giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
expect(giganticDbNode).toBeDefined();
expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]);
@@ -488,7 +472,7 @@ describe("createDatabaseTreeNodes", () => {
},
} as unknown as DataModels.DatabaseAccount,
});
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
standardDbNode = nodes.find((node) => node.label === standardDb.id());
sharedDbNode = nodes.find((node) => node.label === sharedDb.id());
giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
@@ -643,7 +627,7 @@ describe("createDatabaseTreeNodes", () => {
setup();
// Rebuild the nodes after changing the user/config context.
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
standardDbNode = nodes.find((node) => node.label === standardDb.id());
standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id());
+2 -7
View File
@@ -137,7 +137,6 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
export const createDatabaseTreeNodes = (
container: Explorer,
isNotebookEnabled: boolean,
databases: ViewModels.Database[],
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
searchText = "",
@@ -184,9 +183,7 @@ export const createDatabaseTreeNodes = (
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
databaseNode.children.push(buildCollectionNode(database, collection, container, refreshActiveTab)),
);
if (database.collectionsContinuationToken) {
@@ -254,14 +251,13 @@ export const createDatabaseTreeNodes = (
export const buildCollectionNode = (
database: ViewModels.Database,
collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode => {
let children: TreeNode[];
// Flat Tree for Fabric
if (!isFabricMirrored()) {
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab);
children = buildCollectionNodeChildren(database, collection, container, refreshActiveTab);
}
const collectionNode: TreeNode = {
@@ -310,7 +306,6 @@ export const buildCollectionNode = (
const buildCollectionNodeChildren = (
database: ViewModels.Database,
collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode[] => {
-6
View File
@@ -1,5 +1,3 @@
import { ConnectionStatusType } from "Common/Constants";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import create, { UseStore } from "zustand";
import * as ViewModels from "../Contracts/ViewModels";
import { useTabs } from "../hooks/useTabs";
@@ -13,7 +11,6 @@ export interface SelectedNodeState {
collectionId?: string,
subnodeKinds?: ViewModels.CollectionTabKind[],
) => boolean;
isConnectedToContainer: () => boolean;
}
export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) => ({
@@ -61,7 +58,4 @@ export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) =>
subnodeKinds.includes(selectedSubnodeKind)
);
},
isConnectedToContainer: (): boolean => {
return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected;
},
}));
+2 -2
View File
@@ -83,9 +83,9 @@ const App: React.FunctionComponent = () => {
setEncryptedToken(token);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
.catch(async (error) => {
logError(
`Failed to connect with connection string: ${getErrorMessage(error)}`,
`Failed to connect with connection string: ${await (error as Response).text()}`,
"HostedExplorer/connectWithConnectionString",
);
});
-2
View File
@@ -52,8 +52,6 @@ import { ErrorBoundary } from "./Explorer/ErrorBoundary";
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";
import "./Explorer/Panes/PanelComponent.less";
-256
View File
@@ -1,256 +0,0 @@
import { configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext";
import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils";
import promiseRetry, { AbortError, Options } from "p-retry";
import {
Areas,
ConnectionStatusType,
ContainerStatusType,
HttpHeaders,
HttpStatusCodes,
Notebook,
} from "../Common/Constants";
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
import {
ContainerConnectionInfo,
ContainerInfo,
IContainerData,
IMaxAllocationTimeExceeded,
IPhoenixConnectionInfoResult,
IPhoenixError,
IPhoenixServiceInfo,
IProvisionData,
IResponse,
PhoenixErrorType,
} from "../Contracts/DataModels";
import { useNotebook } from "../Explorer/Notebook/useNotebook";
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
export class PhoenixClient {
private armResourceId: string;
private containerHealthHandler: NodeJS.Timeout;
private retryOptions: Options = {
retries: Notebook.retryAttempts,
maxTimeout: Notebook.retryAttemptDelayMs,
minTimeout: Notebook.retryAttemptDelayMs,
};
private abortController: AbortController;
private abortSignal: AbortSignal;
constructor(armResourceId: string) {
this.armResourceId = armResourceId;
}
public async allocateContainer(provisionData: IProvisionData): Promise<IResponse<IPhoenixServiceInfo>> {
this.initializeCancelEventListener();
return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), {
retries: 4,
maxTimeout: 20000,
minTimeout: 20000,
signal: this.abortSignal,
});
}
public async resetContainer(provisionData: IProvisionData): Promise<IResponse<IPhoenixServiceInfo>> {
return this.executeContainerAssignmentOperation(provisionData, "reset");
}
private async executeContainerAssignmentOperation(
provisionData: IProvisionData,
operation: string,
): Promise<IResponse<IPhoenixServiceInfo>> {
let response;
try {
response = await fetch(`${this.getPhoenixControlPlanePathPrefix()}/containerconnections/multicontainer`, {
method: operation === "allocate" ? "POST" : "PATCH",
headers: PhoenixClient.getHeaders(),
body: JSON.stringify(provisionData),
});
const responseJson = await response?.json();
if (response.ok) {
const phoenixConnectionInfoResult = responseJson as IPhoenixConnectionInfoResult[];
if (
!phoenixConnectionInfoResult ||
phoenixConnectionInfoResult.length === 0 ||
!phoenixConnectionInfoResult[0]
) {
throw new Error("Received invalid phoenix connection response.");
}
return {
status: response.status,
data: phoenixConnectionInfoResult[0].phoenixServiceInfo,
};
}
const phoenixError = responseJson as IPhoenixError;
if (response.status === HttpStatusCodes.Forbidden) {
if (phoenixError.message === "Sequence contains no elements") {
throw Error("Phoenix container allocation failed, please try again later.");
}
throw new AbortError(this.ConvertToForbiddenErrorString(phoenixError));
}
throw new AbortError(phoenixError.message);
} catch (error) {
error.status = response?.status;
throw error;
}
}
public async initiateContainerHeartBeat(shouldUseNotebookStates: boolean, containerData: IContainerData) {
if (this.containerHealthHandler) {
clearTimeout(this.containerHealthHandler);
}
await this.getContainerHealth(shouldUseNotebookStates, Notebook.containerStatusHeartbeatDelayMs, containerData);
}
private scheduleContainerHeartbeat(
shouldUseNotebookStates: boolean,
delayMs: number,
containerData: IContainerData,
): void {
this.containerHealthHandler = setTimeout(async () => {
await this.getContainerHealth(shouldUseNotebookStates, delayMs, containerData);
}, delayMs);
}
private async getContainerStatusAsync(
shouldUseNotebookStates: boolean,
containerData: IContainerData,
): Promise<ContainerInfo> {
try {
const runContainerStatusAsync = async () => {
const response = await window.fetch(
`${this.getPhoenixControlPlanePathPrefix()}/${containerData.forwardingId}`,
{
method: "GET",
headers: PhoenixClient.getHeaders(),
},
);
if (response.status === HttpStatusCodes.OK) {
const containerStatus = await response.json();
return {
durationLeftInMinutes: containerStatus?.durationLeftInMinutes,
phoenixServerInfo: containerStatus?.phoenixServerInfo,
status: ContainerStatusType.Active,
};
} else if (response.status === HttpStatusCodes.NotFound) {
const error = "Disconnected from compute workspace";
Logger.logError(error, "");
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Reconnect,
};
TelemetryProcessor.traceMark(Action.PhoenixHeartBeat, {
dataExplorerArea: Areas.Notebook,
message: getErrorMessage(error),
});
shouldUseNotebookStates ? useNotebook.getState().resetContainerConnection(connectionStatus) : undefined;
shouldUseNotebookStates && useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed);
shouldUseNotebookStates &&
useDialog
.getState()
.showOkModalDialog(
"Disconnected",
"Disconnected from temporary workspace. Please click on connect button to connect to temporary workspace.",
);
throw new AbortError(response.statusText);
} else if (response?.status === HttpStatusCodes.Forbidden) {
const validationMessage = this.ConvertToForbiddenErrorString(await response.json());
if (validationMessage) {
useDialog.getState().showOkModalDialog("Connection Failed", `${validationMessage}`);
}
throw new AbortError(response.statusText);
}
throw new Error(response.statusText);
};
return await promiseRetry(runContainerStatusAsync, this.retryOptions);
} catch (error) {
TelemetryProcessor.traceFailure(Action.PhoenixHeartBeat, {
dataExplorerArea: Areas.Notebook,
});
Logger.logError(getErrorMessage(error), "");
const connectionStatus: ContainerConnectionInfo = {
status: ConnectionStatusType.Failed,
};
shouldUseNotebookStates ? useNotebook.getState().resetContainerConnection(connectionStatus) : undefined;
shouldUseNotebookStates && useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed);
return {
durationLeftInMinutes: undefined,
phoenixServerInfo: undefined,
status: ContainerStatusType.Disconnected,
};
}
}
private async getContainerHealth(shouldUseNotebookStates: boolean, delayMs: number, containerData: IContainerData) {
const containerInfo = await this.getContainerStatusAsync(shouldUseNotebookStates, containerData);
shouldUseNotebookStates ? useNotebook.getState().setContainerStatus(containerInfo) : undefined;
const containerStatus = shouldUseNotebookStates ? useNotebook.getState().containerStatus?.status : undefined;
if (containerStatus === ContainerStatusType.Active) {
this.scheduleContainerHeartbeat(shouldUseNotebookStates, delayMs, containerData);
}
}
private getPhoenixControlPlanePathPrefix(): string {
if (!this.armResourceId) {
throw new Error("The Phoenix client was not initialized properly: missing ARM resource id");
}
const toolsEndpoint =
userContext.features.phoenixEndpoint ?? userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT;
if (!validateEndpoint(toolsEndpoint, allowedJunoOrigins)) {
const error = `${toolsEndpoint} not allowed as tools endpoint`;
console.error(error);
throw new Error(error);
}
return `${toolsEndpoint}/api/controlplane/toolscontainer/cosmosaccounts${this.armResourceId}`;
}
private static getHeaders(): HeadersInit {
const authorizationHeader = getAuthorizationHeader();
return {
[authorizationHeader.header]: authorizationHeader.token,
[HttpHeaders.contentType]: "application/json",
};
}
private initializeCancelEventListener(): void {
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
document.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.ctrlKey && (event.key === "c" || event.key === "z")) {
this.abortController.abort(new AbortError("Request canceled"));
}
});
}
public ConvertToForbiddenErrorString(jsonData: IPhoenixError): string {
const errInfo = jsonData;
switch (errInfo?.type) {
case PhoenixErrorType.MaxAllocationTimeExceeded: {
const maxAllocationTimeExceeded = errInfo as IMaxAllocationTimeExceeded;
const allocateAfterTimestamp = new Date(maxAllocationTimeExceeded?.earliestAllocationTimestamp);
allocateAfterTimestamp.setDate(allocateAfterTimestamp.getDate() + 1);
return `${errInfo.message}` + ". Please try again after " + `${allocateAfterTimestamp.toLocaleString()}`;
}
case PhoenixErrorType.MaxDbAccountsPerUserExceeded:
case PhoenixErrorType.MaxUsersPerDbAccountExceeded:
case PhoenixErrorType.AllocationValidationResult:
case PhoenixErrorType.RegionNotServicable:
case PhoenixErrorType.UserMissingPermissionsError:
case PhoenixErrorType.SubscriptionNotAllowed: {
return `${errInfo.message}`;
}
default: {
return undefined;
}
}
}
}
@@ -1,11 +1,27 @@
jest.mock("../../../hooks/useDirectories");
jest.mock("../../../Common/PortalBackendClient");
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { updateUserContext, userContext } from "UserContext";
import React from "react";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { ConnectExplorer } from "./ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction<
typeof isAccountRestrictedForConnectionStringLogin
>;
// fetchEncryptedToken rejects with the raw Response.
const rejectWithResponse = (status: number, body: string) =>
mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response);
beforeEach(() => {
jest.resetAllMocks();
mockIsAccountRestricted.mockResolvedValue(false);
});
it("shows the connect form", () => {
const connectionString = "fakeConnectionString";
const login = jest.fn();
@@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures });
});
it("rejects an unrecognized connection string before token exchange", async () => {
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "not-a-valid-connection-string",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText(
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
),
).toBeInTheDocument();
expect(mockIsAccountRestricted).not.toHaveBeenCalled();
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("shows that a connection is in progress", async () => {
let finishRestrictionCheck: (restricted: boolean) => void = () => undefined;
mockIsAccountRestricted.mockImplementation(
() =>
new Promise((resolve) => {
finishRestrictionCheck = resolve;
}),
);
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=some-key;",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
const connectButton = screen.getByDisplayValue("Connecting...");
expect(connectButton).toBeDisabled();
expect(connectButton.closest("form")).toHaveAttribute("aria-busy", "true");
finishRestrictionCheck(false);
expect(await screen.findByDisplayValue("Connect")).toBeEnabled();
});
it("shows the error when the Portal Backend rejects the connection string", async () => {
// Mongo and Cassandra are the APIs that still exchange the connection string for an encrypted token.
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText(
"Couldn't authenticate with Cosmos DB: Request originated from IP 1.2.3.4 through public internet.",
),
).toBeInTheDocument();
});
it("shows a generic error when the Portal Backend fails without a message", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
// A failure with an empty body leaves nothing worth rendering.
rejectWithResponse(500, "");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText("Failed to connect to the account. Please check the connection string and try again."),
).toBeInTheDocument();
});
it("offers the firewall help link when the Portal Backend is blocked by the account firewall", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(await screen.findByText("Allow access from Azure Portal")).toBeInTheDocument();
});
it("does not offer the firewall help link for a failure the firewall did not cause", async () => {
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
rejectWithResponse(401, "The connection string is invalid.");
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
fireEvent.click(screen.getByText("Connect to your account with connection string"));
fireEvent.click(screen.getByDisplayValue("Connect"));
expect(
await screen.findByText("Couldn't authenticate with Cosmos DB: The connection string is invalid."),
).toBeInTheDocument();
expect(screen.queryByText("Allow access from Azure Portal")).toBeNull();
});
@@ -1,10 +1,11 @@
import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components";
import { useBoolean } from "@fluentui/react-hooks";
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { userContext } from "UserContext";
import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType";
import { HttpStatusCodes } from "../../../Common/Constants";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
@@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
return (
@@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
{isFormVisible && enableConnectionStringLogin ? (
<form
id="connectWithConnectionString"
aria-busy={isConnecting}
onSubmit={async (event) => {
event.preventDefault();
if (isConnecting) {
return;
}
setErrorMessage("");
setIsBlockedByFirewall(false);
setIsConnecting(true);
try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
return;
}
const metadata = parseConnectionString(connectionString);
if (!metadata) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
);
return;
}
} catch (error) {
setErrorMessage(getErrorMessage(error));
return;
}
if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
return;
}
try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
);
return;
}
} catch (error) {
setErrorMessage(getErrorMessage(error as Error));
return;
}
const metadata = parseConnectionString(connectionString);
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so
// we skip the Portal Backend proxy and use the metadata parsed from the connection string.
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
if (isDirectConnectionStringLoginApi(metadata.apiKind)) {
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
// Mongo and Cassandra go through the Portal Backend
try {
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
} catch (error) {
const errorDetails = await (error as Response).text();
setErrorMessage(
errorDetails
? `Couldn't authenticate with Cosmos DB: ${errorDetails}`
: "Failed to connect to the account. Please check the connection string and try again.",
);
// A Forbidden usually means the account firewall dropped the request. The connection
// string is exchanged by the Portal Backend rather than the browser, so the account has
// to allowlist those services.
setIsBlockedByFirewall((error as Response).status === HttpStatusCodes.Forbidden);
}
} finally {
setIsConnecting(false);
}
}}
>
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setConnectionString(event.target.value);
}}
/>
{errorMessage.length > 0 && (
<span className="errorDetailsInfoTooltip">
<img className="errorImg" src={ErrorImage} alt="Error notification" />
<span className="errorDetails">{errorMessage}</span>
</span>
)}
</p>
{errorMessage.length > 0 && (
<FluentProvider theme={webLightTheme} className="connectErrorMessageBar">
<MessageBar intent="error" layout="multiline">
<MessageBarBody>
<span className="errorDetails">{errorMessage}</span>
{isBlockedByFirewall && (
<Link
className="errorHelpLink"
href="https://learn.microsoft.com/azure/cosmos-db/how-to-configure-firewall#allow-requests-from-the-azure-portal"
target="_blank"
rel="noreferrer"
>
Allow access from Azure Portal
</Link>
)}
</MessageBarBody>
</MessageBar>
</FluentProvider>
)}
<p className="connectExplorerContent">
<input className="filterbtnstyle" type="submit" value="Connect" />
<input
className="filterbtnstyle"
type="submit"
value={isConnecting ? "Connecting..." : "Connect"}
disabled={isConnecting}
/>
</p>
<p className="switchConnectTypeText" onClick={login}>
Sign In with Azure Account
+11 -33
View File
@@ -55,41 +55,19 @@
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
font-style: italic;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip {
position: relative;
display: inline-block;
padding-left: 4px;
vertical-align: top;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar {
/* Matches the width of the connection string input so the form stays balanced. */
width: 308px;
margin: 0px auto 8px auto;
text-align: left;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails {
visibility: visible;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails {
/* Service messages are diagnostics containing unbroken URIs and ids. */
overflow-wrap: anywhere;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
bottom: 24px;
width: 165px;
visibility: hidden;
background-color: #393939;
color: #ffffff;
position: absolute;
z-index: 1;
left: -10px;
padding: 6px;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
border-width: 10px 10px 0px 10px;
bottom: -8px;
content: "";
position: absolute;
right: 100%;
border-style: solid;
left: 12px;
width: 0;
height: 0;
border-color: #3b3b3b transparent;
}
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
height: 14px;
width: 14px;
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink {
display: block;
margin-top: 8px;
}
.filterbtnstyle {
@@ -1,19 +1,75 @@
import { configContext, updateConfigContext } from "../../../ConfigContext";
import * as DataModels from "../../../Contracts/DataModels";
import { parseConnectionString } from "./ConnectionStringParser";
import {
buildEndpointsRegex,
dnsZoneAlternation,
parseConnectionString,
selectEndpointZone,
} from "./ConnectionStringParser";
describe("ConnectionStringParser", () => {
const mockAccountName = "Test";
const mockMasterKey = "some-key";
it("should parse a valid sql account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
);
// The shape of each api's connection string, parameterized by the dns zone the account sits in. What
// these tests are about is the zones, so keeping the shapes here stops them being restated once per zone.
const buildConnectionString = {
sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`,
mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`,
// A cassandra connection string can name the account host under either AccountEndpoint or HostName.
cassandra: (zone: string, hostKey = "AccountEndpoint") =>
`${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`,
table: (zone: string) =>
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`,
graph: (zone: string) =>
`AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
};
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
const connectionStringsByApiKind: Record<DataModels.ApiKind, string> = {
[DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"),
[DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"),
[DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"),
[DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"),
[DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"),
[DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"),
};
it("should parse a connection string for every api kind", () => {
Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => {
const metadata = parseConnectionString(connectionString);
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(Number(apiKind));
});
});
// The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the
// suite rather than fail it. Pin the expected contents so that stays visible in review.
it("should support the expected dns zones", () => {
expect(configContext.SQL_DNS_ZONES).toEqual([
"documents.azure.com",
"sql.cosmosdb.azure.com",
"sql.cosmos.azure.com",
"sqlx.cosmos.azure.com",
"documents-staging.windows-ppe.net",
"sql.cosmosdb.windows-ppe.net",
"sql.cosmos.windows-ppe.net",
"sqlx.cosmos.windows-ppe.net",
]);
expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]);
expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]);
expect(configContext.CASSANDRA_DNS_ZONES).toEqual([
"cassandra.cosmosdb.azure.com",
"cassandra.cosmos.azure.com",
"cassandra.cosmosdb.windows-ppe.net",
"cassandra.cosmos.windows-ppe.net",
]);
expect(configContext.TABLE_DNS_ZONES).toEqual([
"table.cosmosdb.azure.com",
"table.cosmos.azure.com",
"table.cosmosdb.windows-ppe.net",
"table.cosmos.windows-ppe.net",
]);
});
it("should keep the document endpoint given by the connection string", () => {
@@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => {
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
});
it("should parse a valid mongo account connection string", () => {
const metadata = parseConnectionString(
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
);
it.each(configContext.SQL_DNS_ZONES)(
"should parse a sql account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
});
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
},
);
it("should parse a valid compute mongo account connection string", () => {
const metadata = parseConnectionString(
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
);
it.each(configContext.MONGO_DNS_ZONES)(
"should parse a mongo account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
});
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
},
);
it.each(configContext.MONGO_COMPUTE_DNS_ZONES)(
"should parse a compute mongo account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
},
);
it.each(
["AccountEndpoint", "HostName"].flatMap((hostKey) =>
configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]),
),
)(
"should parse a cassandra account connection string using %s and the %s zone",
(hostKey: string, dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
},
);
it.each(configContext.TABLE_DNS_ZONES)(
"should parse a table account connection string using the %s zone",
(dnsZone: string) => {
const metadata = parseConnectionString(buildConnectionString.table(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
expect(metadata.apiEndpoint).toBeUndefined();
},
);
// A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than
// giving the document endpoint, so the document endpoint that data plane operations go through is built
// from the account name, under a zone of the same kind as the one that matched.
const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`;
const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`;
const expectedDocumentEndpointByZone: Record<string, string> = {
"documents.azure.com": publicDocumentEndpoint,
"documents-staging.windows-ppe.net": ppeDocumentEndpoint,
"mongo.cosmos.azure.com": publicDocumentEndpoint,
"mongo.cosmos.windows-ppe.net": ppeDocumentEndpoint,
"cassandra.cosmosdb.azure.com": publicDocumentEndpoint,
"cassandra.cosmos.azure.com": publicDocumentEndpoint,
"cassandra.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
"cassandra.cosmos.windows-ppe.net": ppeDocumentEndpoint,
"table.cosmosdb.azure.com": publicDocumentEndpoint,
"table.cosmos.azure.com": publicDocumentEndpoint,
"table.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
"table.cosmos.windows-ppe.net": ppeDocumentEndpoint,
};
it.each([
...configContext.MONGO_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
...configContext.MONGO_COMPUTE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
...configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.cassandra(dnsZone)]),
...configContext.TABLE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.table(dnsZone)]),
])(
"should construct the document endpoint for an account in the %s zone",
(dnsZone: string, connectionString: string) => {
const metadata = parseConnectionString(connectionString);
expect(metadata.documentEndpoint).toBe(expectedDocumentEndpointByZone[dnsZone]);
},
);
it("should parse a valid graph account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
);
const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com"));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
@@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
});
it("should parse a valid table account connection string", () => {
const metadata = parseConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
);
it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
// Table data operations go through the document endpoint, which is constructed from the account name.
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
// The constructed endpoint has to match the kind of zone the document endpoint we matched came from.
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`);
});
it("should parse a valid table account connection string using the cosmos.azure.com zone", () => {
const metadata = parseConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`,
);
it("should reject a connection string when no DNS zone matches the account", () => {
const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES;
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
try {
const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net"));
// The account key travels to the constructed document endpoint, so a config carrying no PPE zone
// has to fail on a PPE account rather than fall back to a zone the account does not own.
expect(metadata).toBe(undefined);
} finally {
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones });
}
});
it("should parse a valid cassandra account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
);
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
it.each([
buildConnectionString.sql("documents.azure.com.attacker.example"),
buildConnectionString.mongo("documents.azure.com.attacker.example"),
buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"),
buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"),
buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"),
])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => {
// The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that
// appends to an allowed zone must not pass as that zone.
expect(parseConnectionString(connectionString)).toBe(undefined);
});
it("should fail to parse an invalid connection string", () => {
@@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => {
expect(metadata).toBe(undefined);
});
describe("dnsZoneAlternation", () => {
it("should escape the dots in a zone", () => {
expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)");
});
it("should join multiple zones into a single alternation", () => {
expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)");
});
it("should not let the dots match arbitrary characters", () => {
// An unescaped dot would make the zone list match hosts that only resemble a real zone.
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
expect(regex.test("documents.azure.com")).toBe(true);
expect(regex.test("documentsXazure.com")).toBe(false);
});
it("should capture the zone that matched", () => {
const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"]));
expect("account.b.test".match(regex)[1]).toBe("b.test");
});
it("should require the zone to run to the end of the host", () => {
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
expect(regex.test("account.documents.azure.com")).toBe(true);
expect(regex.test("account.documents.azure.com:443/")).toBe(true);
expect(regex.test("account.documents.azure.com/")).toBe(true);
// Without this the zone list stops being an allowlist, since anything can be appended to a zone.
expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false);
});
});
describe("buildEndpointsRegex", () => {
it("should build a pattern for every api matched by dns zone", () => {
expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]);
});
it("should build a cassandra pattern for each supported key", () => {
const { cassandra } = buildEndpointsRegex();
expect(cassandra).toHaveLength(2);
expect(cassandra[0]).toContain("AccountEndpoint=");
expect(cassandra[1]).toContain("HostName=");
});
it("should build each pattern from its own zone list", () => {
// The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in
// review and would let an account of one api be parsed as another.
const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex();
expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES));
expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES));
expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES));
cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)));
expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES));
});
});
describe("selectEndpointZone", () => {
const nonPpeZone = "documents.azure.com";
const ppeZone = "documents-staging.windows-ppe.net";
it("should pick the ppe zone for a ppe account", () => {
expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone);
});
it("should pick the non ppe zone for a non ppe account", () => {
expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone);
});
it("should not depend on the order of the zones", () => {
expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone);
expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone);
});
it("should return undefined when no zone matches the kind of account", () => {
// Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone.
expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined();
expect(selectEndpointZone([ppeZone], false)).toBeUndefined();
});
it("should return undefined for an empty zone list", () => {
expect(selectEndpointZone([], false)).toBeUndefined();
});
});
});
@@ -1,44 +1,80 @@
import * as Constants from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's
// accessinputmetadata API constructs from the account name when the connection string does not already
// contain the endpoint.
const DocumentEndpointZone = "documents.azure.com";
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
const PpeDnsSuffix = "windows-ppe.net";
const DnsPort = "443";
// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone.
const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`);
// Picks the DNS zone matching the kind of account the connection string came from, since a PPE
// account's endpoints sit under PPE zones and every other account's do not.
export const selectEndpointZone = (zones: ReadonlyArray<string>, isPpeAccount: boolean): string | undefined =>
zones.find((zone) => isPpeZone(zone) === isPpeAccount);
// Builds an alternation matching any of the given DNS zones, e.g. "(documents\.azure\.com|sql\.cosmos\.azure\.com)".
// The group captures so callers can tell which zone matched, and with it whether the account is a PPE account.
// The zone has to run to the end of the host, otherwise a host that merely starts with an allowed zone
// would pass as that zone and the account key would travel to whatever was appended to it. Zones come
// from config, so every regex metacharacter is escaped rather than just the dots.
export const dnsZoneAlternation = (zones: ReadonlyArray<string>): string =>
`(${zones.map((zone) => zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?=[:/\\s]|$)`;
// The zone lists live in ConfigContext, which is populated asynchronously by initializeConfiguration,
// so these are built per call rather than once at module load.
export const buildEndpointsRegex = () => ({
sql: `AccountEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.SQL_DNS_ZONES)}`,
mongo: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_DNS_ZONES)}`,
mongoCompute: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)}`,
cassandra: ["AccountEndpoint", "HostName"].map(
(key) => `${key}=([^.]+)\\.${dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)}`,
),
table: `TableEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.TABLE_DNS_ZONES)}`,
});
export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) {
try {
const accessInput = {} as AccessInputMetadata;
const connectionStringParts = connectionString.split(";");
const endpointsRegex = buildEndpointsRegex();
// Endpoints we build from the account name have to match the kind of zone the connection string
// actually matched, since PPE accounts and other accounts do not share zones.
let isPpeAccount = false;
connectionStringParts.forEach((connectionStringPart: string) => {
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
if (RegExp(endpointsRegex.sql).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.sql);
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.SQL;
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
// given instead of rebuilding it from the account name.
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
isPpeAccount = isPpeZone(matches[2]);
} else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDB;
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
isPpeAccount = isPpeZone(matches[3]);
} else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute);
accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDBCompute;
} else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
Constants.EndpointsRegex.cassandra.forEach((regex) => {
if (RegExp(regex).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(regex)[1];
isPpeAccount = isPpeZone(matches[3]);
} else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
endpointsRegex.cassandra.forEach((regex) => {
const matches: string[] = connectionStringPart.match(regex);
if (matches) {
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Cassandra;
isPpeAccount = isPpeZone(matches[2]);
}
});
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
} else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.table);
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Table;
isPpeAccount = isPpeZone(matches[2]);
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
accessInput.apiKind = ApiKind.Graph;
}
@@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined;
}
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
// operations go through has to be derived from the account name. Gremlin accounts additionally
// need the Gremlin endpoint, which is never part of the connection string.
// A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather
// than giving the document endpoint, so the document endpoint that data plane operations go through
// has to be built from the account name. SQL and Gremlin strings carry it and take it as given.
// Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string.
if (accessInput.accountName) {
if (accessInput.apiKind === ApiKind.Table) {
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
if (
accessInput.apiKind === ApiKind.Table ||
accessInput.apiKind === ApiKind.MongoDB ||
accessInput.apiKind === ApiKind.MongoDBCompute ||
accessInput.apiKind === ApiKind.Cassandra
) {
const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount);
if (!documentEndpointZone) {
return undefined;
}
accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`;
} else if (accessInput.apiKind === ApiKind.Graph) {
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount);
if (!gremlinEndpointZone) {
return undefined;
}
accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`;
}
}
-87
View File
@@ -1,87 +0,0 @@
import { getErrorMessage } from "../Common/ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
import * as DataModels from "../Contracts/DataModels";
interface KernelConnectionMetadata {
name: string;
configurationEndpoints: DataModels.NotebookConfigurationEndpoints;
notebookConnectionInfo: DataModels.NotebookWorkspaceConnectionInfo;
}
export const _configureServiceEndpoints = async (kernelMetadata: KernelConnectionMetadata): Promise<void> => {
if (!kernelMetadata) {
// should never get into this state
Logger.logWarning("kernel metadata is null or undefined", "NotebookConfigurationUtils/configureServiceEndpoints");
return;
}
const notebookConnectionInfo = kernelMetadata.notebookConnectionInfo;
const configurationEndpoints = kernelMetadata.configurationEndpoints;
if (notebookConnectionInfo && configurationEndpoints) {
try {
const headers: HeadersInit = { "Content-Type": "application/json" };
if (notebookConnectionInfo.authToken) {
headers["Authorization"] = `token ${notebookConnectionInfo.authToken}`;
}
const response = await fetch(`${notebookConnectionInfo.notebookServerEndpoint}/api/configureEndpoints`, {
method: "POST",
headers,
body: JSON.stringify(configurationEndpoints),
});
if (!response.ok) {
const responseMessage = await response.json();
Logger.logError(
getErrorMessage(responseMessage),
"NotebookConfigurationUtils/configureServiceEndpoints",
response.status,
);
}
} catch (error) {
Logger.logError(getErrorMessage(error), "NotebookConfigurationUtils/configureServiceEndpoints");
}
}
};
export const configureServiceEndpoints = async (
notebookPath: string,
notebookConnectionInfo: DataModels.NotebookWorkspaceConnectionInfo,
kernelName: string,
clusterConnectionInfo: DataModels.SparkClusterConnectionInfo,
): Promise<void> => {
if (!notebookPath || !notebookConnectionInfo || !kernelName) {
Logger.logError(
"Invalid or missing notebook connection info/path",
"NotebookConfigurationUtils/configureServiceEndpoints",
);
return Promise.reject("Invalid or missing notebook connection info");
}
if (!clusterConnectionInfo || !clusterConnectionInfo.endpoints || clusterConnectionInfo.endpoints.length === 0) {
Logger.logError(
"Invalid or missing cluster connection info/endpoints",
"NotebookConfigurationUtils/configureServiceEndpoints",
);
return Promise.reject("Invalid or missing cluster connection info");
}
const notebookEndpointInfo: DataModels.NotebookConfigurationEndpointInfo[] = clusterConnectionInfo.endpoints.map(
(clusterEndpoint) => ({
type: clusterEndpoint.kind.toLowerCase(),
endpoint: clusterEndpoint && clusterEndpoint.endpoint,
username: clusterConnectionInfo.userName,
password: clusterConnectionInfo.password,
token: "", // TODO. This was arcadiaToken() when our synapse/spark integration comes back
}),
);
const configurationEndpoints: DataModels.NotebookConfigurationEndpoints = {
path: notebookPath,
endpoints: notebookEndpointInfo,
};
const kernelMetadata: KernelConnectionMetadata = {
configurationEndpoints,
notebookConnectionInfo,
name: kernelName,
};
return await _configureServiceEndpoints(kernelMetadata);
};
-15
View File
@@ -83,12 +83,6 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
useEffect(() => {
const effect = async () => {
if (platform) {
//Updating phoenix feature flags for MPAC based of config context
if (configContext.isPhoenixEnabled === true) {
userContext.features.phoenixNotebooks = true;
userContext.features.phoenixFeatures = true;
}
let explorer: Explorer;
try {
if (platform === Platform.Hosted) {
@@ -1010,15 +1004,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) {
userContext.features.partitionKeyDefault2 = true;
}
if (inputs.flights.indexOf(Flights.PhoenixNotebooks) !== -1) {
userContext.features.phoenixNotebooks = true;
}
if (inputs.flights.indexOf(Flights.PhoenixFeatures) !== -1) {
userContext.features.phoenixFeatures = true;
}
if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) {
userContext.features.notebooksDownBanner = true;
}
}
// Handle initial theme from portal
-15
View File
@@ -1,15 +0,0 @@
import create, { UseStore } from "zustand";
export interface NotebookSnapshotHooks {
snapshot?: string;
error?: string;
setSnapshot: (imageSrc: string) => void;
setError: (error: string) => void;
}
export const useNotebookSnapshotStore: UseStore<NotebookSnapshotHooks> = create((set) => ({
snapshot: undefined,
error: undefined,
setSnapshot: (imageSrc: string) => set((state) => ({ ...state, snapshot: imageSrc })),
setError: (error: string) => set((state) => ({ ...state, error })),
}));
-29
View File
@@ -9,7 +9,6 @@ import {
} from "Shared/AppStatePersistenceUtility";
import create, { UseStore } from "zustand";
import * as ViewModels from "../Contracts/ViewModels";
import { CollectionTabKind } from "../Contracts/ViewModels";
import TabsBase from "../Explorer/Tabs/TabsBase";
export interface TabsState {
@@ -27,7 +26,6 @@ export interface TabsState {
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void;
closeTabsByComparator: (comparator: (tab: TabsBase) => boolean) => void;
closeTab: (tab: TabsBase) => void;
closeAllNotebookTabs: (hardClose: boolean) => void;
openAndActivateReactTab: (tabKind: ReactTabKind) => void;
closeReactTab: (tabKind: ReactTabKind) => void;
setIsTabExecuting: (state: boolean) => void;
@@ -132,33 +130,6 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
get().persistTabsState();
},
closeAllNotebookTabs: (): void => {
const isNotebook = (tabKind: CollectionTabKind): boolean => {
if (
tabKind === CollectionTabKind.Notebook ||
tabKind === CollectionTabKind.NotebookV2 ||
tabKind === CollectionTabKind.SchemaAnalyzer ||
tabKind === CollectionTabKind.Terminal
) {
return true;
}
return false;
};
const tabList = get().openedTabs;
if (tabList && tabList.length > 0) {
tabList.forEach((tab: TabsBase) => {
const tabKind: CollectionTabKind = tab.tabKind;
if (tabKind && isNotebook(tabKind)) {
tab.onCloseTabButtonClick();
}
});
if (get().openedTabs.length === 0 && !isFabricMirrored()) {
set({ activeTab: undefined, activeReactTab: undefined });
}
}
},
openAndActivateReactTab: (tabKind: ReactTabKind) => {
if (get().openedReactTabs.indexOf(tabKind) === -1) {
set((state) => ({
+3
View File
@@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow";
import { TextDecoder, TextEncoder } from "util";
import i18n from "./i18n";
import enResources from "./Localization/en/Resources.json";
jest.mock("web-vitals");
configure({ adapter: new Adapter() });
initializeIcons();