From 086c17ecff83d013e49adf2a90e04f02ad927373 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:23:18 -0700
Subject: [PATCH 1/9] Bump ws from 8.20.1 to 8.21.3 (#2562)
Bumps [ws](https://github.com/websockets/ws) from 8.20.1 to 8.21.3.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.3)
---
updated-dependencies:
- dependency-name: ws
dependency-version: 8.21.3
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 9 ++++-----
package.json | 4 ++--
2 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 6d61697c4..712d2cb8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -102,7 +102,6 @@
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
- "ws": "8.20.1",
"xterm": "4.19.0",
"xterm-addon-fit": "0.5.0",
"zustand": "3.5.0"
@@ -196,7 +195,7 @@
"webpack-bundle-analyzer": "5.2.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.5",
- "ws": "8.20.1"
+ "ws": "8.21.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -27689,9 +27688,9 @@
}
},
"node_modules/ws": {
- "version": "8.20.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
- "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 1ee5cecf0..a58c864bd 100644
--- a/package.json
+++ b/package.json
@@ -97,7 +97,7 @@
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
- "ws": "8.20.1",
+ "ws": "8.21.3",
"xterm": "4.19.0",
"xterm-addon-fit": "0.5.0",
"zustand": "3.5.0"
@@ -206,7 +206,7 @@
"webpack-bundle-analyzer": "5.2.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.5",
- "ws": "8.20.1"
+ "ws": "8.21.3"
},
"scripts": {
"postinstall": "patch-package && npm run generate:i18n-keys",
From 4d9921180deb2f8a8033e4938d0687bebb4e4256 Mon Sep 17 00:00:00 2001
From: asier-isayas
Date: Thu, 20 Aug 2026 07:59:52 -0700
Subject: [PATCH 2/9] Add client-side connection-string login for SQL, Tables,
and Gremlin (#2559)
* Add client-side connection-string login for SQL, Tables, and Gremlin
SQL, Tables, and Gremlin now sign data-plane requests client-side with the account key and skip the Portal Backend proxy (generatetoken/accessinputmetadata/authorizationtokens). Adds client-side host/account validation mirroring the backend ValidateHostAndAccount, plus a real CosmosClient connectivity probe that gates opening the Data Explorer. Mongo and Cassandra continue to use the encrypted-token proxy path.
* Localize connection-string login validation and connectivity messages
Move the hardcoded SQL/Tables/Gremlin connection-string login strings into en/Resources.json and reference them via the type-safe Keys object (t(Keys.connectExplorer.errors.*)).
* Drop unused mongodb:// branch from endpoint host extraction
* Remove Data Explorer references from connectivity probe comment
* Reword proxy reference in ConnectionString comment
* Fix trailing whitespace in connectivity probe comment
* Send connection string in Authorization header for Mongo/Cassandra token request
* Add E2E connection string login tests for SQL, Gremlin, and Tables
* Add wrong account key test for SQL connection string login
* Add access token to authorization header for encrypted token flow
* Wire connection string login E2E tests to dedicated connstring accounts in CI
Add TestAuthType and fold connection-string account resolution into getAccountName; seed and target the dedicated *-connstring accounts in CI while falling back to the standard per-API account locally.
* Handle connection string account types in getTestExplorerUrl switch
* Move globalThis.crypto polyfill to fx.ts so it runs for all specs
* Remove connection string validation for connection string login
- Remove validateDirectConnectionStringLogin and its helpers (extractEndpointHostFromConnectionString, extractHostToken, directLoginAllowlistedEndpointZones)
- Remove old extractMasterKeyfromConnectionString (Gremlin-specific), rename extractAccountKeyFromConnectionString to extractMasterKeyfromConnectionString
- Change validateDirectConnectionStringConnectivity to throw on error instead of returning string|undefined
- Simplify direct-login flow: submit connection string to CosmosClient as-is, no format or endpoint validation
- Keep connectivity pre-check (throws if CosmosClient cannot reach account)
- Remove 6 unused localization keys for validation errors
- Update tests to match new behavior
* Send authorization header for connection string login backend calls
* Simplify connection string login error handling
- Lift the login error state into HostedExplorer so failures from the connect form and from a postMessage login share one source of truth
- Show the message returned by the service instead of falling back to a generic unreachable message
- Move isAuthorizationError into AuthorizationUtils alongside the other shared auth helpers
- Widen getErrorMessage to accept unknown so catch variables no longer need a cast
- Show the connect screen error tooltip beside the icon and widen it so long service messages fit
- Consolidate the repeated account lookup and login steps in the SQL connection string spec
* Consolidate hosted login account metadata into a single state
Encrypted-token and direct connection-string logins now write to one accountMetadata state instead of two, which also fixes the connect form staying mounted after a successful SQL/Tables/Gremlin login. Deletes the now-unused useTokenMetadata hook and moves fetchAccessData to Platform/Hosted/Helpers/PortalAccessData.ts.
* Accept connection string logins without validating them
SQL, Tables, and Gremlin logins probed the account before signing in, so a user whose key was wrong or whose account was unreachable was blocked at the connect form. The probe is now gone: the connection string is accepted as-is and any bad key surfaces on the data-plane requests made from inside the explorer.
Removes validateDirectConnectionStringConnectivity, isAuthorizationError, and the connectExplorer.errors.connectFailed string along with their tests.
Also takes documentEndpoint straight from the AccountEndpoint in SQL and Gremlin connection strings instead of rebuilding it from the account name and a hardcoded DNS zone. Tables still derives it, since a Tables connection string only carries the table endpoint.
* Keep the connect form login error local to ConnectExplorer
The error state was lifted into HostedExplorer so a postMessage login could report failures, which meant the connect screen needed a second copy of the error markup for the AAD view. That copy fell outside the connectExplorerContent selector the tooltip styles are scoped to, so it rendered the message as unstyled inline text.
Move the state back into ConnectExplorer and let connectWithConnectionString log the failure as it did before. A failed postMessage login still leaves the user on the connect screen either way.
* Move Portal Backend calls into a PortalBackendClient helper
fetchEncryptedToken and isAccountRestrictedForConnectionStringLogin lived in ConnectExplorer.tsx, so HostedExplorer imported a network call from a React component. Merge them with fetchAccessData into src/Platform/Hosted/Helpers/PortalBackendClient.ts along with the PortalBackendError type, leaving ConnectExplorer as UI only.
* Leave the encrypted token login path as it is on master
The direct connection string login work does not change how Mongo and Cassandra logins fail, so drop the 401/403 handling this branch added around fetchEncryptedToken along with the now unused isAuthorizationError helper.
* Move the Portal Backend connection string calls out of Helpers
Helpers holds pure string utilities, and every other *Client in the repo sits at its domain root, so name the module for the endpoints it wraps and place it beside HostedUtils. Also drop the branch's ConnectScreen.less tooltip restyle, which was unrelated to connection string login.
* Move the Portal Backend client to Common
The module is a plain service client with no dependency on the hosted platform, and CosmosClient and MetricEvents already hand roll their own Portal Backend calls, so Common is where a shared client belongs.
* Add an E2E test that SQL connection string login skips the Portal Backend
Nothing asserted the defining behavior of the direct login path, so reverting the short-circuit in connectWithConnectionString would have gone unnoticed. The listener filters on the connectionstring route so it covers both generatetoken and accessinputmetadata, and leaves the account restriction check alone since that still runs for every API.
* Refer to the Table API as Table in comments
The comments added by this branch alternated between Table and Tables when naming the API alongside SQL and Gremlin. TablesDB is left alone since that is the literal database name, as is the plural noun where it refers to actual tables.
---------
Co-authored-by: Asier Isayas
---
src/Common/Constants.ts | 2 +-
src/Common/CosmosClient.ts | 1 +
src/Common/MongoProxyClient.ts | 5 +-
src/Common/PortalBackendClient.ts | 45 +++++++
src/Explorer/Tables/TableDataClient.ts | 4 +
src/HostedExplorer.test.tsx | 14 +--
src/HostedExplorer.tsx | 87 ++++++++-----
src/HostedExplorerChildFrame.ts | 8 +-
src/Localization/en/Resources.json | 2 +-
.../Components/ConnectExplorer.test.tsx | 28 ++++-
.../Hosted/Components/ConnectExplorer.tsx | 56 ++++-----
.../Helpers/ConnectionStringParser.test.ts | 28 +++++
.../Hosted/Helpers/ConnectionStringParser.ts | 21 ++++
src/Platform/Hosted/HostedUtils.test.ts | 63 +++++++++-
src/Platform/Hosted/HostedUtils.ts | 12 +-
src/hooks/useKnockoutExplorer.ts | 25 ++--
src/hooks/usePortalAccessToken.tsx | 30 -----
test/fx.ts | 57 ++++++++-
test/gremlin/connectionStringLogin.spec.ts | 84 +++++++++++++
test/sql/connectionStringLogin.spec.ts | 118 ++++++++++++++++++
test/tables/connectionStringLogin.spec.ts | 82 ++++++++++++
test/testData.ts | 7 --
tsconfig.strict.json | 4 +-
23 files changed, 655 insertions(+), 128 deletions(-)
create mode 100644 src/Common/PortalBackendClient.ts
delete mode 100644 src/hooks/usePortalAccessToken.tsx
create mode 100644 test/gremlin/connectionStringLogin.spec.ts
create mode 100644 test/sql/connectionStringLogin.spec.ts
create mode 100644 test/tables/connectionStringLogin.spec.ts
diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts
index eb1cb44c8..c4d0e1cd5 100644
--- a/src/Common/Constants.ts
+++ b/src/Common/Constants.ts
@@ -6,7 +6,7 @@ export class EndpointsRegex {
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.cosmosdb.azure.com";
+ public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com";
}
export class ApiEndpoints {
diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts
index 54444d09e..1bff3f80b 100644
--- a/src/Common/CosmosClient.ts
+++ b/src/Common/CosmosClient.ts
@@ -143,6 +143,7 @@ export async function getTokenFromAuthService(
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": userContext.accessToken,
+ authorization: userContext.accessToken,
},
body: JSON.stringify({
verb,
diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts
index b4287e0ee..2e13b434e 100644
--- a/src/Common/MongoProxyClient.ts
+++ b/src/Common/MongoProxyClient.ts
@@ -22,7 +22,10 @@ const defaultHeaders = {
function authHeaders() {
if (userContext.authType === AuthType.EncryptedToken) {
- return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
+ return {
+ [HttpHeaders.guestAccessToken]: userContext.accessToken,
+ [HttpHeaders.authorization]: userContext.accessToken,
+ };
} else {
const headers: { [key: string]: string } = {
[HttpHeaders.authorization]: userContext.authorizationToken,
diff --git a/src/Common/PortalBackendClient.ts b/src/Common/PortalBackendClient.ts
new file mode 100644
index 000000000..e2e13afa3
--- /dev/null
+++ b/src/Common/PortalBackendClient.ts
@@ -0,0 +1,45 @@
+import { configContext } from "../ConfigContext";
+import { AccessInputMetadata } from "../Contracts/DataModels";
+import { HttpHeaders } from "./Constants";
+
+export async function fetchAccessData(portalToken: string): Promise {
+ const headers = new Headers();
+ // Portal encrypted token API quirk: The token header must be URL encoded
+ headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
+ headers.append(HttpHeaders.authorization, encodeURIComponent(portalToken));
+ const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
+ const options = {
+ method: "GET",
+ headers: headers,
+ };
+
+ return fetch(url, options)
+ .then((response) => response.json())
+ .catch((error) => console.error(error));
+}
+
+export async function fetchEncryptedToken(connectionString: string): Promise {
+ const headers = new Headers();
+ headers.append(HttpHeaders.connectionString, connectionString);
+ headers.append(HttpHeaders.authorization, connectionString);
+ const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
+ const response = await fetch(url, { headers, method: "POST" });
+ if (!response.ok) {
+ throw response;
+ }
+
+ const encryptedTokenResponse: string = await response.json();
+ return decodeURIComponent(encryptedTokenResponse);
+}
+
+export async function isAccountRestrictedForConnectionStringLogin(connectionString: string): Promise {
+ const headers = new Headers();
+ headers.append(HttpHeaders.connectionString, connectionString);
+ const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
+ const response = await fetch(url, { headers, method: "POST" });
+ if (!response.ok) {
+ throw response;
+ }
+
+ return (await response.text()).toLowerCase() === "true";
+}
diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts
index ed9992af5..a88119adc 100644
--- a/src/Explorer/Tables/TableDataClient.ts
+++ b/src/Explorer/Tables/TableDataClient.ts
@@ -552,6 +552,10 @@ export class CassandraAPIDataClient extends TableDataClient {
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
+ if (userContext.authType === AuthType.EncryptedToken) {
+ xhr.setRequestHeader(Constants.HttpHeaders.authorization, userContext.accessToken);
+ }
+
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
}
diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx
index 22629c078..dda571e4f 100644
--- a/src/HostedExplorer.test.tsx
+++ b/src/HostedExplorer.test.tsx
@@ -1,6 +1,6 @@
jest.mock("./hooks/useAADAuth");
jest.mock("./hooks/useConfig");
-jest.mock("./hooks/usePortalAccessToken");
+jest.mock("./Common/PortalBackendClient");
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
jest.mock("./Shared/appInsights");
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
@@ -25,11 +25,11 @@ jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({
import "@testing-library/jest-dom";
import { act, render } from "@testing-library/react";
import React from "react";
+import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
-import { useTokenMetadata } from "./hooks/usePortalAccessToken";
import { App } from "./HostedExplorer";
-import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
+import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction;
@@ -54,7 +54,7 @@ beforeEach(() => {
jest.clearAllMocks();
(useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth);
(useConfig as jest.Mock).mockReturnValue({});
- (useTokenMetadata as jest.Mock).mockReturnValue(undefined);
+ (fetchAccessData as jest.Mock).mockResolvedValue(undefined);
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
});
@@ -81,7 +81,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
- expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr);
+ expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("accepts a valid Mongo connection string from an allowed origin", async () => {
@@ -129,7 +129,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
- expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr);
+ expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
@@ -145,7 +145,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
- expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr);
+ expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("rejects messages from a disallowed origin", async () => {
diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx
index 41863fb7d..ca28376d6 100644
--- a/src/HostedExplorer.tsx
+++ b/src/HostedExplorer.tsx
@@ -1,5 +1,6 @@
import { initializeIcons } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
+import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
import * as React from "react";
import { render } from "react-dom";
@@ -7,11 +8,12 @@ import ChevronRight from "../images/chevron-right.svg";
import "../less/hostedexplorer.less";
import { AuthType } from "./AuthType";
import { logError } from "./Common/Logger";
-import { DatabaseAccount } from "./Contracts/DataModels";
+import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
+import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
-import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
+import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { MeControl } from "./Platform/Hosted/Components/MeControl";
@@ -19,12 +21,14 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import "./Platform/Hosted/ConnectScreen.less";
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
-import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
+import {
+ extractMasterKeyFromDirectLoginConnectionString,
+ isDirectConnectionStringLoginApi,
+} from "./Platform/Hosted/HostedUtils";
import "./Shared/appInsights";
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
-import { useTokenMetadata } from "./hooks/usePortalAccessToken";
initializeIcons();
@@ -32,7 +36,15 @@ const App: React.FunctionComponent = () => {
// For handling encrypted portal tokens sent via query paramter
const params = new URLSearchParams(window.location.search);
const [encryptedToken, setEncryptedToken] = React.useState(params && params.get("key"));
- const encryptedTokenMetadata = useTokenMetadata(encryptedToken);
+ // Encrypted token logins resolve the account metadata through the Portal Backend, while SQL/Table/Gremlin
+ // connection-string logins derive it client-side, so both paths write to the same value.
+ const [accountMetadata, setAccountMetadata] = React.useState();
+
+ React.useEffect(() => {
+ if (encryptedToken) {
+ fetchAccessData(encryptedToken).then(setAccountMetadata);
+ }
+ }, [encryptedToken]);
// For showing/hiding panel
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
@@ -54,19 +66,29 @@ const App: React.FunctionComponent = () => {
setConnectionString(connStr);
if (isResourceTokenConnectionString(connStr)) {
setAuthType(AuthType.ResourceToken);
- } else {
- fetchEncryptedToken(connStr)
- .then((token) => {
- setEncryptedToken(token);
- setAuthType(AuthType.ConnectionString);
- })
- .catch((error) => {
- logError(
- `Failed to connect with connection string: ${error}`,
- "HostedExplorer/connectWithConnectionString",
- );
- });
+ return;
}
+
+ const metadata = parseConnectionString(connStr);
+ 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 derived from the connection string directly.
+ setAccountMetadata(metadata);
+ setAuthType(AuthType.ConnectionString);
+ return;
+ }
+
+ fetchEncryptedToken(connStr)
+ .then((token) => {
+ setEncryptedToken(token);
+ setAuthType(AuthType.ConnectionString);
+ })
+ .catch((error) => {
+ logError(
+ `Failed to connect with connection string: ${getErrorMessage(error)}`,
+ "HostedExplorer/connectWithConnectionString",
+ );
+ });
},
[authType],
);
@@ -114,14 +136,14 @@ const App: React.FunctionComponent = () => {
frameWindow.hostedConfig = {
authType: AuthType.EncryptedToken,
encryptedToken,
- encryptedTokenMetadata,
+ encryptedTokenMetadata: accountMetadata,
};
} else if (authType === AuthType.ConnectionString) {
frameWindow.hostedConfig = {
authType: AuthType.ConnectionString,
encryptedToken,
- encryptedTokenMetadata,
- masterKey: extractMasterKeyfromConnectionString(connectionString),
+ encryptedTokenMetadata: accountMetadata,
+ masterKey: extractMasterKeyFromDirectLoginConnectionString(connectionString),
};
} else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = {
@@ -140,7 +162,7 @@ const App: React.FunctionComponent = () => {
const showExplorer =
(config && isLoggedIn && databaseAccount && !connectionString) ||
- (encryptedTokenMetadata && encryptedTokenMetadata) ||
+ accountMetadata ||
(authType === AuthType.ResourceToken && connectionString);
return (
@@ -157,7 +179,7 @@ const App: React.FunctionComponent = () => {
Microsoft Azure
Cosmos DB
- {(isLoggedIn || encryptedTokenMetadata?.accountName) && (
+ {(isLoggedIn || accountMetadata?.accountName) && (
)}
{isLoggedIn && !connectionString && (
@@ -165,9 +187,9 @@ const App: React.FunctionComponent = () => {
)}
- {(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && (
+ {(!isLoggedIn || connectionString) && accountMetadata?.accountName && (
- {encryptedTokenMetadata?.accountName}
+ {accountMetadata?.accountName}
)}
@@ -188,9 +210,7 @@ const App: React.FunctionComponent = () => {
// It's possible this can be changed once all knockout code has been removed.
)}
- {!isLoggedIn && !encryptedTokenMetadata && (
-
+ {!isLoggedIn && !accountMetadata && (
+
)}
{isLoggedIn && authFailure && }
{isLoggedIn && !authFailure && (
diff --git a/src/HostedExplorerChildFrame.ts b/src/HostedExplorerChildFrame.ts
index 2cff6c862..3a38170ac 100644
--- a/src/HostedExplorerChildFrame.ts
+++ b/src/HostedExplorerChildFrame.ts
@@ -14,10 +14,12 @@ export interface AAD {
export interface ConnectionString {
authType: AuthType.ConnectionString;
- // Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy
- encryptedToken: string;
+ // SQL, Table, and Gremlin sign data-plane requests client-side with the master key and do not need the
+ // proxies, so they carry no encrypted token. Mongo and Cassandra still use the encrypted
+ // token because their operations go through the proxies.
+ encryptedToken?: string;
encryptedTokenMetadata: AccessInputMetadata;
- // Master key is currently only used by Graph API. All other APIs use encrypted tokens and proxy with connection string
+ // Master key is used for the client-side signing path (SQL, Table, Gremlin). Mongo/Cassandra leave it undefined.
masterKey?: string;
}
diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json
index 9b1299b06..f0301eb47 100644
--- a/src/Localization/en/Resources.json
+++ b/src/Localization/en/Resources.json
@@ -1175,4 +1175,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
index dc54a5f2a..a98a89ccc 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
@@ -12,8 +12,20 @@ it("shows the connect form", () => {
const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn();
const setAuthType = jest.fn();
+ const setAccountMetadata = jest.fn();
- render();
+ render(
+ ,
+ );
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull();
fireEvent.click(screen.getByText("Connect to your account with connection string"));
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined();
@@ -25,6 +37,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn();
const setAuthType = jest.fn();
+ const setAccountMetadata = jest.fn();
const oldFeatures = userContext.features;
const params = new URLSearchParams({
@@ -34,7 +47,18 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
const testFeatures = extractFeatures(params);
updateUserContext({ features: testFeatures });
- render();
+ render(
+ ,
+ );
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
updateUserContext({ features: oldFeatures });
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx
index 768fddbb6..99ce638cd 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx
@@ -1,12 +1,15 @@
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 { HttpHeaders } from "../../../Common/Constants";
-import { configContext } from "../../../ConfigContext";
+import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
+import { AccessInputMetadata } from "../../../Contracts/DataModels";
+import { parseConnectionString } from "../Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
+import { isDirectConnectionStringLoginApi } from "../HostedUtils";
interface Props {
connectionString: string;
@@ -14,39 +17,16 @@ interface Props {
setEncryptedToken: (token: string) => void;
setConnectionString: (connectionString: string) => void;
setAuthType: (authType: AuthType) => void;
+ setAccountMetadata: (metadata: AccessInputMetadata) => void;
}
-export const fetchEncryptedToken = async (connectionString: string): Promise => {
- const headers = new Headers();
- headers.append(HttpHeaders.connectionString, connectionString);
- const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
- const response = await fetch(url, { headers, method: "POST" });
- if (!response.ok) {
- throw response;
- }
-
- const encryptedTokenResponse: string = await response.json();
- return decodeURIComponent(encryptedTokenResponse);
-};
-
-export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise => {
- const headers = new Headers();
- headers.append(HttpHeaders.connectionString, connectionString);
- const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
- const response = await fetch(url, { headers, method: "POST" });
- if (!response.ok) {
- throw response;
- }
-
- return (await response.text()).toLowerCase() === "true";
-};
-
export const ConnectExplorer: React.FunctionComponent = ({
setEncryptedToken,
login,
setAuthType,
connectionString,
setConnectionString,
+ setAccountMetadata,
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
@@ -67,10 +47,15 @@ export const ConnectExplorer: React.FunctionComponent = ({
event.preventDefault();
setErrorMessage("");
- 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.",
- );
+ 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));
return;
}
@@ -79,6 +64,15 @@ export const ConnectExplorer: React.FunctionComponent = ({
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;
+ }
+
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
index 93d76855c..823ebb115 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
@@ -12,6 +12,18 @@ describe("ConnectionStringParser", () => {
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();
+ });
+
+ it("should keep the document endpoint given by the connection string", () => {
+ // The endpoint is taken from the connection string rather than rebuilt from the account name, so a
+ // string that omits the port keeps it omitted.
+ const metadata = parseConnectionString(
+ `AccountEndpoint=https://${mockAccountName}.documents.azure.com/;AccountKey=${mockMasterKey};`,
+ );
+
+ expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
});
it("should parse a valid mongo account connection string", () => {
@@ -39,6 +51,8 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
+ expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
+ expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
});
it("should parse a valid table account connection string", () => {
@@ -48,6 +62,20 @@ describe("ConnectionStringParser", () => {
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();
+ });
+
+ 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/;`,
+ );
+
+ 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();
});
it("should parse a valid cassandra account connection string", () => {
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
index 6c8db5f96..44be7e82a 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
@@ -1,6 +1,13 @@
import * as Constants from "../../../Common/Constants";
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 DnsPort = "443";
+
export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) {
try {
@@ -11,6 +18,9 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[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);
accessInput.accountName = matches && matches.length > 1 && matches[2];
@@ -38,6 +48,17 @@ 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.
+ if (accessInput.accountName) {
+ if (accessInput.apiKind === ApiKind.Table) {
+ accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
+ } else if (accessInput.apiKind === ApiKind.Graph) {
+ accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
+ }
+ }
+
return accessInput;
} catch (error) {
return undefined;
diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts
index 1b8ddd666..d199aba0f 100644
--- a/src/Platform/Hosted/HostedUtils.test.ts
+++ b/src/Platform/Hosted/HostedUtils.test.ts
@@ -1,5 +1,9 @@
-import { AccessInputMetadata } from "../../Contracts/DataModels";
-import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
+import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
+import {
+ extractMasterKeyFromDirectLoginConnectionString,
+ getDatabaseAccountPropertiesFromMetadata,
+ isDirectConnectionStringLoginApi,
+} from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => {
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
@@ -30,3 +34,58 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
});
});
});
+
+describe("extractMasterKeyFromDirectLoginConnectionString", () => {
+ const mockAccountName = "Test";
+ const mockKey = "abc123+/=someKey==";
+
+ it("extracts the account key from a SQL connection string", () => {
+ expect(
+ extractMasterKeyFromDirectLoginConnectionString(
+ `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
+ ),
+ ).toBe(mockKey);
+ });
+
+ it("extracts the account key from a Table connection string", () => {
+ expect(
+ extractMasterKeyFromDirectLoginConnectionString(
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
+ ),
+ ).toBe(mockKey);
+ });
+
+ it("extracts the account key from a Gremlin connection string", () => {
+ expect(
+ extractMasterKeyFromDirectLoginConnectionString(
+ `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
+ ),
+ ).toBe(mockKey);
+ });
+
+ it("returns undefined when there is no account key", () => {
+ expect(
+ extractMasterKeyFromDirectLoginConnectionString(
+ `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`,
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns undefined for an empty connection string", () => {
+ expect(extractMasterKeyFromDirectLoginConnectionString("")).toBeUndefined();
+ });
+});
+
+describe("isDirectConnectionStringLoginApi", () => {
+ it("returns true for SQL, Table, and Graph", () => {
+ expect(isDirectConnectionStringLoginApi(ApiKind.SQL)).toBe(true);
+ expect(isDirectConnectionStringLoginApi(ApiKind.Table)).toBe(true);
+ expect(isDirectConnectionStringLoginApi(ApiKind.Graph)).toBe(true);
+ });
+
+ it("returns false for Mongo and Cassandra, which require the Portal Backend proxy", () => {
+ expect(isDirectConnectionStringLoginApi(ApiKind.MongoDB)).toBe(false);
+ expect(isDirectConnectionStringLoginApi(ApiKind.MongoDBCompute)).toBe(false);
+ expect(isDirectConnectionStringLoginApi(ApiKind.Cassandra)).toBe(false);
+ });
+});
diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts
index f39e318b1..32cc2e452 100644
--- a/src/Platform/Hosted/HostedUtils.ts
+++ b/src/Platform/Hosted/HostedUtils.ts
@@ -40,8 +40,14 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC
return AccountKind.GlobalDocumentDB;
}
-export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined {
- // Only Gremlin uses the actual master key for connection to cosmos
- const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$");
+// Returns the master key carried by SQL, Table, and Gremlin connection strings.
+export function extractMasterKeyFromDirectLoginConnectionString(connectionString: string): string | undefined {
+ const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
}
+
+// SQL, Table, and Gremlin can sign data-plane requests client-side with the account key, so they do
+// not need the Portal Backend proxy for connection-string login. Mongo and Cassandra still require the proxy.
+export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean {
+ return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph;
+}
diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts
index d4a4f9555..41bec9797 100644
--- a/src/hooks/useKnockoutExplorer.ts
+++ b/src/hooks/useKnockoutExplorer.ts
@@ -467,13 +467,24 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: {},
};
- updateUserContext({
- // For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
- authType: AuthType.EncryptedToken,
- accessToken: encodeURIComponent(config.encryptedToken),
- databaseAccount,
- masterKey: config.masterKey,
- });
+ if (config.masterKey && !config.encryptedToken) {
+ // Direct client-side signing path (SQL, Table, Gremlin). Requests are signed locally with the
+ // account key via the Cosmos client's tokenProvider, so no Portal Backend proxy or encrypted token
+ // is required.
+ updateUserContext({
+ authType: AuthType.ConnectionString,
+ databaseAccount,
+ masterKey: config.masterKey,
+ });
+ } else {
+ // Legacy encrypted-token proxy path (Mongo, Cassandra).
+ updateUserContext({
+ authType: AuthType.EncryptedToken,
+ accessToken: encodeURIComponent(config.encryptedToken),
+ databaseAccount,
+ masterKey: config.masterKey,
+ });
+ }
const explorer = new Explorer();
return explorer;
}
diff --git a/src/hooks/usePortalAccessToken.tsx b/src/hooks/usePortalAccessToken.tsx
deleted file mode 100644
index bf59dc63f..000000000
--- a/src/hooks/usePortalAccessToken.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useEffect, useState } from "react";
-import { HttpHeaders } from "../Common/Constants";
-import { configContext } from "../ConfigContext";
-import { AccessInputMetadata } from "../Contracts/DataModels";
-
-export async function fetchAccessData(portalToken: string): Promise {
- const headers = new Headers();
- // Portal encrypted token API quirk: The token header must be URL encoded
- headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
- const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
- const options = {
- method: "GET",
- headers: headers,
- };
-
- return fetch(url, options)
- .then((response) => response.json())
- .catch((error) => console.error(error));
-}
-
-export function useTokenMetadata(token: string): AccessInputMetadata | undefined {
- const [state, setState] = useState();
-
- useEffect(() => {
- if (token) {
- fetchAccessData(token).then((response) => setState(response));
- }
- }, [token]);
- return state;
-}
diff --git a/test/fx.ts b/test/fx.ts
index 880388a33..a3cad859a 100644
--- a/test/fx.ts
+++ b/test/fx.ts
@@ -1,8 +1,15 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
-import crypto from "crypto";
+import crypto, { webcrypto } from "crypto";
import { TestContainerContext } from "./testData";
+// The @azure/cosmos client signs requests with globalThis.crypto (Web Crypto API).
+// In Node.js >= 19 it's already available; only assign the polyfill for older versions.
+// This lives in fx.ts (imported by every spec) so the polyfill always runs.
+if (!globalThis.crypto) {
+ Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true });
+}
+
const RETRY_COUNT = 3;
export interface TestNameOptions {
@@ -41,6 +48,14 @@ export enum TestAccount {
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
+ SQLConnectionString = "SQLConnectionString",
+ TableConnectionString = "TableConnectionString",
+ GremlinConnectionString = "GremlinConnectionString",
+}
+
+export enum TestAuthType {
+ EntraID = "EntraID",
+ ConnectionString = "ConnectionString",
}
export function getDefaultAccountName(accountType: TestAccount): string {
@@ -66,6 +81,12 @@ export function getDefaultAccountName(accountType: TestAccount): string {
return `${accountNamePrefix}-de-test-sql-readonly`;
case TestAccount.SQLContainerCopyOnly:
return `${accountNamePrefix}-de-test-sql-containercopy`;
+ case TestAccount.SQLConnectionString:
+ return `${accountNamePrefix}-de-test-sql-connstring-1`;
+ case TestAccount.TableConnectionString:
+ return `${accountNamePrefix}-de-test-table-connstring-1`;
+ case TestAccount.GremlinConnectionString:
+ return `${accountNamePrefix}-de-test-gremlin-connstring-1`;
case TestAccount.SQL: {
const shardIndex = process.env.PLAYWRIGHT_SHARD_INDEX ?? "";
if (!shardIndex) {
@@ -96,7 +117,32 @@ function tryGetStandardName(accountType: TestAccount) {
}
}
-export function getAccountName(accountType: TestAccount) {
+// Maps a base API account type to its dedicated connection string (account key) account.
+const connectionStringAccountTypes: Partial> = {
+ [TestAccount.SQL]: TestAccount.SQLConnectionString,
+ [TestAccount.Tables]: TestAccount.TableConnectionString,
+ [TestAccount.Gremlin]: TestAccount.GremlinConnectionString,
+};
+
+export function getAccountName(accountType: TestAccount, authType: TestAuthType = TestAuthType.EntraID): string {
+ // Connection string (account key) login uses dedicated *-connstring accounts that are only
+ // provisioned in CI (resolved via DE_ACCOUNT_PREFIX). Local runs use DE_TEST_ACCOUNT_PREFIX and
+ // typically don't have those accounts, so they fall back to the standard API account for the same
+ // API (which also has key auth enabled).
+ if (authType === TestAuthType.ConnectionString) {
+ const connectionStringType = connectionStringAccountTypes[accountType];
+ if (!connectionStringType) {
+ throw new Error(`No connection string account defined for account type ${accountType}`);
+ }
+ const override = process.env[`DE_TEST_ACCOUNT_NAME_${connectionStringType.toLocaleUpperCase()}`];
+ if (override) {
+ return override;
+ }
+ if (!process.env.DE_TEST_ACCOUNT_PREFIX) {
+ return getAccountName(connectionStringType);
+ }
+ }
+
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
@@ -210,6 +256,13 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
params.set("enableaaddataplane", "true");
}
break;
+
+ case TestAccount.SQLConnectionString:
+ case TestAccount.TableConnectionString:
+ case TestAccount.GremlinConnectionString:
+ // Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
+ // use this iframe test-explorer URL or any RBAC/AAD data-plane token.
+ break;
}
if (iframeSrc) {
diff --git a/test/gremlin/connectionStringLogin.spec.ts b/test/gremlin/connectionStringLogin.spec.ts
new file mode 100644
index 000000000..50289d867
--- /dev/null
+++ b/test/gremlin/connectionStringLogin.spec.ts
@@ -0,0 +1,84 @@
+import { expect, test } from "@playwright/test";
+
+import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
+import { CosmosClient, Database } from "@azure/cosmos";
+import {
+ DataExplorer,
+ Editor,
+ ONE_MINUTE_MS,
+ TestAccount,
+ TestAuthType,
+ generateUniqueName,
+ getAccountName,
+ getAzureCLICredentials,
+ resourceGroupName,
+ subscriptionId,
+} from "../fx";
+
+const databaseId = generateUniqueName("db");
+const graphId = "testgraph";
+const vertexId = "testvertex";
+
+test.describe("Gremlin account using connection string login", () => {
+ let database: Database = null!;
+
+ test.beforeAll("Seed Test Database", async () => {
+ const credentials = getAzureCLICredentials();
+ const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
+ const accountName = getAccountName(TestAccount.Gremlin, TestAuthType.ConnectionString);
+ const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
+ const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
+
+ // Gremlin graphs are stored as documents, so seed a vertex via the SQL client using Cosmos' internal graph format.
+ const client = new CosmosClient({ endpoint: account.documentEndpoint!, key: keys.primaryMasterKey });
+ database = (await client.databases.createIfNotExists({ id: databaseId })).database;
+ const { container } = await database.containers.createIfNotExists({
+ id: graphId,
+ partitionKey: { paths: ["/pk"] },
+ });
+ await container.items.upsert({ id: vertexId, label: "person", pk: "pk1" });
+ });
+
+ test.afterAll("Delete Test Database", async () => {
+ await database?.delete();
+ });
+
+ test("reads a vertex after connection string login", async ({ page }) => {
+ const credentials = getAzureCLICredentials();
+ const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
+ const accountName = getAccountName(TestAccount.Gremlin, TestAuthType.ConnectionString);
+ const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
+ const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
+
+ // Gremlin signs data-plane requests client-side with the account key, so no encrypted token is issued.
+ const connectionString = `AccountEndpoint=${account.documentEndpoint};AccountKey=${keys.primaryMasterKey};ApiKind=Gremlin;`;
+
+ await page.goto("https://localhost:1234/hostedExplorer.html");
+ const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
+ await switchConnectionLink.waitFor();
+ await switchConnectionLink.click();
+ await page.getByPlaceholder("Please enter a connection string").fill(connectionString);
+ await page.getByRole("button", { name: "Connect" }).click();
+
+ const explorer = await DataExplorer.waitForExplorer(page);
+ const graphNode = await explorer.waitForContainerNode(databaseId, graphId);
+ await graphNode.expand();
+
+ // Open the Graph node to load the graph explorer, then run the default query to read the seeded vertex.
+ const graphDataNode = await explorer.waitForNode(`${databaseId}/${graphId}/Graph`);
+ await graphDataNode.element.click();
+
+ await explorer.frame.getByRole("button", { name: "Execute Gremlin Query" }).click();
+
+ // Results open in the Graph view; switch to the JSON view to read the vertex document.
+ const jsonResultsTab = explorer.frame.getByRole("tab", { name: "JSON" });
+ await jsonResultsTab.waitFor({ timeout: ONE_MINUTE_MS });
+ await jsonResultsTab.click();
+
+ const graphJsonEditor = new Editor(
+ explorer.frame,
+ explorer.frame.locator(".graphJsonEditor").getByTestId("EditorReact/Host/Loaded"),
+ );
+ await expect.poll(async () => await graphJsonEditor.text(), { timeout: ONE_MINUTE_MS }).toContain(vertexId);
+ });
+});
diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts
new file mode 100644
index 000000000..8359e6dee
--- /dev/null
+++ b/test/sql/connectionStringLogin.spec.ts
@@ -0,0 +1,118 @@
+import { Page, expect, test } from "@playwright/test";
+
+import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
+import { CosmosClient, Database } from "@azure/cosmos";
+import {
+ DataExplorer,
+ ONE_MINUTE_MS,
+ TestAccount,
+ TestAuthType,
+ generateUniqueName,
+ getAccountName,
+ getAzureCLICredentials,
+ resourceGroupName,
+ subscriptionId,
+} from "../fx";
+
+const databaseId = generateUniqueName("db");
+const containerId = "testcontainer";
+const documentId = "testdoc1";
+
+async function loginWithConnectionString(page: Page, connectionString: string): Promise {
+ await page.goto("https://localhost:1234/hostedExplorer.html");
+ const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
+ await switchConnectionLink.waitFor();
+ await switchConnectionLink.click();
+ await page.getByPlaceholder("Please enter a connection string").fill(connectionString);
+ await page.getByRole("button", { name: "Connect" }).click();
+}
+
+test.describe("SQL account using connection string login", () => {
+ let database: Database = null!;
+ let documentEndpoint: string = null!;
+ // SQL signs data-plane requests client-side with the account key, so no encrypted token is issued.
+ let connectionString: string = null!;
+
+ test.beforeAll("Seed Test Database", async () => {
+ const credentials = getAzureCLICredentials();
+ const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
+ const accountName = getAccountName(TestAccount.SQL, TestAuthType.ConnectionString);
+ const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
+ const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
+ documentEndpoint = account.documentEndpoint!;
+ connectionString = `AccountEndpoint=${documentEndpoint};AccountKey=${keys.primaryMasterKey};`;
+
+ const client = new CosmosClient({ endpoint: documentEndpoint, key: keys.primaryMasterKey });
+ database = (await client.databases.createIfNotExists({ id: databaseId })).database;
+ const { container } = await database.containers.createIfNotExists({
+ id: containerId,
+ partitionKey: { paths: ["/id"] },
+ });
+ await container.items.upsert({ id: documentId });
+ });
+
+ test.afterAll("Delete Test Database", async () => {
+ await database?.delete();
+ });
+
+ test("reads a document after connection string login", async ({ page }) => {
+ await loginWithConnectionString(page, connectionString);
+
+ const explorer = await DataExplorer.waitForExplorer(page);
+ const collectionNode = await explorer.waitForContainerNode(databaseId, containerId);
+ await expect(collectionNode.element).toBeAttached();
+ await collectionNode.expand();
+
+ // Open the Items node to load the Documents tab and read the seeded document through the data plane.
+ const itemsNode = await explorer.waitForContainerItemsNode(databaseId, containerId);
+ await itemsNode.element.click();
+
+ const documentsTab = explorer.documentsTab("tab0");
+ await documentsTab.documentsFilter.waitFor();
+ await documentsTab.documentsListPane.waitFor();
+ await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: ONE_MINUTE_MS });
+
+ const documentRow = documentsTab.documentsListPane.getByText(documentId, { exact: true }).nth(0);
+ await documentRow.waitFor();
+ await documentRow.click();
+ await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: ONE_MINUTE_MS });
+
+ const resultText = await documentsTab.resultsEditor.text();
+ expect(resultText).not.toBeNull();
+ const resultData = JSON.parse(resultText!);
+ expect(resultData?.id).toEqual(documentId);
+ });
+
+ test("does not call the Portal Backend during login", async ({ page }) => {
+ // SQL derives the account metadata from the connection string and signs data-plane requests with the
+ // account key, so neither the encrypted token nor the access metadata endpoint should be hit.
+ const portalBackendCalls: string[] = [];
+ page.on("request", (request) => {
+ if (request.url().includes("/api/connectionstring/")) {
+ portalBackendCalls.push(request.url());
+ }
+ });
+
+ await loginWithConnectionString(page, connectionString);
+
+ const explorer = await DataExplorer.waitForExplorer(page);
+ const collectionNode = await explorer.waitForContainerNode(databaseId, containerId);
+ await expect(collectionNode.element).toBeAttached();
+
+ expect(portalBackendCalls).toEqual([]);
+ });
+
+ test("opens Data Explorer when the connection string has the wrong account key", async ({ page }) => {
+ // A well-formed but incorrect base64 account key. The login is accepted
+ // without checking the key against the account, so the user gets into Data Explorer either way and
+ // only the data-plane requests made from inside the explorer are rejected.
+ const wrongKey = "A".repeat(86) + "==";
+ await loginWithConnectionString(page, `AccountEndpoint=${documentEndpoint};AccountKey=${wrongKey};`);
+
+ await DataExplorer.waitForExplorer(page);
+
+ // The connect form is replaced by the explorer rather than staying up with a login error.
+ await expect(page.locator("#connectExplorer")).toHaveCount(0);
+ await expect(page.locator(".errorDetails")).toHaveCount(0);
+ });
+});
diff --git a/test/tables/connectionStringLogin.spec.ts b/test/tables/connectionStringLogin.spec.ts
new file mode 100644
index 000000000..02db382a2
--- /dev/null
+++ b/test/tables/connectionStringLogin.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from "@playwright/test";
+
+import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
+import { Container, CosmosClient } from "@azure/cosmos";
+import {
+ DataExplorer,
+ ONE_MINUTE_MS,
+ TestAccount,
+ TestAuthType,
+ generateUniqueName,
+ getAccountName,
+ getAzureCLICredentials,
+ resourceGroupName,
+ subscriptionId,
+} from "../fx";
+
+// Table API accounts store tables in a fixed "TablesDB" database, with each table as a container.
+const databaseId = "TablesDB";
+const tableId = generateUniqueName("table");
+const partitionKey = "testpartition";
+const rowKey = "testrow";
+
+test.describe("Tables account using connection string login", () => {
+ let container: Container = null!;
+
+ test.beforeAll("Seed Test Table", async () => {
+ const credentials = getAzureCLICredentials();
+ const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
+ const accountName = getAccountName(TestAccount.Tables, TestAuthType.ConnectionString);
+ const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
+ const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
+
+ const client = new CosmosClient({ endpoint: account.documentEndpoint!, key: keys.primaryMasterKey });
+ const { database } = await client.databases.createIfNotExists({ id: databaseId });
+ container = (
+ await database.containers.createIfNotExists({
+ id: tableId,
+ partitionKey: { paths: ["/'$pk'"] },
+ })
+ ).container;
+ await container.items.upsert({ $pk: partitionKey, id: rowKey, $id: rowKey });
+ });
+
+ test.afterAll("Delete Test Table", async () => {
+ // Only remove the table we created; the fixed "TablesDB" database is shared by every table in the
+ // account, so deleting it would destroy unrelated tables.
+ await container?.delete();
+ });
+
+ test("reads an entity after connection string login", async ({ page }) => {
+ const credentials = getAzureCLICredentials();
+ const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
+ const accountName = getAccountName(TestAccount.Tables, TestAuthType.ConnectionString);
+ const { connectionStrings = [] } = await armClient.databaseAccounts.listConnectionStrings(
+ resourceGroupName,
+ accountName,
+ );
+
+ // Table accounts sign data-plane requests client-side with the account key, so no encrypted token is issued.
+ const connectionString = connectionStrings.find((cs) => cs.type === "Table")?.connectionString;
+
+ await page.goto("https://localhost:1234/hostedExplorer.html");
+ const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
+ await switchConnectionLink.waitFor();
+ await switchConnectionLink.click();
+ await page.getByPlaceholder("Please enter a connection string").fill(connectionString!);
+ await page.getByRole("button", { name: "Connect" }).click();
+
+ const explorer = await DataExplorer.waitForExplorer(page);
+ const tableNode = await explorer.waitForContainerNode(databaseId, tableId);
+ await tableNode.expand();
+
+ // Open the Entities node to load the table entities grid and read the seeded entity through the data plane.
+ const entitiesNode = await explorer.waitForNode(`${databaseId}/${tableId}/Entities`);
+ await entitiesNode.element.click();
+
+ const entitiesGrid = explorer.frame.locator("#storageTable");
+ await expect(entitiesGrid).toBeVisible({ timeout: ONE_MINUTE_MS });
+ await expect(entitiesGrid.getByText(rowKey, { exact: true }).first()).toBeVisible({ timeout: ONE_MINUTE_MS });
+ await expect(entitiesGrid.getByText(partitionKey, { exact: true }).first()).toBeVisible();
+ });
+});
diff --git a/test/testData.ts b/test/testData.ts
index e9ba759f1..57fbfe5a7 100644
--- a/test/testData.ts
+++ b/test/testData.ts
@@ -9,7 +9,6 @@ import {
JSONObject,
} from "@azure/cosmos";
import { Buffer } from "node:buffer";
-import { webcrypto } from "node:crypto";
import {
generateUniqueName,
getAccountName,
@@ -19,12 +18,6 @@ import {
TestAccount,
} from "./fx";
-// In Node.js >= 19, globalThis.crypto is already available as a read-only getter.
-// Only assign the polyfill for older versions.
-if (!globalThis.crypto) {
- Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true });
-}
-
export interface TestItem {
id: string;
partitionKey: string;
diff --git a/tsconfig.strict.json b/tsconfig.strict.json
index e1021f162..947b073e5 100644
--- a/tsconfig.strict.json
+++ b/tsconfig.strict.json
@@ -25,6 +25,7 @@
"./src/Common/ObjectCache.ts",
"./src/Common/OfferUtility.test.ts",
"./src/Common/OfferUtility.ts",
+ "./src/Common/PortalBackendClient.ts",
"./src/Common/Splitter.ts",
"./src/Common/ThemeUtility.ts",
"./src/Common/UrlUtility.ts",
@@ -107,7 +108,6 @@
"./src/hooks/useDirectories.tsx",
"./src/hooks/useGraphPhoto.tsx",
"./src/hooks/useNotebookSnapshotStore.ts",
- "./src/hooks/usePortalAccessToken.tsx",
"./src/hooks/useNotificationConsole.ts",
"./src/hooks/useObservable.ts",
"./src/hooks/useSidePanel.ts",
@@ -137,4 +137,4 @@
"src/Shared/Telemetry/**/*",
"src/Utils/arm/**/*"
]
-}
\ No newline at end of file
+}
From c3758306990bca73e68fd2c60a81c117e098f915 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 10:04:38 -0700
Subject: [PATCH 3/9] Bump http-proxy-middleware from 2.0.9 to 2.0.10 (#2563)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.9 to 2.0.10.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.10/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.9...v2.0.10)
---
updated-dependencies:
- dependency-name: http-proxy-middleware
dependency-version: 2.0.10
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 712d2cb8f..099acec8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -102,6 +102,7 @@
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
+ "ws": "8.21.3",
"xterm": "4.19.0",
"xterm-addon-fit": "0.5.0",
"zustand": "3.5.0"
@@ -15423,10 +15424,11 @@
}
},
"node_modules/http-proxy-middleware": {
- "version": "2.0.9",
- "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
- "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
+ "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
From 76490708114bfb2750c5c2616b17a222d3c2e3da Mon Sep 17 00:00:00 2001
From: Asier Isayas
Date: Wed, 26 Aug 2026 13:05:32 -0400
Subject: [PATCH 4/9] Support sovereign/PPE endpoint zones for connection
string login
Move the hardcoded account endpoint suffixes out of Constants and into ConfigContext so connection string login works in sovereign clouds and PPE, and widen ConnectionStringParser to accept every configured zone.
Surface Portal Backend rejections in the hosted connect form: read the body off the Response that fetchEncryptedToken throws, and offer a firewall help link on a 403.
Add an E2E test covering SQL connection string login against an account with public network access disabled.
---
src/Common/Constants.ts | 11 -
src/ConfigContext.ts | 34 ++
src/HostedExplorer.tsx | 5 +-
.../Components/ConnectExplorer.test.tsx | 119 +++++++
.../Hosted/Components/ConnectExplorer.tsx | 51 ++-
src/Platform/Hosted/ConnectScreen.less | 44 +--
.../Helpers/ConnectionStringParser.test.ts | 293 ++++++++++++++++--
.../Hosted/Helpers/ConnectionStringParser.ts | 76 +++--
test/fx.ts | 4 +
test/sql/connectionStringLogin.spec.ts | 28 ++
10 files changed, 565 insertions(+), 100 deletions(-)
diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts
index c4d0e1cd5..5f52c1d05 100644
--- a/src/Common/Constants.ts
+++ b/src/Common/Constants.ts
@@ -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";
diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts
index 76574a117..6a9b8f03e 100644
--- a/src/ConfigContext.ts
+++ b/src/ConfigContext.ts
@@ -30,6 +30,13 @@ export interface ConfigContext {
allowedCassandraProxyEndpoints: ReadonlyArray;
allowedMongoProxyEndpoints: ReadonlyArray;
allowedParentFrameOrigins: ReadonlyArray;
+ SQL_DNS_ZONES: ReadonlyArray;
+ MONGO_DNS_ZONES: ReadonlyArray;
+ MONGO_COMPUTE_DNS_ZONES: ReadonlyArray;
+ CASSANDRA_DNS_ZONES: ReadonlyArray;
+ TABLE_DNS_ZONES: ReadonlyArray;
+ DOCUMENT_ENDPOINT_ZONES: ReadonlyArray;
+ GREMLIN_ENDPOINT_ZONES: ReadonlyArray;
gitSha?: string;
proxyPath?: string;
AAD_ENDPOINT: string;
@@ -81,6 +88,33 @@ let configContext: Readonly = {
`^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.cosmosdb.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/",
diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx
index ca28376d6..642141002 100644
--- a/src/HostedExplorer.tsx
+++ b/src/HostedExplorer.tsx
@@ -1,6 +1,5 @@
import { initializeIcons } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
-import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
import * as React from "react";
import { render } from "react-dom";
@@ -83,9 +82,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",
);
});
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
index a98a89ccc..c37f6e841 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
@@ -1,11 +1,31 @@
jest.mock("../../../hooks/useDirectories");
+jest.mock("../../../Common/PortalBackendClient");
+// ScenarioMonitor subscribes to web-vitals on import, and onTTFB reads a navigation timing entry that
+// jsdom never produces, throwing from a timer that lands on whichever test happens to be running.
+jest.mock("web-vitals");
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;
+const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction<
+ typeof isAccountRestrictedForConnectionStringLogin
+>;
+
+// fetchEncryptedToken rejects with the raw Response. jsdom implements no part of the fetch API, so
+// there is no Response constructor to build one with.
+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 +83,102 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures });
});
+
+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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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();
+});
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx
index 99ce638cd..d83637419 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx
@@ -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,7 @@ export const ConnectExplorer: React.FunctionComponent = ({
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
+ const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
return (
@@ -46,6 +48,7 @@ export const ConnectExplorer: React.FunctionComponent = ({
onSubmit={async (event) => {
event.preventDefault();
setErrorMessage("");
+ setIsBlockedByFirewall(false);
try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
@@ -73,9 +76,24 @@ export const ConnectExplorer: React.FunctionComponent = ({
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);
+ }
}}
>
Connect to your account with connection string
@@ -90,13 +108,26 @@ export const ConnectExplorer: React.FunctionComponent = ({
setConnectionString(event.target.value);
}}
/>
- {errorMessage.length > 0 && (
-
-
- {errorMessage}
-
- )}
+ {errorMessage.length > 0 && (
+
+
+
+ {errorMessage}
+ {isBlockedByFirewall && (
+
+ Allow access from Azure Portal
+
+ )}
+
+
+
+ )}
diff --git a/src/Platform/Hosted/ConnectScreen.less b/src/Platform/Hosted/ConnectScreen.less
index d4123f23f..2be2cf128 100644
--- a/src/Platform/Hosted/ConnectScreen.less
+++ b/src/Platform/Hosted/ConnectScreen.less
@@ -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 {
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
index 823ebb115..94410eea2 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
@@ -1,10 +1,72 @@
+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";
+ // Keyed by ApiKind so adding an API to the enum fails to compile here rather than silently going
+ // untested.
+ const connectionStringsByApiKind: Record = {
+ [DataModels.ApiKind
+ .SQL]: `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
+ [DataModels.ApiKind
+ .MongoDB]: `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
+ [DataModels.ApiKind
+ .MongoDBCompute]: `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
+ [DataModels.ApiKind
+ .Cassandra]: `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
+ [DataModels.ApiKind
+ .Table]: `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
+ [DataModels.ApiKind
+ .Graph]: `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
+ };
+
+ 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.cosmosdb.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 parse a valid sql account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
@@ -26,6 +88,20 @@ describe("ConnectionStringParser", () => {
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
});
+ it.each(configContext.SQL_DNS_ZONES)(
+ "should parse a sql account connection string using the %s zone",
+ (dnsZone: string) => {
+ const metadata = parseConnectionString(
+ `AccountEndpoint=https://${mockAccountName}.${dnsZone}:443/;AccountKey=${mockMasterKey};`,
+ );
+
+ 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 mongo account connection string", () => {
const metadata = parseConnectionString(
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
@@ -35,15 +111,85 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
});
- it("should parse a valid compute mongo account connection string", () => {
+ it.each(configContext.MONGO_DNS_ZONES)(
+ "should parse a mongo account connection string using the %s zone",
+ (dnsZone: string) => {
+ const metadata = parseConnectionString(
+ `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${dnsZone}:10255`,
+ );
+
+ 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(
+ `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${dnsZone}:10255`,
+ );
+
+ expect(metadata.accountName).toBe(mockAccountName);
+ expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
+ },
+ );
+
+ it("should parse a valid cassandra account connection string", () => {
const metadata = parseConnectionString(
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
+ `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
);
expect(metadata.accountName).toBe(mockAccountName);
- expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
+ expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
});
+ it.each(
+ ["AccountEndpoint", "HostName"].flatMap((key) =>
+ configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [key, dnsZone]),
+ ),
+ )("should parse a cassandra account connection string using %s and the %s zone", (key: string, dnsZone: string) => {
+ const metadata = parseConnectionString(`${key}=${mockAccountName}.${dnsZone};AccountKey=${mockMasterKey};`);
+
+ 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(
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${dnsZone}:443/;`,
+ );
+
+ expect(metadata.accountName).toBe(mockAccountName);
+ expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
+ expect(metadata.apiEndpoint).toBeUndefined();
+ },
+ );
+
+ it("should construct the document endpoint for a table account from the account name", () => {
+ const metadata = parseConnectionString(
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
+ );
+
+ // Table connection strings only carry the table endpoint, so the document endpoint that data plane
+ // operations go through is built from the account name.
+ expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
+ });
+
+ it.each(["table.cosmosdb.windows-ppe.net", "table.cosmos.windows-ppe.net"])(
+ "should construct a PPE document endpoint for a table account in the %s zone",
+ (dnsZone: string) => {
+ const metadata = parseConnectionString(
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${dnsZone}:443/;`,
+ );
+
+ // The constructed endpoint has to match the kind of zone the table endpoint we matched came from.
+ expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
+ },
+ );
+
it("should parse a valid graph account connection string", () => {
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
@@ -55,36 +201,45 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
});
- it("should parse a valid table account connection string", () => {
+ it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
const metadata = parseConnectionString(
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
+ `AccountEndpoint=https://${mockAccountName}.documents-staging.windows-ppe.net:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
);
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(
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.windows-ppe.net:443/;`,
+ );
+
+ // 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([
+ `AccountEndpoint=https://${mockAccountName}.documents.azure.com.attacker.example:443/;AccountKey=${mockMasterKey};`,
+ `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com.attacker.example:10255`,
+ `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com.attacker.example:10255`,
+ `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com.attacker.example;AccountKey=${mockMasterKey};`,
+ `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com.attacker.example:443/;`,
+ ])("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 +253,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();
+ });
+ });
});
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
index 44be7e82a..0e3dd2bdf 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
@@ -1,44 +1,76 @@
-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";
+const isPpeZone = (zone: string): boolean => 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. Returns undefined when the
+// config carries no zone of that kind, so the caller can reject the connection string rather than build
+// an endpoint the account does not own and send the account key there.
+export const selectEndpointZone = (zones: ReadonlyArray, 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.
+export const dnsZoneAlternation = (zones: ReadonlyArray): 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);
+ } 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) => {
+ } else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
+ endpointsRegex.cassandra.forEach((regex) => {
if (RegExp(regex).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(regex)[1];
accessInput.apiKind = ApiKind.Cassandra;
}
});
- } 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;
}
@@ -53,9 +85,17 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
// need 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}/`;
+ 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}`;
}
}
diff --git a/test/fx.ts b/test/fx.ts
index a3cad859a..f3fc2e679 100644
--- a/test/fx.ts
+++ b/test/fx.ts
@@ -49,6 +49,7 @@ export enum TestAccount {
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
SQLConnectionString = "SQLConnectionString",
+ SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled",
TableConnectionString = "TableConnectionString",
GremlinConnectionString = "GremlinConnectionString",
}
@@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string {
return `${accountNamePrefix}-de-test-sql-containercopy`;
case TestAccount.SQLConnectionString:
return `${accountNamePrefix}-de-test-sql-connstring-1`;
+ case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
+ return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`;
case TestAccount.TableConnectionString:
return `${accountNamePrefix}-de-test-table-connstring-1`;
case TestAccount.GremlinConnectionString:
@@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
break;
case TestAccount.SQLConnectionString:
+ case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
case TestAccount.TableConnectionString:
case TestAccount.GremlinConnectionString:
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts
index 8359e6dee..88293bd64 100644
--- a/test/sql/connectionStringLogin.spec.ts
+++ b/test/sql/connectionStringLogin.spec.ts
@@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => {
await expect(page.locator("#connectExplorer")).toHaveCount(0);
await expect(page.locator(".errorDetails")).toHaveCount(0);
});
+
+ test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => {
+ // An account that refuses this client's IP.
+ const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId);
+ const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled);
+ const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName);
+ const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName);
+
+ await loginWithConnectionString(
+ page,
+ `AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`,
+ );
+
+ const explorer = await DataExplorer.waitForExplorer(page);
+
+ // Login is a client-side parse of the connection string, so nothing checks whether the account will
+ // accept requests from this IP before letting the user in.
+ await expect(page.locator("#connectExplorer")).toHaveCount(0);
+ await expect(page.locator(".errorDetails")).toHaveCount(0);
+
+ // The rejection surfaces once the tree tries to read the data plane, and only in the console.
+ const consoleMessages = await explorer.getNotificationConsoleMessages();
+ await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS });
+
+ // The tree is left with the static Home node and no database or container beneath it.
+ await expect(explorer.treeNode("Home").element).toBeAttached();
+ await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1);
+ });
});
From 527fc2c48c6f3f15879c58383a7e4882bdf89637 Mon Sep 17 00:00:00 2001
From: Asier Isayas
Date: Wed, 26 Aug 2026 14:28:32 -0400
Subject: [PATCH 5/9] Harden connection string DNS zone matching
Detect PPE accounts from Mongo and Cassandra connection strings, build their document endpoint from the matched zone, match the PPE suffix on a label boundary, and escape every regex metacharacter in config-supplied zones. Drop the sqlx.cosmosdb.azure.com zone, which is not a real SQL zone.
---
src/ConfigContext.ts | 1 -
.../Helpers/ConnectionStringParser.test.ts | 165 ++++++++----------
.../Hosted/Helpers/ConnectionStringParser.ts | 30 +++-
3 files changed, 92 insertions(+), 104 deletions(-)
diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts
index 6a9b8f03e..a14deb248 100644
--- a/src/ConfigContext.ts
+++ b/src/ConfigContext.ts
@@ -92,7 +92,6 @@ let configContext: Readonly = {
"documents.azure.com",
"sql.cosmosdb.azure.com",
"sql.cosmos.azure.com",
- "sqlx.cosmosdb.azure.com",
"sqlx.cosmos.azure.com",
"documents-staging.windows-ppe.net",
"sql.cosmosdb.windows-ppe.net",
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
index 94410eea2..f1cb417ba 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts
@@ -11,21 +11,27 @@ describe("ConnectionStringParser", () => {
const mockAccountName = "Test";
const mockMasterKey = "some-key";
- // Keyed by ApiKind so adding an API to the enum fails to compile here rather than silently going
- // untested.
+ // 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;`,
+ };
+
const connectionStringsByApiKind: Record = {
- [DataModels.ApiKind
- .SQL]: `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
- [DataModels.ApiKind
- .MongoDB]: `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
- [DataModels.ApiKind
- .MongoDBCompute]: `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
- [DataModels.ApiKind
- .Cassandra]: `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
- [DataModels.ApiKind
- .Table]: `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
- [DataModels.ApiKind
- .Graph]: `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
+ [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", () => {
@@ -44,7 +50,6 @@ describe("ConnectionStringParser", () => {
"documents.azure.com",
"sql.cosmosdb.azure.com",
"sql.cosmos.azure.com",
- "sqlx.cosmosdb.azure.com",
"sqlx.cosmos.azure.com",
"documents-staging.windows-ppe.net",
"sql.cosmosdb.windows-ppe.net",
@@ -67,17 +72,6 @@ describe("ConnectionStringParser", () => {
]);
});
- it("should parse a valid sql account connection string", () => {
- const metadata = parseConnectionString(
- `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
- );
-
- 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();
- });
-
it("should keep the document endpoint given by the connection string", () => {
// The endpoint is taken from the connection string rather than rebuilt from the account name, so a
// string that omits the port keeps it omitted.
@@ -91,9 +85,7 @@ describe("ConnectionStringParser", () => {
it.each(configContext.SQL_DNS_ZONES)(
"should parse a sql account connection string using the %s zone",
(dnsZone: string) => {
- const metadata = parseConnectionString(
- `AccountEndpoint=https://${mockAccountName}.${dnsZone}:443/;AccountKey=${mockMasterKey};`,
- );
+ const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
@@ -102,21 +94,10 @@ describe("ConnectionStringParser", () => {
},
);
- it("should parse a valid mongo account connection string", () => {
- const metadata = parseConnectionString(
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
- );
-
- expect(metadata.accountName).toBe(mockAccountName);
- expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
- });
-
it.each(configContext.MONGO_DNS_ZONES)(
"should parse a mongo account connection string using the %s zone",
(dnsZone: string) => {
- const metadata = parseConnectionString(
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${dnsZone}:10255`,
- );
+ const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
@@ -126,41 +107,31 @@ describe("ConnectionStringParser", () => {
it.each(configContext.MONGO_COMPUTE_DNS_ZONES)(
"should parse a compute mongo account connection string using the %s zone",
(dnsZone: string) => {
- const metadata = parseConnectionString(
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${dnsZone}:10255`,
- );
+ const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
},
);
- 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(
- ["AccountEndpoint", "HostName"].flatMap((key) =>
- configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [key, dnsZone]),
+ ["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", (key: string, dnsZone: string) => {
- const metadata = parseConnectionString(`${key}=${mockAccountName}.${dnsZone};AccountKey=${mockMasterKey};`);
+ )(
+ "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);
- });
+ 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(
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${dnsZone}:443/;`,
- );
+ const metadata = parseConnectionString(buildConnectionString.table(dnsZone));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
@@ -168,32 +139,42 @@ describe("ConnectionStringParser", () => {
},
);
- it("should construct the document endpoint for a table account from the account name", () => {
- const metadata = parseConnectionString(
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
- );
+ // 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 = {
+ "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,
+ };
- // Table connection strings only carry the table endpoint, so the document endpoint that data plane
- // operations go through is built from the account name.
- expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
- });
+ 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);
- it.each(["table.cosmosdb.windows-ppe.net", "table.cosmos.windows-ppe.net"])(
- "should construct a PPE document endpoint for a table account in the %s zone",
- (dnsZone: string) => {
- const metadata = parseConnectionString(
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${dnsZone}:443/;`,
- );
-
- // The constructed endpoint has to match the kind of zone the table endpoint we matched came from.
- expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
+ 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);
@@ -202,9 +183,7 @@ describe("ConnectionStringParser", () => {
});
it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
- const metadata = parseConnectionString(
- `AccountEndpoint=https://${mockAccountName}.documents-staging.windows-ppe.net:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
- );
+ const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
@@ -218,9 +197,7 @@ describe("ConnectionStringParser", () => {
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
try {
- const metadata = parseConnectionString(
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.windows-ppe.net:443/;`,
- );
+ 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.
@@ -231,11 +208,11 @@ describe("ConnectionStringParser", () => {
});
it.each([
- `AccountEndpoint=https://${mockAccountName}.documents.azure.com.attacker.example:443/;AccountKey=${mockMasterKey};`,
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com.attacker.example:10255`,
- `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com.attacker.example:10255`,
- `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com.attacker.example;AccountKey=${mockMasterKey};`,
- `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com.attacker.example:443/;`,
+ 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.
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
index 0e3dd2bdf..f4a70f031 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
@@ -4,7 +4,8 @@ import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
const PpeDnsSuffix = "windows-ppe.net";
const DnsPort = "443";
-const isPpeZone = (zone: string): boolean => zone.endsWith(PpeDnsSuffix);
+// 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. Returns undefined when the
@@ -16,9 +17,10 @@ export const selectEndpointZone = (zones: ReadonlyArray, isPpeAccount: b
// 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.
+// 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 =>
- `(${zones.map((zone) => zone.replace(/\./g, "\\.")).join("|")})(?=[:/\\s]|$)`;
+ `(${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.
@@ -55,15 +57,19 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDB;
+ 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;
+ isPpeAccount = isPpeZone(matches[3]);
} else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
endpointsRegex.cassandra.forEach((regex) => {
- if (RegExp(regex).test(connectionStringPart)) {
- accessInput.accountName = connectionStringPart.match(regex)[1];
+ const matches: string[] = connectionStringPart.match(regex);
+ if (matches) {
+ accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Cassandra;
+ isPpeAccount = isPpeZone(matches[2]);
}
});
} else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
@@ -80,11 +86,17 @@ 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) {
+ 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;
From b2d40ad9ce95b799ddd4815292abe0016bacedbb Mon Sep 17 00:00:00 2001
From: Asier Isayas
Date: Wed, 26 Aug 2026 15:31:55 -0400
Subject: [PATCH 6/9] Mock web-vitals globally in test setup
ScenarioMonitor subscribes to web-vitals when it is imported, so the onTTFB timer can throw during any suite that outlives it. Mocking it in setupTests.ts keeps that out of individual test files.
---
src/Platform/Hosted/Components/ConnectExplorer.test.tsx | 6 +-----
src/Platform/Hosted/Helpers/ConnectionStringParser.ts | 4 +---
src/setupTests.ts | 3 +++
3 files changed, 5 insertions(+), 8 deletions(-)
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
index c37f6e841..9a6a7bcab 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
@@ -1,8 +1,5 @@
jest.mock("../../../hooks/useDirectories");
jest.mock("../../../Common/PortalBackendClient");
-// ScenarioMonitor subscribes to web-vitals on import, and onTTFB reads a navigation timing entry that
-// jsdom never produces, throwing from a timer that lands on whichever test happens to be running.
-jest.mock("web-vitals");
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { extractFeatures } from "Platform/Hosted/extractFeatures";
@@ -16,8 +13,7 @@ const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as j
typeof isAccountRestrictedForConnectionStringLogin
>;
-// fetchEncryptedToken rejects with the raw Response. jsdom implements no part of the fetch API, so
-// there is no Response constructor to build one with.
+// fetchEncryptedToken rejects with the raw Response.
const rejectWithResponse = (status: number, body: string) =>
mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response);
diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
index f4a70f031..9730052ea 100644
--- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
+++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts
@@ -8,9 +8,7 @@ const DnsPort = "443";
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. Returns undefined when the
-// config carries no zone of that kind, so the caller can reject the connection string rather than build
-// an endpoint the account does not own and send the account key there.
+// account's endpoints sit under PPE zones and every other account's do not.
export const selectEndpointZone = (zones: ReadonlyArray, isPpeAccount: boolean): string | undefined =>
zones.find((zone) => isPpeZone(zone) === isPpeAccount);
diff --git a/src/setupTests.ts b/src/setupTests.ts
index 720a385d8..9d2e6dfb3 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -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();
From f8cfb33618d85aa4d1f91913d3d5f00e3e0fdfd4 Mon Sep 17 00:00:00 2001
From: jawelton74 <103591340+jawelton74@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:42:14 -0700
Subject: [PATCH 7/9] Remove Phoenix and notebooks (Phase 5) (#2561)
* Remove Phoenix and notebook core
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e4f2ebd-f2f8-4c2b-b63d-40e1a2b1c662
* Fix merge issue.
---------
Copilot-Session: 4e4f2ebd-f2f8-4c2b-b63d-40e1a2b1c662
---
docs/remove-notebooks-plan.md | 31 +-
package-lock.json | 533 +-----------------
package.json | 6 +-
src/Explorer/ContextMenuButtonFactory.tsx | 13 +-
.../SettingsComponent.test.tsx.snap | 60 --
src/Explorer/Explorer.test.tsx | 30 +-
src/Explorer/Explorer.tsx | 225 +-------
.../CommandBar/CommandBarComponentAdapter.tsx | 26 +-
.../CommandBarComponentButtonFactory.test.ts | 20 +-
.../CommandBarComponentButtonFactory.tsx | 29 +-
.../Menus/CommandBar/CommandBarUtil.tsx | 17 -
.../CommandBar/ConnectionStatusComponent.less | 184 ------
.../CommandBar/ConnectionStatusComponent.tsx | 186 ------
.../CommandBar/MemoryTrackerComponent.less | 24 -
.../CommandBar/MemoryTrackerComponent.tsx | 29 -
.../Notebook/NotebookContainerClient.ts | 195 -------
src/Explorer/Notebook/NotebookContentItem.ts | 14 -
src/Explorer/Notebook/NotebookManager.tsx | 32 --
src/Explorer/Notebook/NotebookUtil.test.ts | 42 --
src/Explorer/Notebook/NotebookUtil.ts | 81 ---
src/Explorer/Notebook/useNotebook.ts | 248 --------
...AddGlobalSecondaryIndexPanel.test.tsx.snap | 10 -
src/Explorer/SplashScreen/SplashScreen.tsx | 7 -
.../DocumentsTabV2/DocumentsTabV2.test.tsx | 9 -
.../DocumentsTabV2Mongo.test.tsx | 9 -
src/Explorer/Tree/Collection.ts | 1 -
src/Explorer/Tree/Database.test.tsx | 9 -
src/Explorer/Tree/ResourceTree.tsx | 31 +-
src/Explorer/Tree/ResourceTreeAdapter.tsx | 148 -----
.../__snapshots__/treeNodeUtil.test.ts.snap | 2 +-
src/Explorer/Tree/treeNodeUtil.test.ts | 56 +-
src/Explorer/Tree/treeNodeUtil.tsx | 9 +-
src/Explorer/useSelectedNode.ts | 6 -
src/Main.tsx | 2 -
src/Phoenix/PhoenixClient.ts | 256 ---------
src/Utils/NotebookConfigurationUtils.ts | 87 ---
src/hooks/useKnockoutExplorer.ts | 15 -
src/hooks/useNotebookSnapshotStore.ts | 15 -
src/hooks/useTabs.ts | 29 -
tsconfig.strict.json | 4 +-
40 files changed, 122 insertions(+), 2608 deletions(-)
delete mode 100644 src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less
delete mode 100644 src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx
delete mode 100644 src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less
delete mode 100644 src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx
delete mode 100644 src/Explorer/Notebook/NotebookContainerClient.ts
delete mode 100644 src/Explorer/Notebook/NotebookContentItem.ts
delete mode 100644 src/Explorer/Notebook/NotebookManager.tsx
delete mode 100644 src/Explorer/Notebook/NotebookUtil.test.ts
delete mode 100644 src/Explorer/Notebook/NotebookUtil.ts
delete mode 100644 src/Explorer/Notebook/useNotebook.ts
delete mode 100644 src/Phoenix/PhoenixClient.ts
delete mode 100644 src/Utils/NotebookConfigurationUtils.ts
delete mode 100644 src/hooks/useNotebookSnapshotStore.ts
diff --git a/docs/remove-notebooks-plan.md b/docs/remove-notebooks-plan.md
index 6070eaf3e..6ff123c93 100644
--- a/docs/remove-notebooks-plan.md
+++ b/docs/remove-notebooks-plan.md
@@ -7,14 +7,14 @@ the **Phoenix** compute-container service and the **Juno** service, plus a **Git
integration used to pin/browse notebook repositories. This functionality is being
retired. The goal is to remove all notebooks/Phoenix/Juno/GitHub-for-notebooks code,
dependencies, UI surfaces, telemetry, and configuration, while **preserving the
-database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo), which today
+database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo / Cosmos DB NoSQL), which today
share the Terminal infrastructure with notebooks.
This document is the implementation plan only. No code changes are made here.
## Scope decisions (confirmed)
-- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo shells, but migrate
+- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo/Cosmos DB NoSQL shells, but migrate
them to use the **CloudShell** path exclusively. Remove the legacy Phoenix
notebook-server shell path.
- **GitHub integration**: Remove entirely (it exists only for notebook pinned repos).
@@ -108,7 +108,7 @@ This is a continuation of an in-progress removal effort. Reference commits:
Each phase is independently buildable and shippable. Within each phase, **all
references to removed code are also removed** so the tree compiles. After every phase
run: `npm run compile`, `npm run compile:strict`, `npm run lint`, `npm run format:check`,
-`npm test`, and a webpack build (`npm run build:ci`); manually verify the four shells
+`npm test`, and a webpack build (`npm run build:ci`); manually verify the five shells
still open.
### Phase 1 — Decouple database shells to CloudShell-only ✅ COMPLETED
@@ -235,7 +235,28 @@ engine deleted there. No separate work remains for this phase.
pinned-repo methods.
- Remove GitHub-related localization keys from **all** locale files (`en` + non-English).
-### Phase 5 — Remove Phoenix and the notebook container/allocation core
+### Phase 5 — Remove Phoenix and the notebook container/allocation core ✅ COMPLETED
+> **Status:** Implemented on branch `users/jawelton/remove-notebooks-phase5-081426`.
+> The automated verification sweep is green: `compile`, `compile:strict`, `lint`,
+> `format:check`, `test` (1931 passing, 5 skipped), and `build:ci` (webpack) all pass.
+>
+> **Implementation notes / deviations:**
+> - Removed the Phoenix-only connection status and memory tracker command-bar UI, which
+> depended entirely on the deleted `useNotebook` container state.
+> - Moved the unrelated `isSynapseLinkUpdating` flag from `useNotebook` into the existing
+> `useCommandBar` Zustand store and made static command creation react to it.
+> - Finished the deferred Phase 1 cleanup by replacing remaining mixed
+> `isShellEnabled`/notebook availability checks with the `enableCloudShell` feature gate.
+> The non-Phoenix Mongo fallback remains available when CloudShell is disabled.
+> - Removed the remaining direct nteract/runtime dependencies with no surviving importers:
+> `@nteract/commutable`, `@nteract/core`, `@nteract/fixtures`, `@nteract/myths`, and
+> `rx-jupyter`. Added `@types/uuid` explicitly because surviving CloudShell/UserContext
+> code had previously received UUID declarations through the removed transitive graph.
+> - Preserved the newer Cosmos DB (NoSQL) Cloud Shell added after this roadmap was written;
+> all terminal tabs still instantiate `CloudShellTerminalComponentAdapter`.
+> - Residual notebook/Phoenix config, contracts, telemetry, localization, Juno, generated
+> ARM clients, and images remain intentionally deferred to Phase 6.
+
- Delete `src/Phoenix/`, `src/Explorer/Notebook/NotebookContainerClient.ts`,
`src/Explorer/Notebook/NotebookManager.tsx`, `src/Explorer/Notebook/useNotebook.ts`,
`src/Explorer/Notebook/NotebookContentItem.ts`, `src/Explorer/Notebook/NotebookUtil.ts`
@@ -279,7 +300,7 @@ npm run format:check
npm test
npm run build:ci
```
-Plus manual smoke test: open Mongo, Cassandra, Postgres, and VCoreMongo shells.
+Plus manual smoke test: open Mongo, Cassandra, Postgres, VCoreMongo, and Cosmos DB NoSQL shells.
## Notes & considerations
diff --git a/package-lock.json b/package-lock.json
index 099acec8d..66ca2a646 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,11 +19,7 @@
"@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2",
"@microsoft/applicationinsights-web": "2.6.1",
- "@nteract/commutable": "7.5.1",
- "@nteract/core": "15.1.9",
- "@nteract/fixtures": "2.3.0",
"@nteract/markdown": "4.6.0",
- "@nteract/myths": "0.1.9",
"@octokit/request": "8.4.1",
"@octokit/rest": "17.9.2",
"@testing-library/jest-dom": "6.4.6",
@@ -92,7 +88,6 @@
"react-window": "1.8.10",
"react-youtube": "9.0.1",
"reflect-metadata": "0.1.13",
- "rx-jupyter": "5.5.12",
"shell-quote": "1.10.0",
"styled-components": "5.0.1",
"swr": "0.4.0",
@@ -139,6 +134,7 @@
"@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32",
"@types/underscore": "1.7.36",
+ "@types/uuid": "9.0.8",
"@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4",
@@ -6396,154 +6392,6 @@
"node": ">= 8"
}
},
- "node_modules/@nteract/actions": {
- "version": "7.1.0",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "@nteract/messaging": "^7.0.19",
- "@nteract/types": "^7.1.9",
- "immutable": "^4.0.0-rc.12",
- "rx-jupyter": "^5.5.21"
- }
- },
- "node_modules/@nteract/actions/node_modules/rx-jupyter": {
- "version": "5.5.21",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "@nteract/messaging": "^7.0.19",
- "@types/ungap__url-search-params": "^0.1.0",
- "@ungap/url-search-params": "^0.2.0",
- "js-cookie": "^2.2.0",
- "rxjs": "^6.6.0",
- "url-join": "^4.0.0"
- }
- },
- "node_modules/@nteract/commutable": {
- "version": "7.5.1",
- "license": "BSD-3-Clause",
- "dependencies": {
- "immutable": "^4.0.0-rc.12",
- "uuid": "^8.0.0"
- }
- },
- "node_modules/@nteract/commutable/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/@nteract/core": {
- "version": "15.1.9",
- "resolved": "https://registry.npmjs.org/@nteract/core/-/core-15.1.9.tgz",
- "integrity": "sha512-kQf+7d1Qi27aVOvu+q07pXw8dXFUsU7JJLZ3JeAzSxVizNE/PjbEPn9Ruc+AV/I/FqXZovGp7Q04AGr25hsuyA==",
- "dependencies": {
- "@nteract/actions": "^7.0.11",
- "@nteract/commutable": "^7.4.5",
- "@nteract/epics": "^5.0.11",
- "@nteract/reducers": "^5.1.9",
- "@nteract/selectors": "^3.1.9",
- "@nteract/types": "^7.1.9",
- "redux-logger": "^3.0.6"
- },
- "peerDependencies": {
- "immutable": "^4.0.0-rc.12"
- }
- },
- "node_modules/@nteract/epics": {
- "version": "5.1.0",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/actions": "^7.0.11",
- "@nteract/commutable": "^7.4.5",
- "@nteract/messaging": "^7.0.19",
- "@nteract/mythic-configuration": "^1.0.11",
- "@nteract/mythic-notifications": "^0.2.11",
- "@nteract/selectors": "^3.1.9",
- "@nteract/types": "^7.1.9",
- "file-saver": "^2.0.0",
- "redux": "^4.0.1",
- "redux-observable": "^2.0.0-alpha.0",
- "rx-jupyter": "^5.5.21",
- "rxjs": "^6.3.3"
- }
- },
- "node_modules/@nteract/epics/node_modules/rx-jupyter": {
- "version": "5.5.21",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "@nteract/messaging": "^7.0.19",
- "@types/ungap__url-search-params": "^0.1.0",
- "@ungap/url-search-params": "^0.2.0",
- "js-cookie": "^2.2.0",
- "rxjs": "^6.6.0",
- "url-join": "^4.0.0"
- }
- },
- "node_modules/@nteract/fixtures": {
- "version": "2.3.0",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.2.6",
- "@nteract/reducers": "^3.1.0",
- "@nteract/types": "^6.0.0"
- }
- },
- "node_modules/@nteract/fixtures/node_modules/@nteract/actions": {
- "version": "5.0.1",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.2.11",
- "@nteract/messaging": "^7.0.6",
- "@nteract/types": "^6.0.6",
- "immutable": "^4.0.0-rc.12",
- "rx-jupyter": "^5.5.8"
- }
- },
- "node_modules/@nteract/fixtures/node_modules/@nteract/reducers": {
- "version": "3.2.1",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/actions": "^5.0.1",
- "@nteract/commutable": "^7.2.11",
- "@nteract/types": "^6.0.6",
- "escape-carriage": "^1.3.0",
- "immutable": "^4.0.0-rc.12",
- "lodash.has": "^4.5.2",
- "redux": "^4.0.1",
- "redux-immutable": "^4.0.0",
- "uuid": "^7.0.0"
- }
- },
- "node_modules/@nteract/fixtures/node_modules/@nteract/reducers/node_modules/uuid": {
- "version": "7.0.3",
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/@nteract/fixtures/node_modules/@nteract/types": {
- "version": "6.0.7",
- "license": "MIT",
- "dependencies": {
- "@nteract/commutable": "^7.2.12",
- "immutable": "^4.0.0-rc.12",
- "rxjs": "^6.3.3",
- "uuid": "^8.0.0"
- }
- },
- "node_modules/@nteract/fixtures/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/@nteract/markdown": {
"version": "4.6.0",
"license": "BSD-3-Clause",
@@ -6568,184 +6416,6 @@
"react-dom": "^16.13.0 || ^17.0.0"
}
},
- "node_modules/@nteract/messaging": {
- "version": "7.0.20",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/types": "^7.1.9",
- "@types/uuid": "^8.0.0",
- "lodash.clonedeep": "^4.5.0",
- "rxjs": "^6.6.0",
- "uuid": "^8.0.0"
- }
- },
- "node_modules/@nteract/messaging/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/@nteract/mythic-configuration": {
- "version": "1.0.12",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/myths": "^0.2.11",
- "fs-observable": "^4.1.14"
- }
- },
- "node_modules/@nteract/mythic-configuration/node_modules/@nteract/myths": {
- "version": "0.2.13",
- "license": "BSD-3-Clause",
- "dependencies": {
- "redux": "^4.0.0",
- "redux-observable": "^2.0.0-alpha.0",
- "rxjs": "^6.3.3"
- },
- "peerDependencies": {
- "immutable": "^4.0.0-rc.12",
- "react": "^16.3.2",
- "react-dom": "^16.3.2",
- "react-redux": "^7.2.0"
- }
- },
- "node_modules/@nteract/mythic-configuration/node_modules/react-is": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
- "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@nteract/mythic-configuration/node_modules/react-redux": {
- "version": "7.2.9",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
- "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/runtime": "^7.15.4",
- "@types/react-redux": "^7.1.20",
- "hoist-non-react-statics": "^3.3.2",
- "loose-envify": "^1.4.0",
- "prop-types": "^15.7.2",
- "react-is": "^17.0.2"
- },
- "peerDependencies": {
- "react": "^16.8.3 || ^17 || ^18"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- },
- "react-native": {
- "optional": true
- }
- }
- },
- "node_modules/@nteract/mythic-notifications": {
- "version": "0.2.11",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@blueprintjs/core": "^3.7.0",
- "@nteract/myths": "^0.2.11"
- },
- "peerDependencies": {
- "immutable": "^4.0.0-rc.12",
- "react": "^16.3.2",
- "react-dom": "^16.3.2",
- "styled-components": ">= 5.0.1"
- }
- },
- "node_modules/@nteract/mythic-notifications/node_modules/@nteract/myths": {
- "version": "0.2.13",
- "license": "BSD-3-Clause",
- "dependencies": {
- "redux": "^4.0.0",
- "redux-observable": "^2.0.0-alpha.0",
- "rxjs": "^6.3.3"
- },
- "peerDependencies": {
- "immutable": "^4.0.0-rc.12",
- "react": "^16.3.2",
- "react-dom": "^16.3.2",
- "react-redux": "^7.2.0"
- }
- },
- "node_modules/@nteract/mythic-notifications/node_modules/react-is": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
- "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@nteract/mythic-notifications/node_modules/react-redux": {
- "version": "7.2.9",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
- "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/runtime": "^7.15.4",
- "@types/react-redux": "^7.1.20",
- "hoist-non-react-statics": "^3.3.2",
- "loose-envify": "^1.4.0",
- "prop-types": "^15.7.2",
- "react-is": "^17.0.2"
- },
- "peerDependencies": {
- "react": "^16.8.3 || ^17 || ^18"
- },
- "peerDependenciesMeta": {
- "react-dom": {
- "optional": true
- },
- "react-native": {
- "optional": true
- }
- }
- },
- "node_modules/@nteract/myths": {
- "version": "0.1.9",
- "resolved": "https://registry.npmjs.org/@nteract/myths/-/myths-0.1.9.tgz",
- "integrity": "sha512-aPLUamd0kTnzy+8usMsGs+kgMfxfx2QNvDuxd5+ko+9DKuyMJx8quNuqphlxaLXNGiJrnfNi5krXaAyNEil3Sw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "react-redux": "^6.0.0",
- "redux": "^4.0.0",
- "redux-observable": "^2.0.0-alpha.0",
- "rxjs": "^6.3.3"
- },
- "peerDependencies": {
- "immutable": "^4.0.0-rc.12",
- "react": "^16.3.2",
- "react-dom": "^16.3.2"
- }
- },
- "node_modules/@nteract/myths/node_modules/react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "license": "MIT"
- },
- "node_modules/@nteract/myths/node_modules/react-redux": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-6.0.1.tgz",
- "integrity": "sha512-T52I52Kxhbqy/6TEfBv85rQSDz6+Y28V/pf52vDWs1YRXG19mcFOGfHnY2HsNFHyhP+ST34Aih98fvt6tqwVcQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.3.1",
- "hoist-non-react-statics": "^3.3.0",
- "invariant": "^2.2.4",
- "loose-envify": "^1.4.0",
- "prop-types": "^15.7.2",
- "react-is": "^16.8.2"
- },
- "peerDependencies": {
- "react": "^16.4.0-0",
- "redux": "^2.0.0 || ^3.0.0 || ^4.0.0-0"
- }
- },
"node_modules/@nteract/presentational-components": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/@nteract/presentational-components/-/presentational-components-3.4.12.tgz",
@@ -6763,71 +6433,6 @@
"styled-components": ">= 5.0.1"
}
},
- "node_modules/@nteract/reducers": {
- "version": "5.1.13",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/actions": "^7.0.11",
- "@nteract/commutable": "^7.4.5",
- "@nteract/types": "^7.1.9",
- "escape-carriage": "^1.3.0",
- "immutable": "^4.0.0-rc.12",
- "lodash.has": "^4.5.2",
- "redux": "^4.0.1",
- "redux-immutable": "^4.0.0",
- "uuid": "^8.0.0"
- }
- },
- "node_modules/@nteract/reducers/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/@nteract/selectors": {
- "version": "3.2.0",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "@nteract/types": "^7.1.9",
- "immutable": "^4.0.0-rc.12",
- "reselect": "^4.0.0",
- "rx-jupyter": "^5.5.21"
- }
- },
- "node_modules/@nteract/selectors/node_modules/rx-jupyter": {
- "version": "5.5.21",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "@nteract/messaging": "^7.0.19",
- "@types/ungap__url-search-params": "^0.1.0",
- "@ungap/url-search-params": "^0.2.0",
- "js-cookie": "^2.2.0",
- "rxjs": "^6.6.0",
- "url-join": "^4.0.0"
- }
- },
- "node_modules/@nteract/types": {
- "version": "7.1.9",
- "license": "MIT",
- "dependencies": {
- "@nteract/commutable": "^7.4.5",
- "immutable": "^4.0.0-rc.12",
- "rxjs": "^6.6.0",
- "uuid": "^8.0.0"
- }
- },
- "node_modules/@nteract/types/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/@octokit/auth-token": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz",
@@ -8364,6 +7969,7 @@
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.5",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
@@ -8555,6 +8161,7 @@
},
"node_modules/@types/react-redux": {
"version": "7.1.7",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.0",
@@ -8678,16 +8285,15 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/ungap__url-search-params": {
- "version": "0.1.2",
- "license": "MIT"
- },
"node_modules/@types/unist": {
"version": "2.0.10",
"license": "MIT"
},
"node_modules/@types/uuid": {
- "version": "8.3.4",
+ "version": "9.0.8",
+ "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/uuid/-/uuid-9.0.8.tgz",
+ "integrity": "sha1-dUW6T8PAA9bHVvZR878WPY8PKbo=",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
@@ -9001,10 +8607,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
- "node_modules/@ungap/url-search-params": {
- "version": "0.2.2",
- "license": "ISC"
- },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -12093,6 +11695,7 @@
},
"node_modules/debounce": {
"version": "1.2.1",
+ "dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -12148,10 +11751,6 @@
}
}
},
- "node_modules/deep-diff": {
- "version": "0.3.8",
- "license": "MIT"
- },
"node_modules/deep-equal": {
"version": "1.1.2",
"license": "MIT",
@@ -13012,10 +12611,6 @@
"node": ">=6"
}
},
- "node_modules/escape-carriage": {
- "version": "1.3.1",
- "license": "MIT"
- },
"node_modules/escape-html": {
"version": "1.0.3",
"dev": true,
@@ -14021,10 +13616,6 @@
"node": "^10.12.0 || >=12.0.0"
}
},
- "node_modules/file-saver": {
- "version": "2.0.5",
- "license": "MIT"
- },
"node_modules/filesize": {
"version": "8.0.7",
"dev": true,
@@ -14033,13 +13624,6 @@
"node": ">= 0.4.0"
}
},
- "node_modules/filewatcher": {
- "version": "3.0.1",
- "license": "MIT",
- "dependencies": {
- "debounce": "^1.0.0"
- }
- },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -14489,27 +14073,6 @@
"dev": true,
"license": "Unlicense"
},
- "node_modules/fs-observable": {
- "version": "4.1.14",
- "license": "BSD-3-Clause",
- "dependencies": {
- "filewatcher": "^3.0.1",
- "mkdirp": "^0.5.1"
- },
- "peerDependencies": {
- "rxjs": "^6.3.3"
- }
- },
- "node_modules/fs-observable/node_modules/mkdirp": {
- "version": "0.5.6",
- "license": "MIT",
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
"node_modules/fs.realpath": {
"version": "1.0.0",
"license": "ISC"
@@ -14518,6 +14081,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -20659,10 +20223,6 @@
"jquery": ">=1.8.0 <4.0.0"
}
},
- "node_modules/js-cookie": {
- "version": "2.2.1",
- "license": "MIT"
- },
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@@ -21090,10 +20650,6 @@
"version": "4.0.3",
"license": "MIT"
},
- "node_modules/lodash.clonedeep": {
- "version": "4.5.0",
- "license": "MIT"
- },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"license": "MIT"
@@ -21108,10 +20664,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.has": {
- "version": "4.5.2",
- "license": "MIT"
- },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -23757,42 +23309,6 @@
"@babel/runtime": "^7.9.2"
}
},
- "node_modules/redux-immutable": {
- "version": "4.0.0",
- "license": "BSD-3-Clause",
- "peerDependencies": {
- "immutable": "^3.8.1 || ^4.0.0-rc.1"
- }
- },
- "node_modules/redux-logger": {
- "version": "3.0.6",
- "license": "MIT",
- "dependencies": {
- "deep-diff": "^0.3.5"
- }
- },
- "node_modules/redux-observable": {
- "version": "2.0.0",
- "license": "MIT",
- "dependencies": {
- "rxjs": "^7.0.0",
- "tslib": "~2.1.0"
- },
- "peerDependencies": {
- "redux": ">=4 <5"
- }
- },
- "node_modules/redux-observable/node_modules/rxjs": {
- "version": "7.8.1",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.1.0"
- }
- },
- "node_modules/redux-observable/node_modules/tslib": {
- "version": "2.1.0",
- "license": "0BSD"
- },
"node_modules/reflect-metadata": {
"version": "0.1.13",
"license": "Apache-2.0"
@@ -24087,10 +23603,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/reselect": {
- "version": "4.1.8",
- "license": "MIT"
- },
"node_modules/resolve": {
"version": "1.22.8",
"devOptional": true,
@@ -24233,29 +23745,6 @@
"version": "1.3.3",
"license": "BSD-3-Clause"
},
- "node_modules/rx-jupyter": {
- "version": "5.5.12",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@nteract/commutable": "^7.3.2",
- "@nteract/messaging": "^7.0.10",
- "@types/ungap__url-search-params": "^0.1.0",
- "@ungap/url-search-params": "^0.2.0",
- "js-cookie": "^2.2.0",
- "rxjs": "^6.6.0",
- "url-join": "^4.0.0"
- }
- },
- "node_modules/rxjs": {
- "version": "6.6.7",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^1.9.0"
- },
- "engines": {
- "npm": ">=2.0.0"
- }
- },
"node_modules/safe-array-concat": {
"version": "1.1.2",
"license": "MIT",
@@ -26069,10 +25558,6 @@
"punycode": "^2.1.0"
}
},
- "node_modules/url-join": {
- "version": "4.0.1",
- "license": "MIT"
- },
"node_modules/url-loader": {
"version": "4.1.1",
"dev": true,
diff --git a/package.json b/package.json
index a58c864bd..0bffefbbc 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,7 @@
"@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2",
"@microsoft/applicationinsights-web": "2.6.1",
- "@nteract/commutable": "7.5.1",
- "@nteract/core": "15.1.9",
- "@nteract/fixtures": "2.3.0",
"@nteract/markdown": "4.6.0",
- "@nteract/myths": "0.1.9",
"@octokit/request": "8.4.1",
"@octokit/rest": "17.9.2",
"@testing-library/jest-dom": "6.4.6",
@@ -87,7 +83,6 @@
"react-window": "1.8.10",
"react-youtube": "9.0.1",
"reflect-metadata": "0.1.13",
- "rx-jupyter": "5.5.12",
"shell-quote": "1.10.0",
"styled-components": "5.0.1",
"swr": "0.4.0",
@@ -149,6 +144,7 @@
"@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32",
"@types/underscore": "1.7.36",
+ "@types/uuid": "9.0.8",
"@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4",
diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx
index 59c49e5ac..f1e85d6cd 100644
--- a/src/Explorer/ContextMenuButtonFactory.tsx
+++ b/src/Explorer/ContextMenuButtonFactory.tsx
@@ -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: () => {
diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap
index 41e126a53..dd5d3bce8 100644
--- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap
@@ -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],
diff --git a/src/Explorer/Explorer.test.tsx b/src/Explorer/Explorer.test.tsx
index 5a4e89b94..dbb7bc118 100644
--- a/src/Explorer/Explorer.test.tsx
+++ b/src/Explorer/Explorer.test.tsx
@@ -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;
@@ -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>;
+ });
+ explorer.openEnableSynapseLinkDialog();
+
+ const dialogProps = mockOpenDialog.mock.calls[0][0];
+ await dialogProps.onPrimaryButtonClick();
+
+ expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(false);
+ });
});
describe("with targetAccountOverride", () => {
diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx
index bf1c5a33e..bc7df461d 100644
--- a/src/Explorer/Explorer.tsx
+++ b/src/Explorer/Explorer.tsx
@@ -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;
- // 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 {
- 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 {
- 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 {
- 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,
- connectionStatus: DataModels.ContainerConnectionInfo,
- ): Promise {
- 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 => {
- // 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", );
}
- public getDownloadModalContent(fileName: string): JSX.Element {
- if (useNotebook.getState().isPhoenixNotebooks) {
- return (
- <>
- {Notebook.galleryNotebookDownloadContent1}
-
-
- {Notebook.galleryNotebookDownloadContent2}
-
- {Notebook.learnMore}
-
-
- >
- );
- }
- return Download {fileName} from gallery as a copy to your notebooks to run and/or edit the notebook.
;
- }
-
public async refreshExplorer(): Promise {
// 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 });
diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx
index 4d2326f3f..17b89d650 100644
--- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx
+++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx
@@ -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 = create((set) => ({
@@ -35,6 +35,8 @@ export const useCommandBar: UseStore = 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 = ({ 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 = ({ 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)",
diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts
index c0d97bb02..0008f99a3 100644
--- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts
+++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts
@@ -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();
diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx
index ada89d807..c174c8f3a 100644
--- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx
+++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx
@@ -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);
}
},
diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx
index 40628db28..b298be795 100644
--- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx
+++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx
@@ -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: () => ,
- };
-};
-
-export const createConnectionStatus = (container: Explorer, key: string): ICommandBarItemProps => {
- return {
- key,
- onRender: () => ,
- };
-};
-
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {};
diff --git a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less
deleted file mode 100644
index 0688e0baa..000000000
--- a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less
+++ /dev/null
@@ -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);
- }
- }
\ No newline at end of file
diff --git a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx
deleted file mode 100644
index 60499661f..000000000
--- a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx
+++ /dev/null
@@ -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 = ({ 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(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 (
- container.allocateContainer()}>
-
-
-
- {connectionInfo.status}
-
-
-
- );
- }
-
- 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 (
- <>
-
- ) =>
- connectionInfo.status === ConnectionStatusType.Failed ? container.allocateContainer() : e.preventDefault()
- }
- >
-
-
-
- {connectionInfo.status}
-
- {connectionInfo.status === ConnectionStatusType.Connecting && isActive && (
-
- )}
- {connectionInfo.status === ConnectionStatusType.Connected && !isActive && (
- 0.8 ? "lowMemory" : ""}
- description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
- percentComplete={totalGB !== 0 ? usedGB / totalGB : 0}
- />
- )}
-
- {!isBarDismissed &&
- containerInfo.status &&
- containerInfo.status === ContainerStatusType.Active &&
- Math.round(containerInfo.durationLeftInMinutes) <= Notebook.remainingTimeForAlert ? (
- setIsBarDismissed(true)}
- setInitialFocus
- >
-
- Remaining Time
-
-
- 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.
-
-
-
- setIsBarDismissed(true)}>Dimiss
-
-
-
- ) : undefined}
-
-
- >
- );
-};
diff --git a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less
deleted file mode 100644
index 404025a91..000000000
--- a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less
+++ /dev/null
@@ -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;
- }
- }
-}
\ No newline at end of file
diff --git a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx
deleted file mode 100644
index ac0621897..000000000
--- a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx
+++ /dev/null
@@ -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 (
-
- Memory
-
-
- );
- }
-
- const totalGB = memoryUsageInfo.totalKB / 1048576;
- const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576;
-
- return (
-
- Memory
- 0.8 ? "lowMemory" : ""}
- description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
- percentComplete={usedGB / totalGB}
- />
-
- );
-};
diff --git a/src/Explorer/Notebook/NotebookContainerClient.ts b/src/Explorer/Notebook/NotebookContainerClient.ts
deleted file mode 100644
index 76d4ba2b8..000000000
--- a/src/Explorer/Notebook/NotebookContainerClient.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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> {
- this.isResettingWorkspace = true;
- let response: IResponse;
- try {
- response = await this._resetWorkspace();
- } catch (error) {
- Promise.reject(error);
- return response;
- }
- this.isResettingWorkspace = false;
- return response;
- }
-
- private async _resetWorkspace(): Promise> {
- 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",
- };
- }
-}
diff --git a/src/Explorer/Notebook/NotebookContentItem.ts b/src/Explorer/Notebook/NotebookContentItem.ts
deleted file mode 100644
index bb79485b8..000000000
--- a/src/Explorer/Notebook/NotebookContentItem.ts
+++ /dev/null
@@ -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,
-}
diff --git a/src/Explorer/Notebook/NotebookManager.tsx b/src/Explorer/Notebook/NotebookManager.tsx
deleted file mode 100644
index 175c76061..000000000
--- a/src/Explorer/Notebook/NotebookManager.tsx
+++ /dev/null
@@ -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),
- );
- }
-}
diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts
deleted file mode 100644
index 97ff4514a..000000000
--- a/src/Explorer/Notebook/NotebookUtil.test.ts
+++ /dev/null
@@ -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"));
- });
- });
-});
diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts
deleted file mode 100644
index d8e752f3d..000000000
--- a/src/Explorer/Notebook/NotebookUtil.ts
+++ /dev/null
@@ -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}`;
- }
-}
diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts
deleted file mode 100644
index 608358cf7..000000000
--- a/src/Explorer/Notebook/useNotebook.ts
+++ /dev/null
@@ -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;
- findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
- insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem) => void;
- updateNotebookItem: (item: NotebookContentItem) => void;
- deleteNotebookItem: (item: NotebookContentItem) => void;
- initializeNotebooksTree: () => Promise;
- setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void;
- setIsAllocating: (isAllocating: boolean) => void;
- resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void;
- setIsRefreshed: (isAllocating: boolean) => void;
- setContainerStatus: (containerStatus: ContainerInfo) => void;
- getPhoenixStatus: () => Promise;
- setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => void;
- setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => void;
-}
-
-export const useNotebook: UseStore = 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 => {
- 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 => {
- 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 }),
-}));
diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap
index 2495d4165..fd58794e0 100644
--- a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap
+++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap
@@ -145,25 +145,15 @@ exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
= ({ explorer }) => {
React.useEffect(() => {
subscriptions.push(
- {
- dispose: useNotebook.subscribe(
- () => setState({}),
- (state) => state.isNotebookEnabled,
- ),
- },
{ dispose: useSelectedNode.subscribe(() => setState({})) },
{
dispose: useCarousel.subscribe(
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
index ded16bf78..6bb014011 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
@@ -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
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx
index e0bacfa5a..7cee70afe 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx
@@ -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__";
diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts
index f78a95eac..78faf849c 100644
--- a/src/Explorer/Tree/Collection.ts
+++ b/src/Explorer/Tree/Collection.ts
@@ -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";
diff --git a/src/Explorer/Tree/Database.test.tsx b/src/Explorer/Tree/Database.test.tsx
index 9fbeb56fe..4bd8f087a 100644
--- a/src/Explorer/Tree/Database.test.tsx
+++ b/src/Explorer/Tree/Database.test.tsx
@@ -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;
diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx
index dfcf53422..8ce6e2cb0 100644
--- a/src/Explorer/Tree/ResourceTree.tsx
+++ b/src/Explorer/Tree/ResourceTree.tsx
@@ -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 = ({ explorer }: Resource
const [openItems, setOpenItems] = React.useState([]);
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 = ({ 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()
? []
diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx
index e25b76a45..f2f2af369 100644
--- a/src/Explorer/Tree/ResourceTreeAdapter.tsx
+++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx
@@ -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;
- 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 ;
}
- public async initialize(): Promise {
- const refreshTasks: Promise[] = [];
-
- 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()));
}
diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap
index ead9bdb15..53c49c24f 100644
--- a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap
+++ b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap
@@ -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": [
diff --git a/src/Explorer/Tree/treeNodeUtil.test.ts b/src/Explorer/Tree/treeNodeUtil.test.ts
index c64f9276e..f30c3e166 100644
--- a/src/Explorer/Tree/treeNodeUtil.test.ts
+++ b/src/Explorer/Tree/treeNodeUtil.test.ts
@@ -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, Partial]>([
+ it.each<[string, Platform, Partial, Partial]>([
[
"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());
diff --git a/src/Explorer/Tree/treeNodeUtil.tsx b/src/Explorer/Tree/treeNodeUtil.tsx
index 224542755..d784080cb 100644
--- a/src/Explorer/Tree/treeNodeUtil.tsx
+++ b/src/Explorer/Tree/treeNodeUtil.tsx
@@ -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[] => {
diff --git a/src/Explorer/useSelectedNode.ts b/src/Explorer/useSelectedNode.ts
index 4dd34a91e..cbade8ed9 100644
--- a/src/Explorer/useSelectedNode.ts
+++ b/src/Explorer/useSelectedNode.ts
@@ -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 = create((set, get) => ({
@@ -61,7 +58,4 @@ export const useSelectedNode: UseStore = create((set, get) =>
subnodeKinds.includes(selectedSubnodeKind)
);
},
- isConnectedToContainer: (): boolean => {
- return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected;
- },
}));
diff --git a/src/Main.tsx b/src/Main.tsx
index 6179711d6..e60a5e863 100644
--- a/src/Main.tsx
+++ b/src/Main.tsx
@@ -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";
diff --git a/src/Phoenix/PhoenixClient.ts b/src/Phoenix/PhoenixClient.ts
deleted file mode 100644
index f1d722e3b..000000000
--- a/src/Phoenix/PhoenixClient.ts
+++ /dev/null
@@ -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> {
- this.initializeCancelEventListener();
-
- return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), {
- retries: 4,
- maxTimeout: 20000,
- minTimeout: 20000,
- signal: this.abortSignal,
- });
- }
-
- public async resetContainer(provisionData: IProvisionData): Promise> {
- return this.executeContainerAssignmentOperation(provisionData, "reset");
- }
-
- private async executeContainerAssignmentOperation(
- provisionData: IProvisionData,
- operation: string,
- ): Promise> {
- 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 {
- 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;
- }
- }
- }
-}
diff --git a/src/Utils/NotebookConfigurationUtils.ts b/src/Utils/NotebookConfigurationUtils.ts
deleted file mode 100644
index 31bfccfdd..000000000
--- a/src/Utils/NotebookConfigurationUtils.ts
+++ /dev/null
@@ -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 => {
- 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 => {
- 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);
-};
diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts
index 41bec9797..8b8c63a3a 100644
--- a/src/hooks/useKnockoutExplorer.ts
+++ b/src/hooks/useKnockoutExplorer.ts
@@ -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
diff --git a/src/hooks/useNotebookSnapshotStore.ts b/src/hooks/useNotebookSnapshotStore.ts
deleted file mode 100644
index 841f6b5d1..000000000
--- a/src/hooks/useNotebookSnapshotStore.ts
+++ /dev/null
@@ -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 = create((set) => ({
- snapshot: undefined,
- error: undefined,
- setSnapshot: (imageSrc: string) => set((state) => ({ ...state, snapshot: imageSrc })),
- setError: (error: string) => set((state) => ({ ...state, error })),
-}));
diff --git a/src/hooks/useTabs.ts b/src/hooks/useTabs.ts
index f276e1498..4444f5c8c 100644
--- a/src/hooks/useTabs.ts
+++ b/src/hooks/useTabs.ts
@@ -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 = 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) => ({
diff --git a/tsconfig.strict.json b/tsconfig.strict.json
index 947b073e5..1a2ae5ece 100644
--- a/tsconfig.strict.json
+++ b/tsconfig.strict.json
@@ -44,7 +44,6 @@
"./src/Explorer/Graph/GraphExplorerComponent/GraphData.ts",
"./src/Explorer/LazyMonaco.ts",
"./src/Explorer/Menus/NotificationConsole/ConsoleData.tsx",
- "./src/Explorer/Notebook/NotebookContentItem.ts",
"./src/Explorer/OpenFullScreen.test.tsx",
"./src/Explorer/OpenFullScreen.tsx",
"./src/Explorer/Panes/PanelContainerComponent.test.tsx",
@@ -107,7 +106,6 @@
"./src/hooks/useConfig.ts",
"./src/hooks/useDirectories.tsx",
"./src/hooks/useGraphPhoto.tsx",
- "./src/hooks/useNotebookSnapshotStore.ts",
"./src/hooks/useNotificationConsole.ts",
"./src/hooks/useObservable.ts",
"./src/hooks/useSidePanel.ts",
@@ -137,4 +135,4 @@
"src/Shared/Telemetry/**/*",
"src/Utils/arm/**/*"
]
-}
+}
\ No newline at end of file
From 5fe07c25238a2c9192d26ee948f73e967cb12470 Mon Sep 17 00:00:00 2001
From: Asier Isayas
Date: Thu, 27 Aug 2026 11:03:49 -0400
Subject: [PATCH 8/9] Improve connection string login feedback
---
.../Components/ConnectExplorer.test.tsx | 57 ++++++++++++
.../Hosted/Components/ConnectExplorer.tsx | 91 ++++++++++++-------
2 files changed, 113 insertions(+), 35 deletions(-)
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
index 9a6a7bcab..bdc562820 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx
@@ -80,6 +80,63 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures });
});
+it("rejects an unrecognized connection string before token exchange", async () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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";
diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx
index d83637419..26c60e1d5 100644
--- a/src/Platform/Hosted/Components/ConnectExplorer.tsx
+++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx
@@ -32,6 +32,7 @@ export const ConnectExplorer: React.FunctionComponent = ({
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 (
@@ -45,54 +46,69 @@ export const ConnectExplorer: React.FunctionComponent = ({
{isFormVisible && enableConnectionStringLogin ? (