diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 4b1b52054..232427ccc 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -16,6 +16,7 @@ import { userContext } from "../UserContext"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { TreeNodeMenuItem } from "./Controls/TreeComponent/TreeComponent"; 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"; @@ -81,13 +82,13 @@ export const createCollectionContextMenuButton = ( iconSrc: HostedTerminalIcon, onClick: () => { const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); - if (container.isShellEnabled()) { + if (useNotebook.getState().isShellEnabled) { container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { selectedCollection && selectedCollection.onNewMongoShellClick(); } }, - label: container.isShellEnabled() ? "Open Mongo Shell" : "New Shell", + label: useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell", }); } diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 9e07428b0..457e2bb35 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -30,17 +30,9 @@ exports[`SettingsComponent renders 1`] = ` "container": Explorer { "_isInitializingNotebooks": false, "_resetNotebookWorkspace": [Function], - "isAccountReady": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], - "isNotebookEnabled": [Function], - "isNotebooksEnabledForAccount": [Function], "isSchemaEnabled": [Function], - "isShellEnabled": [Function], - "isSynapseLinkUpdating": [Function], "isTabsContentExpanded": [Function], - "memoryUsageInfo": [Function], - "notebookBasePath": [Function], - "notebookServerInfo": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], "provideFeedbackEmail": [Function], @@ -57,7 +49,6 @@ exports[`SettingsComponent renders 1`] = ` "container": [Circular], "parameters": [Function], }, - "sparkClusterConnectionInfo": [Function], "tabsManager": TabsManager { "activeTab": [Function], "openedTabs": [Function], @@ -115,17 +106,9 @@ exports[`SettingsComponent renders 1`] = ` "container": Explorer { "_isInitializingNotebooks": false, "_resetNotebookWorkspace": [Function], - "isAccountReady": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], - "isNotebookEnabled": [Function], - "isNotebooksEnabledForAccount": [Function], "isSchemaEnabled": [Function], - "isShellEnabled": [Function], - "isSynapseLinkUpdating": [Function], "isTabsContentExpanded": [Function], - "memoryUsageInfo": [Function], - "notebookBasePath": [Function], - "notebookServerInfo": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], "provideFeedbackEmail": [Function], @@ -142,7 +125,6 @@ exports[`SettingsComponent renders 1`] = ` "container": [Circular], "parameters": [Function], }, - "sparkClusterConnectionInfo": [Function], "tabsManager": TabsManager { "activeTab": [Function], "openedTabs": [Function], diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 18e43b75c..6f04d5bbd 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -30,7 +30,6 @@ import { listConnectionInfo, start, } from "../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces"; -import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; import { stringToBlob } from "../Utils/BlobUtils"; import { isCapabilityEnabled } from "../Utils/CapabilityUtils"; import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils"; @@ -46,6 +45,7 @@ import { NotebookContentItem, NotebookContentItemType } from "./Notebook/Noteboo import type NotebookManager from "./Notebook/NotebookManager"; import type { NotebookPaneContent } from "./Notebook/NotebookManager"; import { NotebookUtil } from "./Notebook/NotebookUtil"; +import { useNotebook } from "./Notebook/useNotebook"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import { AddDatabasePanel } from "./Panes/AddDatabasePanel/AddDatabasePanel"; import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane/BrowseQueriesPane"; @@ -79,7 +79,6 @@ export interface ExplorerParams { export default class Explorer { public isFixedCollectionWithSharedThroughputSupported: ko.Computed; - public isAccountReady: ko.Observable; public queriesClient: QueriesClient; public tableDataClient: TableDataClient; @@ -97,18 +96,9 @@ export default class Explorer { public isSchemaEnabled: ko.Computed; // Notebooks - public isNotebookEnabled: ko.Observable; - public isNotebooksEnabledForAccount: ko.Observable; - public notebookServerInfo: ko.Observable; - public sparkClusterConnectionInfo: ko.Observable; - public isSynapseLinkUpdating: ko.Observable; - public memoryUsageInfo: ko.Observable; public notebookManager?: NotebookManager; - public isShellEnabled: ko.Observable; - private _isInitializingNotebooks: boolean; - private notebookBasePath: ko.Observable; private notebookToImport: { name: string; content: string; @@ -120,40 +110,11 @@ export default class Explorer { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { dataExplorerArea: Constants.Areas.ResourceTree, }); - this.isAccountReady = ko.observable(false); this._isInitializingNotebooks = false; - this.isShellEnabled = ko.observable(false); - this.isNotebooksEnabledForAccount = ko.observable(false); - this.isNotebooksEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); - this.isSynapseLinkUpdating = ko.observable(false); - this.isAccountReady.subscribe(async (isAccountReady: boolean) => { - if (isAccountReady) { - userContext.authType === AuthType.ResourceToken - ? this.refreshDatabaseForResourceToken() - : this.refreshAllDatabases(true); - await this._refreshNotebooksEnabledStateForAccount(); - this.isNotebookEnabled( - userContext.authType !== AuthType.ResourceToken && - ((await this._containsDefaultNotebookWorkspace(userContext.databaseAccount)) || - userContext.features.enableNotebooks) - ); - - this.isShellEnabled(this.isNotebookEnabled() && isPublicInternetAccessAllowed()); - - TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { - isNotebookEnabled: this.isNotebookEnabled(), - dataExplorerArea: Constants.Areas.Notebook, - }); - - if (this.isNotebookEnabled()) { - await this.initNotebooks(userContext.databaseAccount); - } else if (this.notebookToImport) { - // if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane - this._openSetupNotebooksPaneForQuickstart(); - } - } - }); - this.memoryUsageInfo = ko.observable(); + useNotebook.subscribe( + () => this.refreshCommandBarButtons(), + (state) => state.isNotebooksEnabledForAccount + ); this.queriesClient = new QueriesClient(this); this.isSchemaEnabled = ko.computed(() => userContext.features.enableSchema); @@ -214,53 +175,44 @@ export default class Explorer { startKey ); - this.isNotebookEnabled = ko.observable(false); - this.isNotebookEnabled.subscribe(async () => { - if (!this.notebookManager) { - const NotebookManager = await ( - await import(/* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager") - ).default; - this.notebookManager = new NotebookManager(); - this.notebookManager.initialize({ - container: this, - notebookBasePath: this.notebookBasePath, - resourceTree: this.resourceTree, - refreshCommandBarButtons: () => this.refreshCommandBarButtons(), - refreshNotebookList: () => this.refreshNotebookList(), - }); - } + useNotebook.subscribe( + async () => { + if (!this.notebookManager) { + const NotebookManager = await ( + 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(); - }); + this.refreshCommandBarButtons(); + this.refreshNotebookList(); + }, + (state) => state.isNotebookEnabled + ); this.resourceTree = new ResourceTreeAdapter(this); this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this); - this.notebookServerInfo = ko.observable({ - notebookServerEndpoint: undefined, - authToken: undefined, - }); - this.notebookBasePath = ko.observable(Constants.Notebook.defaultBasePath); - this.sparkClusterConnectionInfo = ko.observable({ - userName: undefined, - password: undefined, - endpoints: [], - }); // Override notebook server parameters from URL parameters if (userContext.features.notebookServerUrl && userContext.features.notebookServerToken) { - this.notebookServerInfo({ + useNotebook.getState().setNotebookServerInfo({ notebookServerEndpoint: userContext.features.notebookServerUrl, authToken: userContext.features.notebookServerToken, }); } if (userContext.features.notebookBasePath) { - this.notebookBasePath(userContext.features.notebookBasePath); + useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath); } if (userContext.features.livyEndpoint) { - this.sparkClusterConnectionInfo({ + useNotebook.getState().setSparkClusterConnectionInfo({ userName: undefined, password: undefined, endpoints: [ @@ -275,7 +227,8 @@ export default class Explorer { if (configContext.enableSchemaAnalyzer) { userContext.features.enableSchemaAnalyzer = true; } - this.isAccountReady(true); + + this.refreshExplorer(); } public openEnableSynapseLinkDialog(): void { @@ -296,7 +249,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." ); - this.isSynapseLinkUpdating(true); + useNotebook.getState().setIsSynapseLinkUpdating(true); useDialog.getState().closeDialog(); try { @@ -315,7 +268,7 @@ export default class Explorer { logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`); TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime); } finally { - this.isSynapseLinkUpdating(false); + useNotebook.getState().setIsSynapseLinkUpdating(false); } }, @@ -464,18 +417,17 @@ export default class Explorer { "default" ); - this.notebookServerInfo({ + useNotebook.getState().setNotebookServerInfo({ notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint, authToken: userContext.features.notebookServerToken || connectionInfo.authToken, }); - this.notebookServerInfo.valueHasMutated(); this.refreshNotebookList(); this._isInitializingNotebooks = false; } public resetNotebookWorkspace() { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookClient) { handleError( "Attempt to reset notebook workspace, but notebook is not enabled", "Explorer/resetNotebookWorkspace" @@ -659,7 +611,7 @@ export default class Explorer { } public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to upload notebook, but notebook is not enabled"; handleError(error, "Explorer/uploadFile"); throw new Error(error); @@ -677,7 +629,7 @@ export default class Explorer { const item = NotebookUtil.createNotebookContentItem(name, path, "file"); const parent = this.resourceTree.myNotebooksContentRoot; - if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { + if (parent && parent.children && useNotebook.getState().isNotebookEnabled && this.notebookManager?.notebookClient) { const existingItem = _.find(parent.children, (node) => node.name === name); if (existingItem) { return this.openNotebook(existingItem); @@ -694,7 +646,7 @@ export default class Explorer { public async importAndOpenContent(name: string, content: string): Promise { const parent = this.resourceTree.myNotebooksContentRoot; - if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { + if (parent && parent.children && useNotebook.getState().isNotebookEnabled && this.notebookManager?.notebookClient) { if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) { this.notebookToImport = undefined; // we don't want to try opening this notebook again } @@ -837,7 +789,7 @@ export default class Explorer { } public renameNotebook(notebookFile: NotebookContentItem): void { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to rename notebook, but notebook is not enabled"; handleError(error, "Explorer/renameNotebook"); throw new Error(error); @@ -878,7 +830,7 @@ export default class Explorer { } public onCreateDirectory(parent: NotebookContentItem): void { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to create notebook directory, but notebook is not enabled"; handleError(error, "Explorer/onCreateDirectory"); throw new Error(error); @@ -908,7 +860,7 @@ export default class Explorer { } public readFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to read file, but notebook is not enabled"; handleError(error, "Explorer/downloadFile"); throw new Error(error); @@ -918,7 +870,7 @@ export default class Explorer { } public downloadFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to download file, but notebook is not enabled"; handleError(error, "Explorer/downloadFile"); throw new Error(error); @@ -955,56 +907,8 @@ export default class Explorer { ); } - private async _refreshNotebooksEnabledStateForAccount(): Promise { - const { databaseAccount, authType } = userContext; - if ( - authType === AuthType.EncryptedToken || - authType === AuthType.ResourceToken || - authType === AuthType.MasterKey - ) { - this.isNotebooksEnabledForAccount(false); - return; - } - - const firstWriteLocation = - databaseAccount?.properties?.writeLocations && - databaseAccount?.properties?.writeLocations[0]?.locationName.toLowerCase(); - const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`; - const authorizationHeader = getAuthorizationHeader(); - 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"); - this.isNotebooksEnabledForAccount(true); - return; - } - - // firstWriteLocation should not be disallowed - const isAccountInAllowedLocation = firstWriteLocation && disallowedLocations.indexOf(firstWriteLocation) === -1; - this.isNotebooksEnabledForAccount(isAccountInAllowedLocation); - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); - this.isNotebooksEnabledForAccount(false); - } - } - private refreshNotebookList = async (): Promise => { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { return; } @@ -1016,7 +920,7 @@ export default class Explorer { }; public deleteNotebookFile(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to delete notebook file, but notebook is not enabled"; handleError(error, "Explorer/deleteNotebookFile"); throw new Error(error); @@ -1057,7 +961,7 @@ export default class Explorer { * This creates a new notebook file, then opens the notebook */ public onNewNotebookClicked(parent?: NotebookContentItem): void { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to create new notebook, but notebook is not enabled"; handleError(error, "Explorer/onNewNotebookClicked"); throw new Error(error); @@ -1101,7 +1005,7 @@ export default class Explorer { } public refreshContentItem(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to refresh notebook list, but notebook is not enabled"; handleError(error, "Explorer/refreshContentItem"); return Promise.reject(new Error(error)); @@ -1110,10 +1014,6 @@ export default class Explorer { return this.notebookManager?.notebookContentClient.updateItemChildren(item); } - public getNotebookBasePath(): string { - return this.notebookBasePath(); - } - public openNotebookTerminal(kind: ViewModels.TerminalKind) { let title: string; @@ -1233,7 +1133,7 @@ export default class Explorer { } public async handleOpenFileAction(path: string): Promise { - if (this.isAccountReady() && !(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) { + if (!(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) { this._openSetupNotebooksPaneForQuickstart(); } @@ -1304,4 +1204,29 @@ export default class Explorer { .getState() .openSidePanel(title, ); } + + public async refreshExplorer(): Promise { + userContext.authType === AuthType.ResourceToken + ? this.refreshDatabaseForResourceToken() + : this.refreshAllDatabases(true); + await useNotebook.getState().refreshNotebooksEnabledStateForAccount(); + const isNotebookEnabled: boolean = + userContext.authType !== AuthType.ResourceToken && + ((await this._containsDefaultNotebookWorkspace(userContext.databaseAccount)) || + userContext.features.enableNotebooks); + useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled); + useNotebook.getState().setIsShellEnabled(isNotebookEnabled && isPublicInternetAccessAllowed()); + + TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { + isNotebookEnabled, + dataExplorerArea: Constants.Areas.Notebook, + }); + + if (isNotebookEnabled) { + await this.initNotebooks(userContext.databaseAccount); + } else if (this.notebookToImport) { + // if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane + this._openSetupNotebooksPaneForQuickstart(); + } + } } diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 03a3c1ef8..675ba6800 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -54,7 +54,7 @@ export const CommandBar: React.FC = ({ container }: Props) => { uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); if (container.tabsManager.activeTab()?.tabKind === ViewModels.CollectionTabKind.NotebookV2) { - uiFabricControlButtons.unshift(CommandBarUtil.createMemoryTracker("memoryTracker", container.memoryUsageInfo)); + uiFabricControlButtons.unshift(CommandBarUtil.createMemoryTracker("memoryTracker")); } return ( diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts index 490b76067..a8dba6874 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts @@ -6,6 +6,7 @@ import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService"; import { updateUserContext } from "../../../UserContext"; import Explorer from "../../Explorer"; import NotebookManager from "../../Notebook/NotebookManager"; +import { useNotebook } from "../../Notebook/useNotebook"; import { useDatabases } from "../../useDatabases"; import { useSelectedNode } from "../../useSelectedNode"; import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory"; @@ -28,9 +29,6 @@ describe("CommandBarComponentButtonFactory tests", () => { }, } as DatabaseAccount, }); - mockExplorer.isSynapseLinkUpdating = ko.observable(false); - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); }); it("Account is not serverless - button should be visible", () => { @@ -71,18 +69,19 @@ describe("CommandBarComponentButtonFactory tests", () => { }, } as DatabaseAccount, }); - mockExplorer.isSynapseLinkUpdating = ko.observable(false); }); afterEach(() => { updateUserContext({ portalEnv: "prod", }); + useNotebook.getState().setIsNotebookEnabled(false); + useNotebook.getState().setIsNotebooksEnabledForAccount(false); }); it("Notebooks is already enabled - button should be hidden", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel); @@ -90,8 +89,6 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Account is running on one of the national clouds - button should be hidden", () => { - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); updateUserContext({ portalEnv: "mooncake", }); @@ -102,8 +99,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is not enabled but is available - button should be shown and enabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel); @@ -113,9 +109,6 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); - const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const enableNotebookBtn = buttons.find((button) => button.commandButtonLabel === enableNotebookBtnLabel); expect(enableNotebookBtn).toBeDefined(); @@ -139,24 +132,25 @@ describe("CommandBarComponentButtonFactory tests", () => { }, } as DatabaseAccount, }); - mockExplorer.isSynapseLinkUpdating = ko.observable(false); - mockExplorer.isShellEnabled = ko.observable(true); }); afterAll(() => { updateUserContext({ apiType: "SQL", }); + useNotebook.getState().setIsShellEnabled(false); }); beforeEach(() => { updateUserContext({ apiType: "Mongo", }); - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); + useNotebook.getState().setIsShellEnabled(true); + }); - mockExplorer.isShellEnabled = ko.observable(true); + afterEach(() => { + useNotebook.getState().setIsNotebookEnabled(false); + useNotebook.getState().setIsNotebooksEnabledForAccount(false); }); it("Mongo Api not available - button should be hidden", () => { @@ -185,7 +179,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is not enabled and is available - button should be hidden", () => { - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel); @@ -193,7 +187,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel); @@ -203,8 +197,8 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and is available - button should be shown and enabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel); @@ -214,9 +208,9 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and is available, terminal is unavailable due to ipRules - button should be hidden", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); - mockExplorer.isShellEnabled = ko.observable(false); + useNotebook.getState().setIsNotebookEnabled(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); + useNotebook.getState().setIsShellEnabled(false); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openMongoShellBtn = buttons.find((button) => button.commandButtonLabel === openMongoShellBtnLabel); @@ -237,7 +231,6 @@ describe("CommandBarComponentButtonFactory tests", () => { }, } as DatabaseAccount, }); - mockExplorer.isSynapseLinkUpdating = ko.observable(false); }); beforeEach(() => { @@ -248,8 +241,11 @@ describe("CommandBarComponentButtonFactory tests", () => { }, } as DatabaseAccount, }); - mockExplorer.isNotebookEnabled = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); + }); + + afterEach(() => { + useNotebook.getState().setIsNotebookEnabled(false); + useNotebook.getState().setIsNotebooksEnabledForAccount(false); }); it("Cassandra Api not available - button should be hidden", () => { @@ -283,7 +279,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is not enabled and is available - button should be shown and enabled", () => { - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); @@ -291,7 +287,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and is unavailable - button should be shown and enabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); @@ -301,8 +297,8 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and is available - button should be shown and enabled", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); + useNotebook.getState().setIsNotebooksEnabledForAccount(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); @@ -327,23 +323,17 @@ describe("CommandBarComponentButtonFactory tests", () => { } as DatabaseAccount, }); - mockExplorer.isSynapseLinkUpdating = ko.observable(false); - mockExplorer.isNotebooksEnabledForAccount = ko.observable(false); - mockExplorer.notebookManager = new NotebookManager(); mockExplorer.notebookManager.gitHubOAuthService = new GitHubOAuthService(undefined); }); - beforeEach(() => { - mockExplorer.isNotebookEnabled = ko.observable(false); - }); - afterEach(() => { jest.resetAllMocks(); + useNotebook.getState().setIsNotebookEnabled(false); }); it("Notebooks is enabled and GitHubOAuthService is not logged in - connect to github button should be visible", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const connectToGitHubBtn = buttons.find((button) => button.commandButtonLabel === connectToGitHubBtnLabel); @@ -351,7 +341,7 @@ describe("CommandBarComponentButtonFactory tests", () => { }); it("Notebooks is enabled and GitHubOAuthService is logged in - manage github settings button should be visible", () => { - mockExplorer.isNotebookEnabled = ko.observable(true); + useNotebook.getState().setIsNotebookEnabled(true); mockExplorer.notebookManager.gitHubOAuthService.isLoggedIn = jest.fn().mockReturnValue(true); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index 26bc809b7..52c70d99b 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -28,6 +28,7 @@ import { isServerlessAccount } from "../../../Utils/CapabilityUtils"; import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; +import { useNotebook } from "../../Notebook/useNotebook"; import { OpenFullScreen } from "../../OpenFullScreen"; import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane"; import { SettingsPane } from "../../Panes/SettingsPane/SettingsPane"; @@ -63,7 +64,7 @@ export function createStaticCommandBarButtons( buttons.push(createDivider()); - if (container.isNotebookEnabled()) { + if (useNotebook.getState().isNotebookEnabled) { const newNotebookButton = createNewNotebookButton(container); newNotebookButton.children = [createNewNotebookButton(container), createuploadNotebookButton(container)]; buttons.push(newNotebookButton); @@ -77,7 +78,7 @@ export function createStaticCommandBarButtons( buttons.push(createNotebookWorkspaceResetButton(container)); if ( (userContext.apiType === "Mongo" && - container.isShellEnabled() && + useNotebook.getState().isShellEnabled && selectedNodeState.isDatabaseNodeOrNoneSelected()) || userContext.apiType === "Cassandra" ) { @@ -139,13 +140,13 @@ export function createContextCommandBarButtons( const buttons: CommandButtonComponentProps[] = []; if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") { - const label = container.isShellEnabled() ? "Open Mongo Shell" : "New Shell"; + const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell"; const newMongoShellBtn: CommandButtonComponentProps = { iconSrc: HostedTerminalIcon, iconAlt: label, onCommandClick: () => { const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); - if (container.isShellEnabled()) { + if (useNotebook.getState().isShellEnabled) { container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { selectedCollection && selectedCollection.onNewMongoShellClick(); @@ -270,7 +271,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo onCommandClick: () => container.openEnableSynapseLinkDialog(), commandButtonLabel: label, hasPopup: false, - disabled: container.isSynapseLinkUpdating(), + disabled: useNotebook.getState().isSynapseLinkUpdating, ariaLabel: label, }; } @@ -450,9 +451,9 @@ function createEnableNotebooksButton(container: Explorer): CommandButtonComponen onCommandClick: () => container.openSetupNotebooksPanel(label, description), commandButtonLabel: label, hasPopup: false, - disabled: !container.isNotebooksEnabledForAccount(), + disabled: !useNotebook.getState().isNotebooksEnabledForAccount, ariaLabel: label, - tooltipText: container.isNotebooksEnabledForAccount() ? "" : tooltip, + tooltipText: useNotebook.getState().isNotebooksEnabledForAccount ? "" : tooltip, }; } @@ -476,12 +477,13 @@ function createOpenMongoTerminalButton(container: Explorer): CommandButtonCompon const title = "Set up workspace"; const description = "Looks like you have not created a workspace for this account. To proceed and start using features including mongo shell and notebook, we will need to create a default workspace in this account."; - const disableButton = !container.isNotebooksEnabledForAccount() && !container.isNotebookEnabled(); + const disableButton = + !useNotebook.getState().isNotebooksEnabledForAccount && !useNotebook.getState().isNotebookEnabled; return { iconSrc: HostedTerminalIcon, iconAlt: label, onCommandClick: () => { - if (container.isNotebookEnabled()) { + if (useNotebook.getState().isNotebookEnabled) { container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { container.openSetupNotebooksPanel(title, description); @@ -502,12 +504,13 @@ function createOpenCassandraTerminalButton(container: Explorer): CommandButtonCo const title = "Set up workspace"; const description = "Looks like you have not created a workspace for this account. To proceed and start using features including cassandra shell and notebook, we will need to create a default workspace in this account."; - const disableButton = !container.isNotebooksEnabledForAccount() && !container.isNotebookEnabled(); + const disableButton = + !useNotebook.getState().isNotebooksEnabledForAccount && !useNotebook.getState().isNotebookEnabled; return { iconSrc: HostedTerminalIcon, iconAlt: label, onCommandClick: () => { - if (container.isNotebookEnabled()) { + if (useNotebook.getState().isNotebookEnabled) { container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra); } else { container.openSetupNotebooksPanel(title, description); diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx index 2f17a1370..6403b4ed9 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx @@ -6,16 +6,14 @@ import { IDropdownOption, IDropdownStyles, } from "@fluentui/react"; -import { Observable } from "knockout"; import * as React from "react"; import _ from "underscore"; import ChevronDownIcon from "../../../../images/Chevron_down.svg"; import { StyleConstants } from "../../../Common/Constants"; -import { MemoryUsageInfo } from "../../../Contracts/DataModels"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; -import { MemoryTrackerComponent } from "./MemoryTrackerComponent"; +import { MemoryTracker } from "./MemoryTrackerComponent"; /** * Convert our NavbarButtonConfig to UI Fabric buttons @@ -185,12 +183,9 @@ export const createDivider = (key: string): ICommandBarItemProps => { }; }; -export const createMemoryTracker = ( - key: string, - memoryUsageInfo: Observable -): ICommandBarItemProps => { +export const createMemoryTracker = (key: string): ICommandBarItemProps => { return { key, - onRender: () => , + onRender: () => , }; }; diff --git a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx index 4317fed4e..ac0621897 100644 --- a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx +++ b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx @@ -1,48 +1,29 @@ import { ProgressIndicator, Spinner, SpinnerSize, Stack } from "@fluentui/react"; -import { Observable, Subscription } from "knockout"; import * as React from "react"; -import { MemoryUsageInfo } from "../../../Contracts/DataModels"; - -interface MemoryTrackerProps { - memoryUsageInfo: Observable; -} - -export class MemoryTrackerComponent extends React.Component { - private memoryUsageInfoSubscription: Subscription; - - public componentDidMount(): void { - this.memoryUsageInfoSubscription = this.props.memoryUsageInfo.subscribe(() => { - this.forceUpdate(); - }); - } - - public componentWillUnmount(): void { - this.memoryUsageInfoSubscription && this.memoryUsageInfoSubscription.dispose(); - } - - public render(): JSX.Element { - const memoryUsageInfo: MemoryUsageInfo = this.props.memoryUsageInfo(); - if (!memoryUsageInfo) { - return ( - - Memory - - - ); - } - - const totalGB = memoryUsageInfo.totalKB / 1048576; - const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576; +import { useNotebook } from "../../Notebook/useNotebook"; +export const MemoryTracker: React.FC = (): JSX.Element => { + const memoryUsageInfo = useNotebook((state) => state.memoryUsageInfo); + if (!memoryUsageInfo) { return ( Memory - 0.8 ? "lowMemory" : ""} - description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"} - percentComplete={usedGB / totalGB} - /> + ); } -} + + 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 index 3764360ef..0e50106a7 100644 --- a/src/Explorer/Notebook/NotebookContainerClient.ts +++ b/src/Explorer/Notebook/NotebookContainerClient.ts @@ -8,25 +8,26 @@ import * as DataModels from "../../Contracts/DataModels"; import { userContext } from "../../UserContext"; import { createOrUpdate, destroy } from "../../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces"; import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { useNotebook } from "./useNotebook"; export class NotebookContainerClient { private clearReconnectionAttemptMessage? = () => {}; private isResettingWorkspace: boolean; - constructor( - private notebookServerInfo: ko.Observable, - private onConnectionLost: () => void, - private onMemoryUsageInfoUpdate: (update: DataModels.MemoryUsageInfo) => void - ) { - if (notebookServerInfo() && notebookServerInfo().notebookServerEndpoint) { + constructor(private onConnectionLost: () => void) { + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + if (notebookServerInfo?.notebookServerEndpoint) { this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs); } else { - const subscription = notebookServerInfo.subscribe((newServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => { - if (newServerInfo && newServerInfo.notebookServerEndpoint) { - this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs); - } - subscription.dispose(); - }); + const unsub = useNotebook.subscribe( + (newServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => { + if (newServerInfo?.notebookServerEndpoint) { + this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs); + } + unsub(); + }, + (state) => state.notebookServerInfo + ); } } @@ -36,13 +37,14 @@ export class NotebookContainerClient { private scheduleHeartbeat(delayMs: number): void { setTimeout(() => { this.getMemoryUsage() - .then((memoryUsageInfo) => this.onMemoryUsageInfoUpdate(memoryUsageInfo)) + .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)) .finally(() => this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs)); }, delayMs); } private async getMemoryUsage(): Promise { - if (!this.notebookServerInfo() || !this.notebookServerInfo().notebookServerEndpoint) { + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) { const error = "No server endpoint detected"; Logger.logError(error, "NotebookContainerClient/getMemoryUsage"); return Promise.reject(error); @@ -98,7 +100,8 @@ export class NotebookContainerClient { } private async _resetWorkspace(): Promise { - if (!this.notebookServerInfo() || !this.notebookServerInfo().notebookServerEndpoint) { + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) { const error = "No server endpoint detected"; Logger.logError(error, "NotebookContainerClient/resetWorkspace"); return Promise.reject(error); @@ -117,15 +120,11 @@ export class NotebookContainerClient { } private getNotebookServerConfig(): { notebookServerEndpoint: string; authToken: string } { - let authToken: string, - notebookServerEndpoint = this.notebookServerInfo().notebookServerEndpoint, - token = this.notebookServerInfo().authToken; - if (token) { - authToken = `Token ${token}`; - } + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + const authToken: string = notebookServerInfo.authToken ? `Token ${notebookServerInfo.authToken}` : undefined; return { - notebookServerEndpoint, + notebookServerEndpoint: notebookServerInfo.notebookServerEndpoint, authToken, }; } diff --git a/src/Explorer/Notebook/NotebookContentClient.ts b/src/Explorer/Notebook/NotebookContentClient.ts index e7f14b112..a4a9958d0 100644 --- a/src/Explorer/Notebook/NotebookContentClient.ts +++ b/src/Explorer/Notebook/NotebookContentClient.ts @@ -1,18 +1,14 @@ import { stringifyNotebook } from "@nteract/commutable"; import { FileType, IContent, IContentProvider, IEmptyContent, ServerConfig } from "@nteract/core"; import { AjaxResponse } from "rxjs/ajax"; -import * as DataModels from "../../Contracts/DataModels"; import * as StringUtils from "../../Utils/StringUtils"; import * as FileSystemUtil from "./FileSystemUtil"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { NotebookUtil } from "./NotebookUtil"; +import { useNotebook } from "./useNotebook"; export class NotebookContentClient { - constructor( - private notebookServerInfo: ko.Observable, - public notebookBasePath: ko.Observable, - private contentProvider: IContentProvider - ) {} + constructor(private contentProvider: IContentProvider) {} /** * This updates the item and points all the children's parent to this item @@ -271,9 +267,10 @@ export class NotebookContentClient { } private getServerConfig(): ServerConfig { + const notebookServerInfo = useNotebook.getState().notebookServerInfo; return { - endpoint: this.notebookServerInfo().notebookServerEndpoint, - token: this.notebookServerInfo().authToken, + endpoint: notebookServerInfo.notebookServerEndpoint, + token: notebookServerInfo.authToken, crossDomain: true, }; } diff --git a/src/Explorer/Notebook/NotebookManager.tsx b/src/Explorer/Notebook/NotebookManager.tsx index d2fb3bcb0..9aca162f1 100644 --- a/src/Explorer/Notebook/NotebookManager.tsx +++ b/src/Explorer/Notebook/NotebookManager.tsx @@ -4,13 +4,11 @@ import { ImmutableNotebook } from "@nteract/commutable"; import type { IContentProvider } from "@nteract/core"; -import ko from "knockout"; import React from "react"; import { contents } from "rx-jupyter"; import { Areas, HttpStatusCodes } from "../../Common/Constants"; import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; import * as Logger from "../../Common/Logger"; -import { MemoryUsageInfo } from "../../Contracts/DataModels"; import { GitHubClient } from "../../GitHub/GitHubClient"; import { GitHubContentProvider } from "../../GitHub/GitHubContentProvider"; import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; @@ -37,7 +35,6 @@ export type { NotebookPaneContent }; export interface NotebookManagerOptions { container: Explorer; - notebookBasePath: ko.Observable; resourceTree: ResourceTreeAdapter; refreshCommandBarButtons: () => void; refreshNotebookList: () => void; @@ -81,17 +78,11 @@ export default class NotebookManager { contents.JupyterContentProvider ); - this.notebookClient = new NotebookContainerClient( - this.params.container.notebookServerInfo, - () => this.params.container.initNotebooks(userContext?.databaseAccount), - (update: MemoryUsageInfo) => this.params.container.memoryUsageInfo(update) + this.notebookClient = new NotebookContainerClient(() => + this.params.container.initNotebooks(userContext?.databaseAccount) ); - this.notebookContentClient = new NotebookContentClient( - this.params.container.notebookServerInfo, - this.params.notebookBasePath, - this.notebookContentProvider - ); + this.notebookContentClient = new NotebookContentClient(this.notebookContentProvider); this.gitHubOAuthService.getTokenObservable().subscribe((token) => { this.gitHubClient.setToken(token?.access_token); diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts new file mode 100644 index 000000000..ecf927565 --- /dev/null +++ b/src/Explorer/Notebook/useNotebook.ts @@ -0,0 +1,106 @@ +import create, { UseStore } from "zustand"; +import { AuthType } from "../../AuthType"; +import * as Constants 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 { userContext } from "../../UserContext"; +import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; + +interface NotebookState { + isNotebookEnabled: boolean; + isNotebooksEnabledForAccount: boolean; + notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo; + sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo; + isSynapseLinkUpdating: boolean; + memoryUsageInfo: DataModels.MemoryUsageInfo; + isShellEnabled: boolean; + notebookBasePath: string; + isInitializingNotebooks: 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; + refreshNotebooksEnabledStateForAccount: () => Promise; +} + +export const useNotebook: UseStore = create((set) => ({ + isNotebookEnabled: false, + isNotebooksEnabledForAccount: false, + notebookServerInfo: { + notebookServerEndpoint: undefined, + authToken: undefined, + }, + sparkClusterConnectionInfo: { + userName: undefined, + password: undefined, + endpoints: [], + }, + isSynapseLinkUpdating: false, + memoryUsageInfo: undefined, + isShellEnabled: false, + notebookBasePath: Constants.Notebook.defaultBasePath, + isInitializingNotebooks: false, + 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 }), + refreshNotebooksEnabledStateForAccount: async (): Promise => { + const { databaseAccount, authType } = userContext; + if ( + authType === AuthType.EncryptedToken || + authType === AuthType.ResourceToken || + authType === AuthType.MasterKey + ) { + set({ isNotebooksEnabledForAccount: false }); + return; + } + + const firstWriteLocation = + databaseAccount?.properties?.writeLocations && + databaseAccount?.properties?.writeLocations[0]?.locationName.toLowerCase(); + const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`; + const authorizationHeader = getAuthorizationHeader(); + 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 }); + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); + set({ isNotebooksEnabledForAccount: false }); + } + }, +})); diff --git a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx index 3a982da82..60cb731b8 100644 --- a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx +++ b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx @@ -9,6 +9,7 @@ import * as GitHubUtils from "../../../Utils/GitHubUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import Explorer from "../../Explorer"; import { NotebookContentItem, NotebookContentItemType } from "../../Notebook/NotebookContentItem"; +import { useNotebook } from "../../Notebook/useNotebook"; import { ResourceTreeAdapter } from "../../Tree/ResourceTreeAdapter"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent"; @@ -101,7 +102,7 @@ export const CopyNotebookPane: FunctionComponent = ({ case "MyNotebooks": parent = { name: ResourceTreeAdapter.MyNotebooksTitle, - path: container.getNotebookBasePath(), + path: useNotebook.getState().notebookBasePath, type: NotebookContentItemType.Directory, }; break; diff --git a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap index 9841b63da..8522a1cfd 100644 --- a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap +++ b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap @@ -19,17 +19,9 @@ exports[`GitHub Repos Panel should render Default properly 1`] = ` "container": Explorer { "_isInitializingNotebooks": false, "_resetNotebookWorkspace": [Function], - "isAccountReady": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], - "isNotebookEnabled": [Function], - "isNotebooksEnabledForAccount": [Function], "isSchemaEnabled": [Function], - "isShellEnabled": [Function], - "isSynapseLinkUpdating": [Function], "isTabsContentExpanded": [Function], - "memoryUsageInfo": [Function], - "notebookBasePath": [Function], - "notebookServerInfo": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], "provideFeedbackEmail": [Function], @@ -46,7 +38,6 @@ exports[`GitHub Repos Panel should render Default properly 1`] = ` "container": [Circular], "parameters": [Function], }, - "sparkClusterConnectionInfo": [Function], "tabsManager": TabsManager { "activeTab": [Function], "openedTabs": [Function], diff --git a/src/Explorer/Panes/SetupNotebooksPanel/SetupNotebooksPanel.tsx b/src/Explorer/Panes/SetupNotebooksPanel/SetupNotebooksPanel.tsx index 75a17d89a..a7d943749 100644 --- a/src/Explorer/Panes/SetupNotebooksPanel/SetupNotebooksPanel.tsx +++ b/src/Explorer/Panes/SetupNotebooksPanel/SetupNotebooksPanel.tsx @@ -63,7 +63,7 @@ export const SetupNoteBooksPanel: FunctionComponent = userContext.databaseAccount.name, "default" ); - explorer.isAccountReady.valueHasMutated(); // re-trigger init notebooks + explorer.refreshExplorer(); closeSidePanel(); diff --git a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap index 720e6feff..9648eff21 100644 --- a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap +++ b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap @@ -9,17 +9,9 @@ exports[`StringInput Pane should render Create new directory properly 1`] = ` Explorer { "_isInitializingNotebooks": false, "_resetNotebookWorkspace": [Function], - "isAccountReady": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], - "isNotebookEnabled": [Function], - "isNotebooksEnabledForAccount": [Function], "isSchemaEnabled": [Function], - "isShellEnabled": [Function], - "isSynapseLinkUpdating": [Function], "isTabsContentExpanded": [Function], - "memoryUsageInfo": [Function], - "notebookBasePath": [Function], - "notebookServerInfo": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], "provideFeedbackEmail": [Function], @@ -36,7 +28,6 @@ exports[`StringInput Pane should render Create new directory properly 1`] = ` "container": [Circular], "parameters": [Function], }, - "sparkClusterConnectionInfo": [Function], "tabsManager": TabsManager { "activeTab": [Function], "openedTabs": [Function], diff --git a/src/Explorer/SplashScreen/SplashScreen.test.ts b/src/Explorer/SplashScreen/SplashScreen.test.ts index 5878f2e46..8af31e8a2 100644 --- a/src/Explorer/SplashScreen/SplashScreen.test.ts +++ b/src/Explorer/SplashScreen/SplashScreen.test.ts @@ -1,4 +1,3 @@ -import * as ko from "knockout"; import { DataSamplesUtil } from "../DataSamples/DataSamplesUtil"; import Explorer from "../Explorer"; import { TabsManager } from "../Tabs/TabsManager"; @@ -7,7 +6,6 @@ jest.mock("../Explorer"); const createExplorer = () => { const mock = new Explorer(); - mock.isNotebookEnabled = ko.observable(false); mock.tabsManager = new TabsManager(); return mock as jest.Mocked; }; diff --git a/src/Explorer/SplashScreen/SplashScreen.tsx b/src/Explorer/SplashScreen/SplashScreen.tsx index c96a4c147..fedc1c1eb 100644 --- a/src/Explorer/SplashScreen/SplashScreen.tsx +++ b/src/Explorer/SplashScreen/SplashScreen.tsx @@ -22,6 +22,7 @@ import { FeaturePanelLauncher } from "../Controls/FeaturePanel/FeaturePanelLaunc import { DataSamplesUtil } from "../DataSamples/DataSamplesUtil"; import Explorer from "../Explorer"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; +import { useNotebook } from "../Notebook/useNotebook"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; @@ -61,8 +62,13 @@ export class SplashScreen extends React.Component { public componentDidMount() { this.subscriptions.push( - { dispose: useSelectedNode.subscribe(() => this.setState({})) }, - this.container.isNotebookEnabled.subscribe(() => this.setState({})) + { + dispose: useNotebook.subscribe( + () => this.setState({}), + (state) => state.isNotebookEnabled + ), + }, + { dispose: useSelectedNode.subscribe(() => this.setState({})) } ); } @@ -210,7 +216,7 @@ export class SplashScreen extends React.Component { }); } - if (this.container.isNotebookEnabled()) { + if (useNotebook.getState().isNotebookEnabled) { heroes.push({ iconSrc: NewNotebookIcon, title: "New Notebook", diff --git a/src/Explorer/Tabs/NotebookTabBase.ts b/src/Explorer/Tabs/NotebookTabBase.ts index ed36da3fa..de019eb08 100644 --- a/src/Explorer/Tabs/NotebookTabBase.ts +++ b/src/Explorer/Tabs/NotebookTabBase.ts @@ -6,6 +6,7 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import Explorer from "../Explorer"; import { NotebookClientV2 } from "../Notebook/NotebookClientV2"; +import { useNotebook } from "../Notebook/useNotebook"; import TabsBase from "./TabsBase"; export interface NotebookTabBaseOptions extends ViewModels.TabOptions { @@ -28,7 +29,7 @@ export default class NotebookTabBase extends TabsBase { if (!NotebookTabBase.clientManager) { NotebookTabBase.clientManager = new NotebookClientV2({ - connectionInfo: this.container.notebookServerInfo(), + connectionInfo: useNotebook.getState().notebookServerInfo, databaseAccountName: userContext?.databaseAccount?.name, defaultExperience: userContext.apiType, contentProvider: this.container.notebookManager?.notebookContentProvider, diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 96adcab86..012406760 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -23,6 +23,7 @@ import * as CdbActions from "../Notebook/NotebookComponent/actions"; import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter"; import { CdbAppState, SnapshotRequest } from "../Notebook/NotebookComponent/types"; import { NotebookContentItem } from "../Notebook/NotebookContentItem"; +import { useNotebook } from "../Notebook/useNotebook"; import NotebookTabBase, { NotebookTabBaseOptions } from "./NotebookTabBase"; export interface NotebookTabOptions extends NotebookTabBaseOptions { @@ -39,10 +40,13 @@ export default class NotebookTabV2 extends NotebookTabBase { this.container = options.container; this.notebookPath = ko.observable(options.notebookContentItem.path); - this.container.notebookServerInfo.subscribe(() => logConsoleInfo("New notebook server info received.")); + useNotebook.subscribe( + () => logConsoleInfo("New notebook server info received."), + (state) => state.notebookServerInfo + ); this.notebookComponentAdapter = new NotebookComponentAdapter({ contentItem: options.notebookContentItem, - notebooksBasePath: this.container.getNotebookBasePath(), + notebooksBasePath: useNotebook.getState().notebookBasePath, notebookClient: NotebookTabBase.clientManager, onUpdateKernelInfo: this.onKernelUpdate, }); @@ -359,8 +363,8 @@ export default class NotebookTabV2 extends NotebookTabBase { }; private async configureServiceEndpoints(kernelName: string) { - const notebookConnectionInfo = this.container && this.container.notebookServerInfo(); - const sparkClusterConnectionInfo = this.container && this.container.sparkClusterConnectionInfo(); + const notebookConnectionInfo = useNotebook.getState().notebookServerInfo; + const sparkClusterConnectionInfo = useNotebook.getState().sparkClusterConnectionInfo; await NotebookConfigurationUtils.configureServiceEndpoints( this.notebookPath(), notebookConnectionInfo, diff --git a/src/Explorer/Tabs/TerminalTab.tsx b/src/Explorer/Tabs/TerminalTab.tsx index c0c7bf8c6..7eee433d3 100644 --- a/src/Explorer/Tabs/TerminalTab.tsx +++ b/src/Explorer/Tabs/TerminalTab.tsx @@ -8,6 +8,7 @@ import { userContext } from "../../UserContext"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { NotebookTerminalComponent } from "../Controls/Notebook/NotebookTerminalComponent"; import Explorer from "../Explorer"; +import { useNotebook } from "../Notebook/useNotebook"; import TabsBase from "./TabsBase"; export interface TerminalTabOptions extends ViewModels.TabOptions { @@ -54,8 +55,8 @@ export default class TerminalTab extends TabsBase { this.notebookTerminalComponentAdapter.parameters = ko.computed(() => { if ( this.isTemplateReady() && - this.container.isNotebookEnabled() && - this.container.notebookServerInfo().notebookServerEndpoint + useNotebook.getState().isNotebookEnabled && + useNotebook.getState().notebookServerInfo?.notebookServerEndpoint ) { return true; } @@ -95,7 +96,7 @@ export default class TerminalTab extends TabsBase { throw new Error(`Terminal kind: ${options.kind} not supported`); } - const info: DataModels.NotebookWorkspaceConnectionInfo = options.container.notebookServerInfo(); + const info: DataModels.NotebookWorkspaceConnectionInfo = useNotebook.getState().notebookServerInfo; return { authToken: info.authToken, notebookServerEndpoint: `${info.notebookServerEndpoint.replace(/\/+$/, "")}/${endpointSuffix}`, diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 7165c0a4f..a499857c2 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -31,6 +31,7 @@ import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { NotebookUtil } from "../Notebook/NotebookUtil"; +import { useNotebook } from "../Notebook/useNotebook"; import TabsBase from "../Tabs/TabsBase"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; @@ -57,7 +58,10 @@ export class ResourceTreeAdapter implements ReactAdapter { useSelectedNode.subscribe(() => this.triggerRender()); this.container.tabsManager.activeTab.subscribe((newValue: TabsBase) => this.triggerRender()); - this.container.isNotebookEnabled.subscribe((newValue) => this.triggerRender()); + useNotebook.subscribe( + () => this.triggerRender(), + (state) => state.isNotebookEnabled + ); useDatabases.subscribe(() => this.triggerRender()); this.triggerRender(); @@ -91,7 +95,7 @@ export class ResourceTreeAdapter implements ReactAdapter { const dataRootNode = this.buildDataTree(); const notebooksRootNode = this.buildNotebooksTrees(); - if (this.container.isNotebookEnabled()) { + if (useNotebook.getState().isNotebookEnabled) { return ( <> @@ -122,12 +126,12 @@ export class ResourceTreeAdapter implements ReactAdapter { this.myNotebooksContentRoot = { name: ResourceTreeAdapter.MyNotebooksTitle, - path: this.container.getNotebookBasePath(), + path: useNotebook.getState().notebookBasePath, type: NotebookContentItemType.Directory, }; // Only if notebook server is available we can refresh - if (this.container.notebookServerInfo().notebookServerEndpoint) { + if (useNotebook.getState().notebookServerInfo?.notebookServerEndpoint) { refreshTasks.push( this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => { this.triggerRender(); @@ -268,7 +272,11 @@ export class ResourceTreeAdapter implements ReactAdapter { contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(this.container, collection), }); - if (this.container.isNotebookEnabled() && userContext.apiType === "Mongo" && isPublicInternetAccessAllowed()) { + if ( + useNotebook.getState().isNotebookEnabled && + userContext.apiType === "Mongo" && + isPublicInternetAccessAllowed() + ) { children.push({ label: "Schema (Preview)", onClick: collection.onSchemaAnalyzerClick.bind(collection), diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 139499429..ab9c875b3 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -195,7 +195,6 @@ function configureEmulator(explorerParams: ExplorerParams): Explorer { authType: AuthType.MasterKey, }); const explorer = new Explorer(explorerParams); - explorer.isAccountReady(true); return explorer; } diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 9e5d5e3db..07976aaa1 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -54,7 +54,6 @@ "./src/Explorer/Notebook/NotebookComponent/loadTransform.ts", "./src/Explorer/Notebook/NotebookComponent/reducers.ts", "./src/Explorer/Notebook/NotebookComponent/types.ts", - "./src/Explorer/Notebook/NotebookContentClient.ts", "./src/Explorer/Notebook/NotebookContentItem.ts", "./src/Explorer/Notebook/NotebookRenderer/AzureTheme.tsx", "./src/Explorer/Notebook/NotebookRenderer/Prompt.tsx",