diff --git a/configs/mpac.json b/configs/mpac.json index 7c270c6d5..f0c3654f6 100644 --- a/configs/mpac.json +++ b/configs/mpac.json @@ -1,3 +1,4 @@ { - "JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com" + "JUNO_ENDPOINT": "https://tools-staging.cosmos.azure.com", + "isTerminalEnabled" : true } \ No newline at end of file diff --git a/configs/prod.json b/configs/prod.json index e2614b018..b6716b0c3 100644 --- a/configs/prod.json +++ b/configs/prod.json @@ -1,3 +1,4 @@ { - "JUNO_ENDPOINT": "https://tools.cosmos.azure.com" + "JUNO_ENDPOINT": "https://tools.cosmos.azure.com", + "isTerminalEnabled" : false } diff --git a/less/documentDB.less b/less/documentDB.less index 79e9435c3..bc36d1483 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -2077,7 +2077,7 @@ a:link { .resourceTreeAndTabs { display: flex; flex: 1 1 auto; - overflow-x: auto; + overflow-x: clip; overflow-y: auto; height: 100%; } @@ -2245,7 +2245,7 @@ a:link { } .refreshColHeader { - padding: 3px 6px 6px 6px; + padding: 3px 6px 10px 0px !important; } .refreshColHeader:hover { @@ -2869,31 +2869,39 @@ a:link { } } -settings-pane { - .settingsSection { - border-bottom: 1px solid @BaseMedium; - margin-right: 24px; - padding: @MediumSpace 0px; +.settingsSection { + border-bottom: 1px solid @BaseMedium; + margin-right: 24px; + padding: @MediumSpace 0px; - &:first-child { - padding-top: 0px; - } + &:first-child { + padding-top: 0px; + padding-bottom: 10px; + } - &:last-child { - border-bottom: none; - } + &:last-child { + border-bottom: none; + } - .settingsSectionPart { - padding-left: 8px; - } + .settingsSectionPart { + padding-left: 8px; + } - .settingsSectionLabel { - margin-bottom: @DefaultSpace; - } + .settingsSectionLabel { + margin-bottom: @DefaultSpace; + margin-right: 5px; + } - .pageOptionsPart { - padding-bottom: @MediumSpace; - } + .pageOptionsPart { + padding-bottom: @MediumSpace; + } + + .legendLabel { + border-bottom: 0px; + width: auto; + font-size: @mediumFontSize; + display: inline !important; + float: left; } } diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 838cb5584..e40977ded 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -96,7 +96,8 @@ export class Flights { public static readonly AutoscaleTest = "autoscaletest"; public static readonly PartitionKeyTest = "partitionkeytest"; public static readonly PKPartitionKeyTest = "pkpartitionkeytest"; - public static readonly Phoenix = "phoenix"; + public static readonly PhoenixNotebooks = "phoenixnotebooks"; + public static readonly PhoenixFeatures = "phoenixfeatures"; public static readonly NotebooksDownBanner = "notebooksdownbanner"; } @@ -365,10 +366,12 @@ export class Notebook { public static readonly containerStatusHeartbeatDelayMs = 30000; public static readonly kernelRestartInitialDelayMs = 1000; public static readonly kernelRestartMaxDelayMs = 20000; - public static readonly autoSaveIntervalMs = 120000; + public static readonly autoSaveIntervalMs = 300000; public static readonly memoryGuageToGB = 1048576; public static readonly lowMemoryThreshold = 0.8; public static readonly remainingTimeForAlert = 10; + public static readonly retryAttempts = 3; + public static readonly retryAttemptDelayMs = 5000; public static readonly temporarilyDownMsg = "Notebooks is currently not available. We are working on it."; public static readonly mongoShellTemporarilyDownMsg = "We have identified an issue with the Mongo Shell and it is unavailable right now. We are actively working on the mitigation."; @@ -376,7 +379,7 @@ export class Notebook { "We have identified an issue with the Cassandra Shell and it is unavailable right now. We are actively working on the mitigation."; public static saveNotebookModalTitle = "Save notebook in temporary workspace"; public static saveNotebookModalContent = - "This notebook will be saved in the temporary workspace and will be removed when the session expires. To save your work permanently, save your notebooks to a GitHub repository or download the notebooks to your local machine before the session ends."; + "This notebook will be saved in the temporary workspace and will be removed when the session expires."; public static newNotebookModalTitle = "Create notebook in temporary workspace"; public static newNotebookUploadModalTitle = "Upload notebook to temporary workspace"; public static newNotebookModalContent1 = @@ -410,3 +413,11 @@ export class TerminalQueryParams { public static readonly SubscriptionId = "subscriptionId"; public static readonly TerminalEndpoint = "terminalEndpoint"; } + +export class JunoEndpoints { + public static readonly Test = "https://juno-test.documents-dev.windows-int.net"; + public static readonly Test2 = "https://juno-test2.documents-dev.windows-int.net"; + public static readonly Test3 = "https://juno-test3.documents-dev.windows-int.net"; + public static readonly Prod = "https://tools.cosmos.azure.com"; + public static readonly Stage = "https://tools-staging.cosmos.azure.com"; +} diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index 14aa882fa..7a85d9bd5 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -1,5 +1,6 @@ import * as Cosmos from "@azure/cosmos"; import { RequestInfo, setAuthorizationTokenHeaderUsingMasterKey } from "@azure/cosmos"; +import { CosmosHeaders } from "@azure/cosmos/dist-esm"; import { configContext, Platform } from "../ConfigContext"; import { userContext } from "../UserContext"; import { logConsoleError } from "../Utils/NotificationConsoleUtils"; @@ -77,10 +78,21 @@ export async function getTokenFromAuthService(verb: string, resourceType: string } } +// The Capability is a bitmap, which cosmosdb backend decodes as per the below enum +enum SDKSupportedCapabilities { + None = 0, + PartitionMerge = 1 << 0, +} + let _client: Cosmos.CosmosClient; export function client(): Cosmos.CosmosClient { if (_client) return _client; + + let _defaultHeaders: CosmosHeaders = {}; + _defaultHeaders["x-ms-cosmos-sdk-supported-capabilities"] = + SDKSupportedCapabilities.None | SDKSupportedCapabilities.PartitionMerge; + const options: Cosmos.CosmosClientOptions = { endpoint: endpoint() || "https://cosmos.azure.com", // CosmosClient gets upset if we pass a bad URL. This should never actually get called key: userContext.masterKey, @@ -89,6 +101,7 @@ export function client(): Cosmos.CosmosClient { enableEndpointDiscovery: false, }, userAgentSuffix: "Azure Portal", + defaultHeaders: _defaultHeaders, }; if (configContext.PROXY_PATH !== undefined) { diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index f14e0223b..cb0f1d222 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -24,7 +24,9 @@ export interface ConfigContext { PROXY_PATH?: string; JUNO_ENDPOINT: string; GITHUB_CLIENT_ID: string; + GITHUB_TEST_ENV_CLIENT_ID: string; GITHUB_CLIENT_SECRET?: string; // No need to inject secret for prod. Juno already knows it. + isTerminalEnabled: boolean; hostedExplorerURL: string; armAPIVersion?: string; msalRedirectURI?: string; @@ -44,9 +46,11 @@ let configContext: Readonly = { GRAPH_API_VERSION: "1.6", ARCADIA_ENDPOINT: "https://workspaceartifacts.projectarcadia.net", ARCADIA_LIVY_ENDPOINT_DNS_ZONE: "dev.azuresynapse.net", - GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/settings/applications/1189306 + GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306 + GITHUB_TEST_ENV_CLIENT_ID: "b63fc8cbf87fd3c6e2eb", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1777772 JUNO_ENDPOINT: "https://tools.cosmos.azure.com", BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com", + isTerminalEnabled: false, }; export function resetConfigContext(): void { diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 99db16f11..64a9575d3 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -27,6 +27,7 @@ export interface DatabaseAccountExtendedProperties { ipRules?: IpRule[]; privateEndpointConnections?: unknown[]; capacity?: { totalThroughputLimit: number }; + locations?: DatabaseAccountResponseLocation[]; } export interface DatabaseAccountResponseLocation { @@ -437,15 +438,10 @@ export interface ContainerInfo { } export interface IProvisionData { - aadToken: string; - subscriptionId: string; - resourceGroup: string; - dbAccountName: string; cosmosEndpoint: string; } export interface IContainerData { - dbAccountName: string; forwardingId: string; } diff --git a/src/Contracts/ExplorerContracts.ts b/src/Contracts/ExplorerContracts.ts index d1c3dba58..09a271194 100644 --- a/src/Contracts/ExplorerContracts.ts +++ b/src/Contracts/ExplorerContracts.ts @@ -33,6 +33,7 @@ export enum MessageTypes { CreateWorkspace, CreateSparkPool, RefreshDatabaseAccount, + CloseTab, } export { Versions, ActionContracts, Diagnostics }; diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index a52254cc3..70d02e5aa 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -83,7 +83,6 @@ export const createCollectionContextMenuButton = ( items.push({ iconSrc: HostedTerminalIcon, - isDisabled: useNotebook.getState().isShellEnabled && userContext.features.notebooksTemporarilyDown, onClick: () => { const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); if (useNotebook.getState().isShellEnabled) { diff --git a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx index d9747d6ee..fdc93adea 100644 --- a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx +++ b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx @@ -55,6 +55,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testAccount, notebookServerInfo: testNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -65,6 +66,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testMongo32Account, notebookServerInfo: testMongoNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -75,6 +77,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testMongo36Account, notebookServerInfo: testMongoNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); @@ -85,6 +88,7 @@ describe("NotebookTerminalComponent", () => { const props: NotebookTerminalComponentProps = { databaseAccount: testCassandraAccount, notebookServerInfo: testCassandraNotebookServerInfo, + tabId: undefined, }; const wrapper = shallow(); diff --git a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx index 637f24192..9df968226 100644 --- a/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx +++ b/src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx @@ -12,6 +12,7 @@ import * as StringUtils from "../../../Utils/StringUtils"; export interface NotebookTerminalComponentProps { notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo; databaseAccount: DataModels.DatabaseAccount; + tabId: string; } export class NotebookTerminalComponent extends React.Component { @@ -55,6 +56,7 @@ export class NotebookTerminalComponent extends React.Component { - if (database.offer()) { - const dbThroughput = database.offer().autoscaleMaxThroughput || database.offer().manualThroughput; - this.totalThroughputUsed += dbThroughput; - } - - (database.collections() || []).forEach((collection) => { - if (collection.offer()) { - const colThroughput = collection.offer().autoscaleMaxThroughput || collection.offer().manualThroughput; - this.totalThroughputUsed += colThroughput; - } - }); - }); + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap !== -1) { + this.calculateTotalThroughputUsed(); + } } componentDidMount(): void { @@ -504,6 +494,26 @@ export class SettingsComponent extends React.Component this.setState({ isMongoIndexingPolicyDiscardable }); + private calculateTotalThroughputUsed = (): void => { + this.totalThroughputUsed = 0; + (useDatabases.getState().databases || []).forEach(async (database) => { + if (database.offer()) { + const dbThroughput = database.offer().autoscaleMaxThroughput || database.offer().manualThroughput; + this.totalThroughputUsed += dbThroughput; + } + + (database.collections() || []).forEach(async (collection) => { + if (collection.offer()) { + const colThroughput = collection.offer().autoscaleMaxThroughput || collection.offer().manualThroughput; + this.totalThroughputUsed += colThroughput; + } + }); + }); + + const numberOfRegions = userContext.databaseAccount?.properties.locations?.length || 1; + this.totalThroughputUsed *= numberOfRegions; + }; + public getAnalyticalStorageTtl = (): number => { if (this.isAnalyticalStorageEnabled) { if (this.state.analyticalStorageTtlSelection === TtlType.On) { @@ -669,9 +679,11 @@ export class SettingsComponent extends React.Component { let throughputError = ""; const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; - if (throughputCap && throughputCap - this.totalThroughputUsed < newThroughput - this.offer.autoscaleMaxThroughput) { + const numberOfRegions = userContext.databaseAccount?.properties.locations?.length || 1; + const throughputDelta = (newThroughput - this.offer.autoscaleMaxThroughput) * numberOfRegions; + if (throughputCap && throughputCap !== -1 && throughputCap - this.totalThroughputUsed < throughputDelta) { throughputError = `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ - this.totalThroughputUsed + newThroughput + this.totalThroughputUsed + throughputDelta } RU/s. Change total throughput limit in cost management.`; } this.setState({ autoPilotThroughput: newThroughput, throughputError }); @@ -680,9 +692,11 @@ export class SettingsComponent extends React.Component { let throughputError = ""; const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; - if (throughputCap && throughputCap - this.totalThroughputUsed < newThroughput - this.offer.manualThroughput) { + const numberOfRegions = userContext.databaseAccount?.properties.locations?.length || 1; + const throughputDelta = (newThroughput - this.offer.manualThroughput) * numberOfRegions; + if (throughputCap && throughputCap !== -1 && throughputCap - this.totalThroughputUsed < throughputDelta) { throughputError = `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ - this.totalThroughputUsed + newThroughput + this.totalThroughputUsed + throughputDelta } RU/s. Change total throughput limit in cost management.`; } this.setState({ throughput: newThroughput, throughputError }); diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 6f01661ea..db013e13a 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -34,7 +34,13 @@ exports[`SettingsComponent renders 1`] = ` "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient {}, + "phoenixClient": PhoenixClient { + "retryOptions": Object { + "maxTimeout": 5000, + "minTimeout": 5000, + "retries": 3, + }, + }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], @@ -102,7 +108,13 @@ exports[`SettingsComponent renders 1`] = ` "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient {}, + "phoenixClient": PhoenixClient { + "retryOptions": Object { + "maxTimeout": 5000, + "minTimeout": 5000, + "retries": 3, + }, + }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx index 1b5972d36..ff4d7ed25 100644 --- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx +++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx @@ -40,6 +40,7 @@ export const ThroughputInput: FunctionComponent = ({ setThroughputValue(throughput); const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + const numberOfRegions = userContext.databaseAccount?.properties.locations?.length || 1; useEffect(() => { // throughput cap check for the initial state @@ -57,12 +58,13 @@ export const ThroughputInput: FunctionComponent = ({ } }); }); + totalThroughput *= numberOfRegions; setTotalThroughputUsed(totalThroughput); - if (throughputCap && throughputCap - totalThroughput < throughput) { + if (throughputCap && throughputCap !== -1 && throughputCap - totalThroughput < throughput) { setThroughputError( `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ - totalThroughputUsed + throughput + totalThroughput + throughput * numberOfRegions } RU/s. Change total throughput limit in cost management.` ); @@ -71,10 +73,10 @@ export const ThroughputInput: FunctionComponent = ({ }, []); const checkThroughputCap = (newThroughput: number): boolean => { - if (throughputCap && throughputCap - totalThroughputUsed < newThroughput) { + if (throughputCap && throughputCap !== -1 && throughputCap - totalThroughputUsed < newThroughput) { setThroughputError( `Your account is currently configured with a total throughput limit of ${throughputCap} RU/s. This update isn't possible because it would increase the total throughput to ${ - totalThroughputUsed + newThroughput + totalThroughputUsed + newThroughput * numberOfRegions } RU/s. Change total throughput limit in cost management.` ); setIsThroughputCapExceeded(true); diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 6eb8ed626..c211ae0c8 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -9,19 +9,23 @@ import shallow from "zustand/shallow"; import { AuthType } from "../AuthType"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; import * as Constants from "../Common/Constants"; -import { ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants"; +import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants"; import { readCollection } from "../Common/dataAccess/readCollection"; import { readDatabases } from "../Common/dataAccess/readDatabases"; import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils"; import * as Logger from "../Common/Logger"; import { QueriesClient } from "../Common/QueriesClient"; import * as DataModels from "../Contracts/DataModels"; -import { ContainerConnectionInfo, IPhoenixConnectionInfoResult, IResponse } from "../Contracts/DataModels"; +import { + ContainerConnectionInfo, + IPhoenixConnectionInfoResult, + IProvisionData, + IResponse +} from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; import { GitHubOAuthService } from "../GitHub/GitHubOAuthService"; import { useSidePanel } from "../hooks/useSidePanel"; import { useTabs } from "../hooks/useTabs"; -import { IGalleryItem } from "../Juno/JunoClient"; import { PhoenixClient } from "../Phoenix/PhoenixClient"; import * as ExplorerSettings from "../Shared/ExplorerSettings"; import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; @@ -32,7 +36,6 @@ import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; import { get as getWorkspace, listByDatabaseAccount, - listConnectionInfo, start } from "../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces"; import { stringToBlob } from "../Utils/BlobUtils"; @@ -48,7 +51,6 @@ import * as FileSystemUtil from "./Notebook/FileSystemUtil"; import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; 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"; @@ -341,24 +343,7 @@ export default class Explorer { return; } this._isInitializingNotebooks = true; - if (userContext.features.phoenix === false) { - await this.ensureNotebookWorkspaceRunning(); - const connectionInfo = await listConnectionInfo( - userContext.subscriptionId, - userContext.resourceGroup, - databaseAccount.name, - "default" - ); - - useNotebook.getState().setNotebookServerInfo({ - notebookServerEndpoint: (validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) && userContext.features.notebookServerUrl) || connectionInfo.notebookServerEndpoint, - authToken: userContext.features.notebookServerToken || connectionInfo.authToken, - forwardingId: undefined, - }); - } - this.refreshNotebookList(); - this._isInitializingNotebooks = false; } @@ -370,11 +355,7 @@ export default class Explorer { (notebookServerInfo === undefined || (notebookServerInfo && notebookServerInfo.notebookServerEndpoint === undefined)) ) { - const provisionData = { - aadToken: userContext.authorizationToken, - subscriptionId: userContext.subscriptionId, - resourceGroup: userContext.resourceGroup, - dbAccountName: userContext.databaseAccount.name, + const provisionData: IProvisionData = { cosmosEndpoint: userContext.databaseAccount.properties.documentEndpoint, }; const connectionStatus: ContainerConnectionInfo = { @@ -382,17 +363,36 @@ export default class Explorer { }; useNotebook.getState().setConnectionInfo(connectionStatus); try { + TelemetryProcessor.traceStart(Action.PhoenixConnection, { + dataExplorerArea: Areas.Notebook, + }); useNotebook.getState().setIsAllocating(true); const connectionInfo = await this.phoenixClient.allocateContainer(provisionData); + if (connectionInfo.status !== HttpStatusCodes.OK) { + throw new Error(`Received status code: ${connectionInfo?.status}`); + } + if (!connectionInfo?.data?.notebookServerUrl) { + throw new Error(`NotebookServerUrl is invalid!`); + } await this.setNotebookInfo(connectionInfo, connectionStatus); + TelemetryProcessor.traceSuccess(Action.PhoenixConnection, { + dataExplorerArea: Areas.Notebook, + }); } catch (error) { + TelemetryProcessor.traceFailure(Action.PhoenixConnection, { + dataExplorerArea: Areas.Notebook, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }); connectionStatus.status = ConnectionStatusType.Failed; useNotebook.getState().resetContainerConnection(connectionStatus); throw error; + } finally { + useNotebook.getState().setIsAllocating(false); + this.refreshCommandBarButtons(); + this.refreshNotebookList(); + this._isInitializingNotebooks = false; } - this.refreshNotebookList(); - - this._isInitializingNotebooks = false; } } @@ -400,28 +400,22 @@ export default class Explorer { connectionInfo: IResponse, connectionStatus: DataModels.ContainerConnectionInfo ) { - if (connectionInfo.status === HttpStatusCodes.OK && connectionInfo.data && connectionInfo.data.notebookServerUrl) { - const containerData = { - forwardingId: connectionInfo.data.forwardingId, - dbAccountName: userContext.databaseAccount.name, - }; - await this.phoenixClient.initiateContainerHeartBeat(containerData); + const containerData = { + forwardingId: connectionInfo.data.forwardingId, + dbAccountName: userContext.databaseAccount.name, + }; + await this.phoenixClient.initiateContainerHeartBeat(containerData); - connectionStatus.status = ConnectionStatusType.Connected; - useNotebook.getState().setConnectionInfo(connectionStatus); - useNotebook.getState().setNotebookServerInfo({ - notebookServerEndpoint: validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) && userContext.features.notebookServerUrl || connectionInfo.data.notebookServerUrl, - authToken: userContext.features.notebookServerToken || connectionInfo.data.notebookAuthToken, - forwardingId: connectionInfo.data.forwardingId, - }); - this.notebookManager?.notebookClient - .getMemoryUsage() - .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)); - } else { - connectionStatus.status = ConnectionStatusType.Failed; - useNotebook.getState().resetContainerConnection(connectionStatus); - } - useNotebook.getState().setIsAllocating(false); + connectionStatus.status = ConnectionStatusType.Connected; + useNotebook.getState().setConnectionInfo(connectionStatus); + useNotebook.getState().setNotebookServerInfo({ + notebookServerEndpoint: validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) && userContext.features.notebookServerUrl || connectionInfo.data.notebookServerUrl, + authToken: userContext.features.notebookServerToken || connectionInfo.data.notebookAuthToken, + forwardingId: connectionInfo.data.forwardingId, + }); + this.notebookManager?.notebookClient + .getMemoryUsage() + .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)); } public resetNotebookWorkspace(): void { @@ -432,7 +426,7 @@ export default class Explorer { ); return; } - const dialogContent = NotebookUtil.isPhoenixEnabled() + const dialogContent = useNotebook.getState().isPhoenixNotebooks ? "Notebooks saved in the temporary workspace will be deleted. Do you want to proceed?" : "This lets you keep your notebook files and the workspace will be restored to default. Proceed anyway?"; @@ -506,8 +500,10 @@ export default class Explorer { logConsoleError(error); return; } - - if (NotebookUtil.isPhoenixEnabled()) { + TelemetryProcessor.traceStart(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, + }); + if (useNotebook.getState().isPhoenixNotebooks) { useTabs.getState().closeAllNotebookTabs(true); connectionStatus = { status: ConnectionStatusType.Connecting, @@ -515,35 +511,32 @@ export default class Explorer { useNotebook.getState().setConnectionInfo(connectionStatus); } const connectionInfo = await this.notebookManager?.notebookClient.resetWorkspace(); - if (connectionInfo && connectionInfo.status && connectionInfo.status === HttpStatusCodes.OK) { - if (NotebookUtil.isPhoenixEnabled() && connectionInfo.data && connectionInfo.data.notebookServerUrl) { - await this.setNotebookInfo(connectionInfo, connectionStatus); - useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - } - logConsoleInfo("Successfully reset notebook workspace"); - TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace); - } else { - logConsoleError(`Failed to reset notebook workspace`); - TelemetryProcessor.traceFailure(Action.ResetNotebookWorkspace); - if (NotebookUtil.isPhoenixEnabled()) { - connectionStatus = { - status: ConnectionStatusType.Reconnect, - }; - useNotebook.getState().resetContainerConnection(connectionStatus); - useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - } + if (connectionInfo?.status !== HttpStatusCodes.OK) { + throw new Error(`Reset Workspace: Received status code- ${connectionInfo?.status}`); } + if (!connectionInfo?.data?.notebookServerUrl) { + throw new Error(`Reset Workspace: NotebookServerUrl is invalid!`); + } + if (useNotebook.getState().isPhoenixNotebooks) { + await this.setNotebookInfo(connectionInfo, connectionStatus); + useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); + } + logConsoleInfo("Successfully reset notebook workspace"); + TelemetryProcessor.traceSuccess(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, + }); } catch (error) { logConsoleError(`Failed to reset notebook workspace: ${error}`); - TelemetryProcessor.traceFailure(Action.ResetNotebookWorkspace, { + TelemetryProcessor.traceFailure(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, error: getErrorMessage(error), errorStack: getErrorStack(error), }); - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks) { connectionStatus = { status: ConnectionStatusType.Failed, }; - useNotebook.getState().setConnectionInfo(connectionStatus); + useNotebook.getState().resetContainerConnection(connectionStatus); useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); } throw error; @@ -737,7 +730,7 @@ export default class Explorer { if (!notebookContentItem || !notebookContentItem.path) { throw new Error(`Invalid notebookContentItem: ${notebookContentItem}`); } - if (notebookContentItem.type === NotebookContentItemType.Notebook && NotebookUtil.isPhoenixEnabled()) { + if (notebookContentItem.type === NotebookContentItemType.Notebook && useNotebook.getState().isPhoenixNotebooks) { await this.allocateContainer(); } @@ -955,20 +948,17 @@ export default class Explorer { /** * This creates a new notebook file, then opens the notebook */ - public onNewNotebookClicked(parent?: NotebookContentItem, isGithubTree?: boolean): void { + public async onNewNotebookClicked(parent?: NotebookContentItem, isGithubTree?: boolean): Promise { 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); } - const isPhoenixEnabled = NotebookUtil.isPhoenixEnabled(); - if (isPhoenixEnabled) { + if (useNotebook.getState().isPhoenixNotebooks) { if (isGithubTree) { - async () => { - await this.allocateContainer(); - parent = parent || this.resourceTree.myNotebooksContentRoot; - this.createNewNoteBook(parent, isGithubTree); - }; + await this.allocateContainer(); + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.createNewNoteBook(parent, isGithubTree); } else { useDialog.getState().showOkCancelModalDialog( Notebook.newNotebookModalTitle, @@ -1053,7 +1043,7 @@ export default class Explorer { } public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixFeatures) { await this.allocateContainer(); const notebookServerInfo = useNotebook.getState().notebookServerInfo; if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) { @@ -1093,7 +1083,7 @@ export default class Explorer { const terminalTabs: TerminalTab[] = useTabs .getState() - .getTabs(ViewModels.CollectionTabKind.Terminal, (tab) => tab.tabTitle() === title) as TerminalTab[]; + .getTabs(ViewModels.CollectionTabKind.Terminal, (tab) => tab.tabTitle().startsWith(title)) as TerminalTab[]; let index = 1; if (terminalTabs.length > 0) { @@ -1165,7 +1155,10 @@ export default class Explorer { ); } else { - await useDatabases.getState().loadDatabaseOffers(); + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + throughputCap && throughputCap !== -1 + ? await useDatabases.getState().loadAllOffers() + : await useDatabases.getState().loadDatabaseOffers(); useSidePanel .getState() .openSidePanel("New " + getCollectionName(), ); @@ -1191,10 +1184,9 @@ export default class Explorer { } public async handleOpenFileAction(path: string): Promise { - if ( - userContext.features.phoenix === false && - !(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount)) - ) { + if (useNotebook.getState().isPhoenixNotebooks) { + await this.allocateContainer(); + } else if (!(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) { this._openSetupNotebooksPaneForQuickstart(); } @@ -1226,7 +1218,7 @@ export default class Explorer { } public openUploadFilePanel(parent?: NotebookContentItem): void { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks) { useDialog.getState().showOkCancelModalDialog( Notebook.newNotebookUploadModalTitle, undefined, @@ -1256,7 +1248,7 @@ export default class Explorer { } public getDownloadModalConent(fileName: string): JSX.Element { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks) { return ( <>

{Notebook.galleryNotebookDownloadContent1}

@@ -1280,22 +1272,19 @@ export default class Explorer { await useNotebook.getState().refreshNotebooksEnabledStateForAccount(); // TODO: remove reference to isNotebookEnabled and isNotebooksEnabledForAccount - const isNotebookEnabled = userContext.features.notebooksDownBanner || userContext.features.phoenix; + const isNotebookEnabled = userContext.features.notebooksDownBanner || useNotebook.getState().isPhoenixNotebooks; useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled); - useNotebook.getState().setIsShellEnabled(userContext.features.phoenix && isPublicInternetAccessAllowed()); + useNotebook + .getState() + .setIsShellEnabled(useNotebook.getState().isPhoenixFeatures && isPublicInternetAccessAllowed()); TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { isNotebookEnabled, dataExplorerArea: Constants.Areas.Notebook, }); - if (!userContext.features.notebooksTemporarilyDown) { - 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(); - } + if (useNotebook.getState().isPhoenixNotebooks) { + await this.initNotebooks(userContext.databaseAccount); } } } diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 222fe2fa4..8827994e4 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -4,15 +4,12 @@ * and update any knockout observables passed from the parent. */ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; +import { useNotebook } from "Explorer/Notebook/useNotebook"; import * as React from "react"; import create, { UseStore } from "zustand"; import { StyleConstants } from "../../../Common/Constants"; -import * as ViewModels from "../../../Contracts/ViewModels"; -import { useTabs } from "../../../hooks/useTabs"; -import { userContext } from "../../../UserContext"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; -import { NotebookUtil } from "../../Notebook/NotebookUtil"; import { useSelectedNode } from "../../useSelectedNode"; import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory"; import * as CommandBarUtil from "./CommandBarUtil"; @@ -56,18 +53,10 @@ export const CommandBar: React.FC = ({ container }: Props) => { const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor); uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks || useNotebook.getState().isPhoenixFeatures) { uiFabricControlButtons.unshift(CommandBarUtil.createConnectionStatus(container, "connectionStatus")); } - if ( - userContext.features.phoenix === false && - userContext.features.notebooksTemporarilyDown === false && - useTabs.getState().activeTab?.tabKind === ViewModels.CollectionTabKind.NotebookV2 - ) { - uiFabricControlButtons.unshift(CommandBarUtil.createMemoryTracker("memoryTracker")); - } - return (
{ }); }); - it("Account is not serverless - button should be visible", () => { + it("Button should be visible", () => { const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const enableAzureSynapseLinkBtn = buttons.find( (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel ); expect(enableAzureSynapseLinkBtn).toBeDefined(); }); - - it("Account is serverless - button should be hidden", () => { - updateUserContext({ - databaseAccount: { - properties: { - capabilities: [{ name: "EnableServerless" }], - }, - } as DatabaseAccount, - }); - const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); - const enableAzureSynapseLinkBtn = buttons.find( - (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel - ); - expect(enableAzureSynapseLinkBtn).toBeUndefined(); - }); }); describe("Enable notebook button", () => { diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index c659d7712..0780f9e61 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -25,7 +25,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel"; import { JunoClient } from "../../../Juno/JunoClient"; import { userContext } from "../../../UserContext"; import { getCollectionName, getDatabaseName } from "../../../Utils/APITypeUtils"; -import { isServerlessAccount } from "../../../Utils/CapabilityUtils"; import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; @@ -78,9 +77,12 @@ export function createStaticCommandBarButtons( if (container.notebookManager?.gitHubOAuthService) { notebookButtons.push(createManageGitHubAccountButton(container)); } - - notebookButtons.push(createOpenTerminalButton(container)); - notebookButtons.push(createNotebookWorkspaceResetButton(container)); + if (useNotebook.getState().isPhoenixFeatures && configContext.isTerminalEnabled) { + notebookButtons.push(createOpenTerminalButton(container)); + } + if (useNotebook.getState().isPhoenixNotebooks && selectedNodeState.isConnectedToContainer()) { + notebookButtons.push(createNotebookWorkspaceResetButton(container)); + } if ( (userContext.apiType === "Mongo" && useNotebook.getState().isShellEnabled && @@ -96,19 +98,21 @@ export function createStaticCommandBarButtons( } notebookButtons.forEach((btn) => { - if (userContext.features.notebooksTemporarilyDown) { - if (btn.commandButtonLabel.indexOf("Cassandra") !== -1) { + if (btn.commandButtonLabel.indexOf("Cassandra") !== -1) { + if (!useNotebook.getState().isPhoenixFeatures) { applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.cassandraShellTemporarilyDownMsg); - } else if (btn.commandButtonLabel.indexOf("Mongo") !== -1) { - applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.mongoShellTemporarilyDownMsg); - } else { - applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.temporarilyDownMsg); } + } else if (btn.commandButtonLabel.indexOf("Mongo") !== -1) { + if (!useNotebook.getState().isPhoenixFeatures) { + applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.mongoShellTemporarilyDownMsg); + } + } else if (!useNotebook.getState().isPhoenixNotebooks) { + applyNotebooksTemporarilyDownStyle(btn, Constants.Notebook.temporarilyDownMsg); } buttons.push(btn); }); } else { - if (!isRunningOnNationalCloud() && !userContext.features.notebooksTemporarilyDown) { + if (!isRunningOnNationalCloud() && useNotebook.getState().isPhoenixNotebooks) { buttons.push(createDivider()); buttons.push(createEnableNotebooksButton(container)); } @@ -166,9 +170,7 @@ export function createContextCommandBarButtons( onCommandClick: () => { const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); if (useNotebook.getState().isShellEnabled) { - if (!userContext.features.notebooksTemporarilyDown) { - container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); - } + container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { selectedCollection && selectedCollection.onNewMongoShellClick(); } @@ -176,13 +178,6 @@ export function createContextCommandBarButtons( commandButtonLabel: label, ariaLabel: label, hasPopup: true, - tooltipText: - useNotebook.getState().isShellEnabled && userContext.features.notebooksTemporarilyDown - ? Constants.Notebook.mongoShellTemporarilyDownMsg - : undefined, - disabled: - (selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") || - (useNotebook.getState().isShellEnabled && userContext.features.notebooksTemporarilyDown), }; buttons.push(newMongoShellBtn); } @@ -278,10 +273,6 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo return undefined; } - if (isServerlessAccount()) { - return undefined; - } - if (userContext?.databaseAccount?.properties?.enableAnalyticalStorage) { return undefined; } @@ -308,8 +299,13 @@ function createNewDatabase(container: Explorer): CommandButtonComponentProps { return { iconSrc: AddDatabaseIcon, iconAlt: label, - onCommandClick: () => - useSidePanel.getState().openSidePanel("New " + getDatabaseName(), ), + onCommandClick: async () => { + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap !== -1) { + await useDatabases.getState().loadAllOffers(); + } + useSidePanel.getState().openSidePanel("New " + getDatabaseName(), ); + }, commandButtonLabel: label, ariaLabel: label, hasPopup: true, diff --git a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx index 631bd1b32..520608acd 100644 --- a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx +++ b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx @@ -69,7 +69,10 @@ export const ConnectionStatus: React.FC = ({ container }: Props): JSX.Ele }, [isActive, counter]); React.useEffect(() => { - if (connectionInfo && connectionInfo.status === ConnectionStatusType.Reconnect) { + 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]); @@ -102,6 +105,7 @@ export const ConnectionStatus: React.FC = ({ container }: Props): JSX.Ele } if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connecting && isActive === false) { + stopTimer(); setIsActive(true); setStatusColor("status connecting is-animating"); setToolTipContent("Connecting to temporary workspace."); @@ -118,8 +122,7 @@ export const ConnectionStatus: React.FC = ({ container }: Props): JSX.Ele <> { + this.getStore().dispatch( + actions.save({ + contentRef: this.contentRef, + }) + ); + }, + "Cancel", + undefined, + this.getSaveNotebookSubText() + ); + } else { + this.getStore().dispatch( + actions.save({ + contentRef: this.contentRef, + }) + ); + } } public notebookChangeKernel(kernelSpecName: string): void { @@ -341,4 +369,19 @@ export class NotebookComponentBootstrapper { protected getStore(): Store { return this.notebookClient.getStore(); } + + private getSaveNotebookSubText(): JSX.Element { + return ( + <> +

{Notebook.saveNotebookModalContent}

+
+

+ {Notebook.newNotebookModalContent2} + + {Notebook.learnMore} + +

+ + ); + } } diff --git a/src/Explorer/Notebook/NotebookComponent/epics.ts b/src/Explorer/Notebook/NotebookComponent/epics.ts index da2bc35a8..22f214bbc 100644 --- a/src/Explorer/Notebook/NotebookComponent/epics.ts +++ b/src/Explorer/Notebook/NotebookComponent/epics.ts @@ -12,11 +12,12 @@ import { ServerConfig as JupyterServerConfig, } from "@nteract/core"; import { Channels, childOf, createMessage, JupyterMessage, message, ofMessageType } from "@nteract/messaging"; +import { defineConfigOption } from "@nteract/mythic-configuration"; import { RecordOf } from "immutable"; -import { AnyAction } from "redux"; +import { Action, AnyAction } from "redux"; import { ofType, StateObservable } from "redux-observable"; import { kernels, sessions } from "rx-jupyter"; -import { concat, EMPTY, from, merge, Observable, Observer, of, Subject, Subscriber, timer } from "rxjs"; +import { concat, EMPTY, from, interval, merge, Observable, Observer, of, Subject, Subscriber, timer } from "rxjs"; import { catchError, concatMap, @@ -41,7 +42,7 @@ import { logConsoleError, logConsoleInfo } from "../../../Utils/NotificationCons import { useDialog } from "../../Controls/Dialog"; import * as FileSystemUtil from "../FileSystemUtil"; import * as cdbActions from "../NotebookComponent/actions"; -import { NotebookUtil } from "../NotebookUtil"; +import { NotebookContentProviderType, NotebookUtil } from "../NotebookUtil"; import * as CdbActions from "./actions"; import * as TextFile from "./contents/file/text-file"; import { CdbAppState } from "./types"; @@ -948,6 +949,54 @@ const resetCellStatusOnExecuteCanceledEpic = ( ); }; +const { selector: autoSaveInterval } = defineConfigOption({ + key: "autoSaveInterval", + label: "Auto-save interval", + defaultValue: 120_000, +}); + +/** + * Override autoSaveCurrentContentEpic to disable auto save for notebooks under temporary workspace. + * @param action$ + */ +export function autoSaveCurrentContentEpic( + action$: Observable, + state$: StateObservable +): Observable { + return state$.pipe( + map((state) => autoSaveInterval(state)), + switchMap((time) => interval(time)), + mergeMap(() => { + const state = state$.value; + return from( + selectors + .contentByRef(state) + .filter( + /* + * Only save contents that are files or notebooks with + * a filepath already set. + */ + (content) => (content.type === "file" || content.type === "notebook") && content.filepath !== "" + ) + .keys() + ); + }), + filter((contentRef: ContentRef) => { + const model = selectors.model(state$.value, { contentRef }); + const content = selectors.content(state$.value, { contentRef }); + if ( + model && + model.type === "notebook" && + NotebookUtil.getContentProviderType(content.filepath) !== NotebookContentProviderType.JupyterContentProviderType + ) { + return selectors.notebook.isDirty(model); + } + return false; + }), + map((contentRef: ContentRef) => actions.save({ contentRef })) + ); +} + export const allEpics = [ addInitialCodeCellEpic, focusInitialCodeCellEpic, @@ -965,4 +1014,5 @@ export const allEpics = [ traceNotebookInfoEpic, traceNotebookKernelEpic, resetCellStatusOnExecuteCanceledEpic, + autoSaveCurrentContentEpic, ]; diff --git a/src/Explorer/Notebook/NotebookComponent/store.ts b/src/Explorer/Notebook/NotebookComponent/store.ts index d0f49f99e..fdba9d452 100644 --- a/src/Explorer/Notebook/NotebookComponent/store.ts +++ b/src/Explorer/Notebook/NotebookComponent/store.ts @@ -1,12 +1,12 @@ -import { AppState, epics as coreEpics, reducers, IContentProvider } from "@nteract/core"; -import { compose, Store, AnyAction, Middleware, Dispatch, MiddlewareAPI } from "redux"; -import { Epic } from "redux-observable"; -import { allEpics } from "./epics"; -import { coreReducer, cdbReducer } from "./reducers"; -import { catchError } from "rxjs/operators"; -import { Observable } from "rxjs"; +import { AppState, epics as coreEpics, IContentProvider, reducers } from "@nteract/core"; import { configuration } from "@nteract/mythic-configuration"; import { makeConfigureStore } from "@nteract/myths"; +import { AnyAction, compose, Dispatch, Middleware, MiddlewareAPI, Store } from "redux"; +import { Epic } from "redux-observable"; +import { Observable } from "rxjs"; +import { catchError } from "rxjs/operators"; +import { allEpics } from "./epics"; +import { cdbReducer, coreReducer } from "./reducers"; import { CdbAppState } from "./types"; const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -81,7 +81,6 @@ export const getCoreEpics = (autoStartKernelOnNotebookOpen: boolean): Epic[] => // This list needs to be consistent and in sync with core.allEpics until we figure // out how to safely filter out the ones we are overriding here. const filteredCoreEpics = [ - coreEpics.autoSaveCurrentContentEpic, coreEpics.executeCellEpic, coreEpics.executeFocusedCellEpic, coreEpics.executeCellAfterKernelLaunchEpic, diff --git a/src/Explorer/Notebook/NotebookContainerClient.ts b/src/Explorer/Notebook/NotebookContainerClient.ts index c0c02023c..8b3c5500a 100644 --- a/src/Explorer/Notebook/NotebookContainerClient.ts +++ b/src/Explorer/Notebook/NotebookContainerClient.ts @@ -1,32 +1,32 @@ /** * Notebook container related stuff */ +import promiseRetry, { AbortError } from "p-retry"; import { PhoenixClient } from "Phoenix/PhoenixClient"; import * as Constants from "../../Common/Constants"; -import { ConnectionStatusType, HttpHeaders } from "../../Common/Constants"; +import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook } from "../../Common/Constants"; import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; import * as Logger from "../../Common/Logger"; import * as DataModels from "../../Contracts/DataModels"; -import { - ContainerConnectionInfo, - IPhoenixConnectionInfoResult, - IProvisionData, - IResponse, -} from "../../Contracts/DataModels"; +import { IPhoenixConnectionInfoResult, IProvisionData, IResponse } from "../../Contracts/DataModels"; import { userContext } from "../../UserContext"; -import { createOrUpdate, destroy } from "../../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces"; import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; -import { NotebookUtil } from "./NotebookUtil"; import { useNotebook } from "./useNotebook"; export class NotebookContainerClient { private clearReconnectionAttemptMessage? = () => {}; private isResettingWorkspace: boolean; private phoenixClient: PhoenixClient; + private retryOptions: promiseRetry.Options; constructor(private onConnectionLost: () => void) { this.phoenixClient = new PhoenixClient(); + this.retryOptions = { + retries: Notebook.retryAttempts, + maxTimeout: Notebook.retryAttemptDelayMs, + minTimeout: Notebook.retryAttemptDelayMs, + }; const notebookServerInfo = useNotebook.getState().notebookServerInfo; if (notebookServerInfo?.notebookServerEndpoint) { this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs); @@ -47,10 +47,13 @@ export class NotebookContainerClient { * Heartbeat: each ping schedules another ping */ private scheduleHeartbeat(delayMs: number): void { - setTimeout(() => { - this.getMemoryUsage() - .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)) - .finally(() => this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs)); + setTimeout(async () => { + const memoryUsageInfo = await this.getMemoryUsage(); + useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo); + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + if (notebookServerInfo?.notebookServerEndpoint) { + this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs); + } }, delayMs); } @@ -68,29 +71,10 @@ export class NotebookContainerClient { const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig(); try { - if (this.checkStatus()) { - 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, - }; - } - } - } - return undefined; + 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) { @@ -98,30 +82,49 @@ export class NotebookContainerClient { "Connection lost with Notebook server. Attempting to reconnect..." ); } - if (NotebookUtil.isPhoenixEnabled()) { - const connectionStatus: ContainerConnectionInfo = { - status: ConnectionStatusType.Failed, - }; - useNotebook.getState().resetContainerConnection(connectionStatus); - useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - } this.onConnectionLost(); return undefined; } } - private checkStatus(): boolean { - if (NotebookUtil.isPhoenixEnabled()) { - if (useNotebook.getState().containerStatus?.status === Constants.ContainerStatusType.Disconnected) { - const connectionStatus: ContainerConnectionInfo = { - status: ConnectionStatusType.Reconnect, - }; - useNotebook.getState().resetContainerConnection(connectionStatus); - useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - return false; + 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; } - return true; + } + + private shouldExecuteMemoryCall(): boolean { + return ( + useNotebook.getState().containerStatus?.status === Constants.ContainerStatusType.Active && + useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected + ); } public async resetWorkspace(): Promise> { @@ -146,12 +149,8 @@ export class NotebookContainerClient { } try { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks) { const provisionData: IProvisionData = { - aadToken: userContext.authorizationToken, - subscriptionId: userContext.subscriptionId, - resourceGroup: userContext.resourceGroup, - dbAccountName: userContext.databaseAccount.name, cosmosEndpoint: userContext.databaseAccount.properties.documentEndpoint, }; return await this.phoenixClient.resetContainer(provisionData); @@ -159,9 +158,6 @@ export class NotebookContainerClient { return null; } catch (error) { Logger.logError(getErrorMessage(error), "NotebookContainerClient/resetWorkspace"); - if (!NotebookUtil.isPhoenixEnabled()) { - await this.recreateNotebookWorkspaceAsync(); - } throw error; } } @@ -176,25 +172,6 @@ export class NotebookContainerClient { }; } - private async recreateNotebookWorkspaceAsync(): Promise { - const { databaseAccount } = userContext; - if (!databaseAccount?.id) { - throw new Error("DataExplorer not initialized"); - } - try { - await destroy(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name, "default"); - await createOrUpdate( - userContext.subscriptionId, - userContext.resourceGroup, - userContext.databaseAccount.name, - "default" - ); - } catch (error) { - Logger.logError(getErrorMessage(error), "NotebookContainerClient/recreateNotebookWorkspaceAsync"); - return Promise.reject(error); - } - } - private getHeaders(): HeadersInit { const authorizationHeader = getAuthorizationHeader(); return { diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index 68d562f38..b44719beb 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -3,14 +3,19 @@ import { AppState, selectors } from "@nteract/core"; import domtoimage from "dom-to-image"; import Html2Canvas from "html2canvas"; import path from "path"; -import { userContext } from "../../UserContext"; import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as StringUtils from "../../Utils/StringUtils"; +import * as InMemoryContentProviderUtils from "../Notebook/NotebookComponent/ContentProviders/InMemoryContentProviderUtils"; import { SnapshotFragment } from "./NotebookComponent/types"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; // Must match rx-jupyter' FileType export type FileType = "directory" | "file" | "notebook"; +export enum NotebookContentProviderType { + GitHubContentProviderType, + InMemoryContentProviderType, + JupyterContentProviderType, +} // Utilities for notebooks export class NotebookUtil { public static UntrustedNotebookRunHint = "Please trust notebook first before running any code cells"; @@ -127,6 +132,18 @@ export class NotebookUtil { return relativePath.split("/").pop(); } + public static getContentProviderType(path: string): NotebookContentProviderType { + if (InMemoryContentProviderUtils.fromContentUri(path)) { + return NotebookContentProviderType.InMemoryContentProviderType; + } + + if (GitHubUtils.fromContentUri(path)) { + return NotebookContentProviderType.GitHubContentProviderType; + } + + return NotebookContentProviderType.JupyterContentProviderType; + } + public static replaceName(path: string, newName: string): string { const contentInfo = GitHubUtils.fromContentUri(path); if (contentInfo) { @@ -329,16 +346,4 @@ export class NotebookUtil { link.click(); document.body.removeChild(link); } - - public static getNotebookBtnTitle(fileName: string): string { - if (this.isPhoenixEnabled()) { - return `Download to ${fileName}`; - } else { - return `Download to my notebooks`; - } - } - - public static isPhoenixEnabled(): boolean { - return userContext.features.notebooksTemporarilyDown === false && userContext.features.phoenix === true; - } } diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts index b4f90bfb0..1515f281f 100644 --- a/src/Explorer/Notebook/useNotebook.ts +++ b/src/Explorer/Notebook/useNotebook.ts @@ -1,4 +1,6 @@ +import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { cloneDeep } from "lodash"; +import { PhoenixClient } from "Phoenix/PhoenixClient"; import create, { UseStore } from "zustand"; import { AuthType } from "../../AuthType"; import * as Constants from "../../Common/Constants"; @@ -17,7 +19,6 @@ import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import NotebookManager from "./NotebookManager"; -import { NotebookUtil } from "./NotebookUtil"; interface NotebookState { isNotebookEnabled: boolean; @@ -37,6 +38,8 @@ interface NotebookState { isAllocating: boolean; isRefreshed: boolean; containerStatus: ContainerInfo; + isPhoenixNotebooks: boolean; + isPhoenixFeatures: boolean; setIsNotebookEnabled: (isNotebookEnabled: boolean) => void; setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void; setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void; @@ -58,6 +61,9 @@ interface NotebookState { 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) => ({ @@ -92,6 +98,8 @@ export const useNotebook: UseStore = create((set, get) => ({ durationLeftInMinutes: undefined, notebookServerInfo: undefined, }, + isPhoenixNotebooks: undefined, + isPhoenixFeatures: undefined, setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }), setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }), setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => @@ -104,6 +112,7 @@ export const useNotebook: UseStore = create((set, get) => ({ setNotebookBasePath: (notebookBasePath: string) => set({ notebookBasePath }), setNotebookFolderName: (notebookFolderName: string) => set({ notebookFolderName }), refreshNotebooksEnabledStateForAccount: async (): Promise => { + await get().getPhoenixStatus(); const { databaseAccount, authType } = userContext; if ( authType === AuthType.EncryptedToken || @@ -196,7 +205,7 @@ export const useNotebook: UseStore = create((set, get) => ({ isGithubTree ? set({ gitHubNotebooksContentRoot: root }) : set({ myNotebooksContentRoot: root }); }, initializeNotebooksTree: async (notebookManager: NotebookManager): Promise => { - const notebookFolderName = NotebookUtil.isPhoenixEnabled() === true ? "Temporary Notebooks" : "My Notebooks"; + const notebookFolderName = get().isPhoenixNotebooks ? "Temporary Notebooks" : "My Notebooks"; set({ notebookFolderName }); const myNotebooksContentRoot = { name: get().notebookFolderName, @@ -292,4 +301,21 @@ export const useNotebook: UseStore = create((set, get) => ({ }, setIsRefreshed: (isRefreshed: boolean) => set({ isRefreshed }), setContainerStatus: (containerStatus: ContainerInfo) => set({ containerStatus }), + getPhoenixStatus: async () => { + if (get().isPhoenixNotebooks === undefined || get().isPhoenixFeatures === undefined) { + let isPhoenix = false; + if (userContext.features.phoenixNotebooks || userContext.features.phoenixFeatures) { + const phoenixClient = new PhoenixClient(); + isPhoenix = isPublicInternetAccessAllowed() && (await phoenixClient.isDbAcountWhitelisted()); + } + + const isPhoenixNotebooks = userContext.features.phoenixNotebooks && isPhoenix; + const isPhoenixFeatures = userContext.features.phoenixFeatures && isPhoenix; + + set({ isPhoenixNotebooks: isPhoenixNotebooks }); + set({ isPhoenixFeatures: isPhoenixFeatures }); + } + }, + setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => set({ isPhoenixNotebooks: isPhoenixNotebooks }), + setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => set({ isPhoenixFeatures: isPhoenixFeatures }), })); diff --git a/src/Explorer/OpenFullScreen.tsx b/src/Explorer/OpenFullScreen.tsx index 7dabbc107..d9f4f9508 100644 --- a/src/Explorer/OpenFullScreen.tsx +++ b/src/Explorer/OpenFullScreen.tsx @@ -25,6 +25,7 @@ export const OpenFullScreen: React.FunctionComponent = () => { { copyToClipboard(readWriteUrl); setIsReadWriteUrlCopy(true); @@ -43,6 +44,7 @@ export const OpenFullScreen: React.FunctionComponent = () => { { setIsReadUrlCopy(true); copyToClipboard(readUrl); diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index a5d6651d5..1722d97fd 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -279,7 +279,7 @@ export class AddCollectionPanel extends React.Component - {`${getCollectionName()} ${userContext.apiType === "Mongo" ? "name" : "id"}`} + {`${getCollectionName()} id`} = ({ explorer: container, + buttonElement, }: AddDatabasePaneProps) => { const closeSidePanel = useSidePanel((state) => state.closeSidePanel); let throughput: number; @@ -78,6 +80,9 @@ export const AddDatabasePanel: FunctionComponent = ({ dataExplorerArea: Constants.Areas.ContextualPane, }; TelemetryProcessor.trace(Action.CreateDatabase, ActionModifiers.Open, addDatabasePaneOpenMessage); + if (buttonElement) { + buttonElement.focus(); + } }, []); const onSubmit = () => { diff --git a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx index 9aa4faf6b..c2b310500 100644 --- a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx +++ b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx @@ -334,7 +334,7 @@ export const CassandraAddCollectionPane: FunctionComponent (tableThroughput = throughput)} setIsAutoscale={(isAutoscale: boolean) => (isTableAutoscale = isAutoscale)} setIsThroughputCapExceeded={(isCapExceeded: boolean) => setIsThroughputCapExceeded(isCapExceeded)} diff --git a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx index 960f25845..39351c948 100644 --- a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx +++ b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx @@ -5,7 +5,6 @@ import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { IPinnedRepo, JunoClient } from "../../../Juno/JunoClient"; -import { userContext } from "../../../UserContext"; import * as GitHubUtils from "../../../Utils/GitHubUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import Explorer from "../../Explorer"; @@ -76,7 +75,7 @@ export const CopyNotebookPane: FunctionComponent = ({ selectedLocation.owner, selectedLocation.repo )} - ${selectedLocation.branch}`; - } else if (selectedLocation.type === "MyNotebooks" && userContext.features.phoenix) { + } else if (selectedLocation.type === "MyNotebooks" && useNotebook.getState().isPhoenixNotebooks) { destination = useNotebook.getState().notebookFolderName; } diff --git a/src/Explorer/Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane.tsx b/src/Explorer/Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane.tsx index 81eeb6621..bdd16764b 100644 --- a/src/Explorer/Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane.tsx +++ b/src/Explorer/Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane.tsx @@ -38,7 +38,7 @@ export const DeleteCollectionConfirmationPane: FunctionComponent => { const collection = useSelectedNode.getState().findSelectedCollection(); if (!collection || inputCollectionName !== collection.id()) { - const errorMessage = "Input " + collectionName + " name does not match the selected " + collectionName; + const errorMessage = "Input " + collectionName + " id does not match the selected " + collectionName; setFormError(errorMessage); NotificationConsoleUtils.logConsoleError( `Error while deleting ${collectionName} ${collection.id()}: ${errorMessage}` diff --git a/src/Explorer/Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane.tsx b/src/Explorer/Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane.tsx index 064aadd5a..81653a025 100644 --- a/src/Explorer/Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane.tsx +++ b/src/Explorer/Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane.tsx @@ -1,6 +1,6 @@ import { IDropdownOption, IImageProps, Image, Stack, Text } from "@fluentui/react"; import { useBoolean } from "@fluentui/react-hooks"; -import React, { FunctionComponent, useState } from "react"; +import React, { FunctionComponent, useRef, useState } from "react"; import AddPropertyIcon from "../../../../images/Add-property.svg"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { logConsoleError } from "../../../Utils/NotificationConsoleUtils"; @@ -25,19 +25,16 @@ interface UnwrappedExecuteSprocParam { export const ExecuteSprocParamsPane: FunctionComponent = ({ storedProcedure, }: ExecuteSprocParamsPaneProps): JSX.Element => { + const paramKeyValuesRef = useRef([{ key: "string", text: "" }]); + const partitionValueRef = useRef(); + const partitionKeyRef = useRef("string"); const closeSidePanel = useSidePanel((state) => state.closeSidePanel); + const [numberOfParams, setNumberOfParams] = useState(1); const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false); - const [paramKeyValues, setParamKeyValues] = useState([{ key: "string", text: "" }]); - const [partitionValue, setPartitionValue] = useState(); // Defaulting to undefined here is important. It is not the same partition key as "" - const [selectedKey, setSelectedKey] = React.useState({ key: "string", text: "" }); const [formError, setFormError] = useState(""); - const onPartitionKeyChange = (event: React.FormEvent, item: IDropdownOption): void => { - setSelectedKey(item); - }; - const validateUnwrappedParams = (): boolean => { - const unwrappedParams: UnwrappedExecuteSprocParam[] = paramKeyValues; + const unwrappedParams: UnwrappedExecuteSprocParam[] = paramKeyValuesRef.current; for (let i = 0; i < unwrappedParams.length; i++) { const { key: paramType, text: paramValue } = unwrappedParams[i]; if (paramType === "custom" && (paramValue === "" || paramValue === undefined)) { @@ -53,8 +50,9 @@ export const ExecuteSprocParamsPane: FunctionComponent { - const wrappedSprocParams: UnwrappedExecuteSprocParam[] = paramKeyValues; - const { key: partitionKey } = selectedKey; + const wrappedSprocParams: UnwrappedExecuteSprocParam[] = paramKeyValuesRef.current; + const partitionValue: string = partitionValueRef.current; + const partitionKey: string = partitionKeyRef.current; if (partitionKey === "custom" && (partitionValue === "" || partitionValue === undefined)) { setInvalidParamError(partitionValue); return; @@ -78,37 +76,21 @@ export const ExecuteSprocParamsPane: FunctionComponent { - const cloneParamKeyValue = [...paramKeyValues]; - cloneParamKeyValue.splice(indexToRemove, 1); - setParamKeyValues(cloneParamKeyValue); + paramKeyValuesRef.current.splice(indexToRemove, 1); + setNumberOfParams(numberOfParams - 1); }; const addNewParamAtIndex = (indexToAdd: number): void => { - const cloneParamKeyValue = [...paramKeyValues]; - cloneParamKeyValue.splice(indexToAdd, 0, { key: "string", text: "" }); - setParamKeyValues(cloneParamKeyValue); - }; - - const paramValueChange = (value: string, indexOfInput: number): void => { - const cloneParamKeyValue = [...paramKeyValues]; - cloneParamKeyValue[indexOfInput].text = value; - setParamKeyValues(cloneParamKeyValue); - }; - - const paramKeyChange = ( - _event: React.FormEvent, - selectedParam: IDropdownOption, - indexOfParam: number - ): void => { - const cloneParamKeyValue = [...paramKeyValues]; - cloneParamKeyValue[indexOfParam].key = selectedParam.key.toString(); - setParamKeyValues(cloneParamKeyValue); + paramKeyValuesRef.current.splice(indexToAdd, 0, { key: "string", text: "" }); + setNumberOfParams(numberOfParams + 1); }; const addNewParamAtLastIndex = (): void => { - const cloneParamKeyValue = [...paramKeyValues]; - cloneParamKeyValue.splice(cloneParamKeyValue.length, 0, { key: "string", text: "" }); - setParamKeyValues(cloneParamKeyValue); + paramKeyValuesRef.current.push({ + key: "string", + text: "", + }); + setNumberOfParams(numberOfParams + 1); }; const props: RightPaneFormProps = { @@ -118,46 +100,52 @@ export const ExecuteSprocParamsPane: FunctionComponent submit(), }; + const getInputParameterComponent = (): JSX.Element[] => { + const inputParameters: JSX.Element[] = []; + for (let i = 0; i < numberOfParams; i++) { + const paramKeyValue = paramKeyValuesRef.current[i]; + inputParameters.push( + deleteParamAtIndex(i)} + onAddNewParamKeyPress={() => addNewParamAtIndex(i + 1)} + onParamValueChange={(_event, newInput?: string) => (paramKeyValuesRef.current[i].text = newInput)} + onParamKeyChange={(_event, selectedParam: IDropdownOption) => + (paramKeyValuesRef.current[i].key = selectedParam.key.toString()) + } + paramValue={paramKeyValue.text} + selectedKey={paramKeyValue.key} + /> + ); + } + + return inputParameters; + }; + return ( -
-
- { - setPartitionValue(newInput); - }} - onParamKeyChange={onPartitionKeyChange} - paramValue={partitionValue} - selectedKey={selectedKey.key} - /> - {paramKeyValues.map((paramKeyValue, index) => ( - deleteParamAtIndex(index)} - onAddNewParamKeyPress={() => addNewParamAtIndex(index + 1)} - onParamValueChange={(event, newInput?: string) => { - paramValueChange(newInput, index); - }} - onParamKeyChange={(event: React.FormEvent, selectedParam: IDropdownOption) => { - paramKeyChange(event, selectedParam, index); - }} - paramValue={paramKeyValue && paramKeyValue.text} - selectedKey={paramKeyValue && paramKeyValue.key} - /> - ))} - - Add param - Add New Param - -
+
+ (partitionValueRef.current = newInput)} + onParamKeyChange={(_event: React.FormEvent, item: IDropdownOption) => + (partitionKeyRef.current = item.key.toString()) + } + paramValue={partitionValueRef.current} + selectedKey={partitionKeyRef.current} + /> + {getInputParameterComponent()} + addNewParamAtLastIndex()} tabIndex={0}> + Add param + Add New Param +
); diff --git a/src/Explorer/Panes/ExecuteSprocParamsPane/InputParameter.tsx b/src/Explorer/Panes/ExecuteSprocParamsPane/InputParameter.tsx index 677158b61..6fcea9328 100644 --- a/src/Explorer/Panes/ExecuteSprocParamsPane/InputParameter.tsx +++ b/src/Explorer/Panes/ExecuteSprocParamsPane/InputParameter.tsx @@ -55,7 +55,7 @@ export const InputParameter: FunctionComponent = ({ = ({ {isAddRemoveVisible && ( <> diff --git a/src/Explorer/Panes/ExecuteSprocParamsPane/__snapshots__/ExecuteSprocParamsPane.test.tsx.snap b/src/Explorer/Panes/ExecuteSprocParamsPane/__snapshots__/ExecuteSprocParamsPane.test.tsx.snap index 3f163902a..e5bc2cbef 100644 --- a/src/Explorer/Panes/ExecuteSprocParamsPane/__snapshots__/ExecuteSprocParamsPane.test.tsx.snap +++ b/src/Explorer/Panes/ExecuteSprocParamsPane/__snapshots__/ExecuteSprocParamsPane.test.tsx.snap @@ -17,4983 +17,351 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = ` onSubmit={[Function]} >
-
- - - + - - - - -
- - - -
- - - - - - -
-
-
-
- - -
-
- - - - - -
- -
-
-
-
-
-
-
-
- - - - - - - -
- - - -
- - - - - - -
-
-
-
- - -
-
- - - - - -
- -
-
-
-
-
-
- - -
- Delete param -
-
-
-
-
- - -
- Add param -
-
-
-
-
-
-
+ Partition key value + + +
- - -
- Add param -
-
-
- + + + + + + +
+ + + + - - Add New Param - - +
+
+ + + + + +
+ +
+
+
+ +
-
+ + + + + + + + +
+ + + +
+ + + + + + +
+
+
+
+ + +
+
+ + + + + +
+ +
+
+
+
+
+
+ + +
+ Delete param +
+
+
+
+
+ + +
+ Add param +
+
+
+
+
+
+
+ +
+ + +
+ Add param +
+
+
+ + + Add New Param + + +
+
{ const handleOnPageOptionChange = (ev: React.FormEvent, option: IChoiceGroupOption): void => { setPageOption(option.key); }; + + const choiceButtonStyles = { + root: { + clear: "both", + }, + flexContainer: [ + { + selectors: { + ".ms-ChoiceFieldGroup root-133": { + clear: "both", + }, + ".ms-ChoiceField-wrapper label": { + fontSize: 12, + paddingTop: 0, + }, + ".ms-ChoiceField": { + marginTop: 0, + }, + }, + }, + ], + }; return (
{shouldShowQueryPageOptions && (
-
-
- Page options +
+
+ + Page Options + Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page. -
- + +
{isCustomPageOptionSelected() && ( diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index ccc2ad3a7..928e7f7a7 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -14,32 +14,59 @@ exports[`Settings Pane should render Default properly 1`] = ` className="settingsSection" >
-
- Page options +
+ + Page Options + Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page. -
- + "flexContainer": Array [ + Object { + "selectors": Object { + ".ms-ChoiceField": Object { + "marginTop": 0, + }, + ".ms-ChoiceField-wrapper label": Object { + "fontSize": 12, + "paddingTop": 0, + }, + ".ms-ChoiceFieldGroup root-133": Object { + "clear": "both", + }, + }, + }, + ], + "root": Object { + "clear": "both", + }, + } + } + /> +
{ const mainItems = this.createMainItems(); const commonTaskItems = this.createCommonTaskItems(); let recentItems = this.createRecentItems(); - if (userContext.features.notebooksTemporarilyDown) { - recentItems = recentItems.filter((item) => item.description !== "Notebook"); - } + recentItems = recentItems.filter((item) => item.description !== "Notebook"); const tipsItems = this.createTipsItems(); const onClearRecent = this.clearMostRecent; @@ -223,7 +221,7 @@ export class SplashScreen extends React.Component { }); } - if (useNotebook.getState().isNotebookEnabled && !userContext.features.notebooksTemporarilyDown) { + if (useNotebook.getState().isPhoenixNotebooks) { heroes.push({ iconSrc: NewNotebookIcon, title: "New Notebook", @@ -307,10 +305,18 @@ export class SplashScreen extends React.Component { iconSrc: AddDatabaseIcon, title: "New " + getDatabaseName(), description: undefined, - onClick: () => + onClick: async () => { + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap !== -1) { + await useDatabases.getState().loadAllOffers(); + } useSidePanel .getState() - .openSidePanel("New " + getDatabaseName(), ), + .openSidePanel( + "New " + getDatabaseName(), + + ); + }, }); } diff --git a/src/Explorer/Tables/Constants.ts b/src/Explorer/Tables/Constants.ts index 9bb313b7a..7a2aa15e1 100644 --- a/src/Explorer/Tables/Constants.ts +++ b/src/Explorer/Tables/Constants.ts @@ -14,11 +14,13 @@ export const CassandraType = { Bigint: "Bigint", Blob: "Blob", Boolean: "Boolean", + Date: "Date", Decimal: "Decimal", Double: "Double", Float: "Float", Int: "Int", Text: "Text", + Timestamp: "Timestamp", Uuid: "Uuid", Varchar: "Varchar", Varint: "Varint", diff --git a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts index f11730b2b..1f1a90d9d 100644 --- a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts +++ b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts @@ -431,7 +431,7 @@ export default class TableEntityListViewModel extends DataTableViewModel { if (newHeaders.length > 0) { // Any new columns found will be added into headers array, which will trigger a re-render of the DataTable. // So there is no need to call it here. - this.updateHeaders(newHeaders, /* notifyColumnChanges */ true); + this.updateHeaders(selectedHeadersUnion, /* notifyColumnChanges */ true); } else { if (columnSortOrder) { this.sortColumns(columnSortOrder, oSettings); diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index afe31b711..cc3ea8295 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -535,7 +535,9 @@ export class CassandraAPIDataClient extends TableDataClient { dataType === TableConstants.CassandraType.Text || dataType === TableConstants.CassandraType.Inet || dataType === TableConstants.CassandraType.Ascii || - dataType === TableConstants.CassandraType.Varchar + dataType === TableConstants.CassandraType.Varchar || + dataType === TableConstants.CassandraType.Timestamp || + dataType === TableConstants.CassandraType.Date ); } diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 01865246f..605122eee 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -364,13 +364,11 @@ export default class QueryTabComponent extends React.Component { - if (!this.isCloseClicked) { - useCommandBar.getState().setContextButtons(this.getTabsButtons()); - } else { - this.isCloseClicked = false; - } - }, 0); + if (!this.isCloseClicked) { + useCommandBar.getState().setContextButtons(this.getTabsButtons()); + } else { + this.isCloseClicked = false; + } } public onExecuteQueryClick = async (): Promise => { @@ -875,9 +873,11 @@ export default class QueryTabComponent extends React.Component
diff --git a/src/Explorer/Tabs/TerminalTab.tsx b/src/Explorer/Tabs/TerminalTab.tsx index 6f318f3cf..1c010d6b3 100644 --- a/src/Explorer/Tabs/TerminalTab.tsx +++ b/src/Explorer/Tabs/TerminalTab.tsx @@ -25,7 +25,8 @@ class NotebookTerminalComponentAdapter implements ReactAdapter { public parameters: ko.Computed; constructor( private getNotebookServerInfo: () => DataModels.NotebookWorkspaceConnectionInfo, - private getDatabaseAccount: () => DataModels.DatabaseAccount + private getDatabaseAccount: () => DataModels.DatabaseAccount, + private getTabId: () => string ) {} public renderComponent(): JSX.Element { @@ -33,6 +34,7 @@ class NotebookTerminalComponentAdapter implements ReactAdapter { ) : ( @@ -50,7 +52,8 @@ export default class TerminalTab extends TabsBase { this.container = options.container; this.notebookTerminalComponentAdapter = new NotebookTerminalComponentAdapter( () => this.getNotebookServerInfo(options), - () => userContext?.databaseAccount + () => userContext?.databaseAccount, + () => this.tabId ); this.notebookTerminalComponentAdapter.parameters = ko.computed(() => { if ( diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index fd444c0ed..31156c61c 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -1,5 +1,5 @@ import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; -import { NotebookUtil } from "Explorer/Notebook/NotebookUtil"; +import { useNotebook } from "Explorer/Notebook/useNotebook"; import * as ko from "knockout"; import * as _ from "underscore"; import * as Constants from "../../Common/Constants"; @@ -529,7 +529,7 @@ export default class Collection implements ViewModels.Collection { }; public onSchemaAnalyzerClick = async () => { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixFeatures) { await this.container.allocateContainer(); } useSelectedNode.getState().setSelectedNode(this); @@ -576,7 +576,8 @@ export default class Collection implements ViewModels.Collection { public onSettingsClick = async (): Promise => { useSelectedNode.getState().setSelectedNode(this); - await this.loadOffer(); + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + throughputCap && throughputCap !== -1 ? await useDatabases.getState().loadAllOffers() : await this.loadOffer(); this.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings); TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { description: "Settings node", diff --git a/src/Explorer/Tree/Database.tsx b/src/Explorer/Tree/Database.tsx index 5dc01343f..156b5f275 100644 --- a/src/Explorer/Tree/Database.tsx +++ b/src/Explorer/Tree/Database.tsx @@ -57,7 +57,7 @@ export default class Database implements ViewModels.Database { this.isOfferRead = false; } - public onSettingsClick = (): void => { + public onSettingsClick = async (): Promise => { useSelectedNode.getState().setSelectedNode(this); this.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings); TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { @@ -66,6 +66,11 @@ export default class Database implements ViewModels.Database { dataExplorerArea: Constants.Areas.ResourceTree, }); + const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit; + if (throughputCap && throughputCap !== -1) { + await useDatabases.getState().loadAllOffers(); + } + const pendingNotificationsPromise: Promise = this.getPendingThroughputSplitNotification(); const tabKind = ViewModels.CollectionTabKind.DatabaseSettingsV2; const matchingTabs = useTabs.getState().getTabs(tabKind, (tab) => tab.node?.id() === this.id()); diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx index f31a4a52b..181fd07d6 100644 --- a/src/Explorer/Tree/ResourceTree.tsx +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -121,7 +121,7 @@ export const ResourceTree: React.FC = ({ container }: Resourc children: [], }; - if (userContext.features.notebooksTemporarilyDown) { + if (!useNotebook.getState().isPhoenixNotebooks) { notebooksTree.children.push(buildNotebooksTemporarilyDownTree()); } else { if (galleryContentRoot) { @@ -130,9 +130,8 @@ export const ResourceTree: React.FC = ({ container }: Resourc if ( myNotebooksContentRoot && - ((NotebookUtil.isPhoenixEnabled() && - useNotebook.getState().connectionInfo.status === ConnectionStatusType.Connected) || - userContext.features.phoenix === false) + useNotebook.getState().isPhoenixNotebooks && + useNotebook.getState().connectionInfo.status === ConnectionStatusType.Connected ) { notebooksTree.children.push(buildMyNotebooksTree()); } @@ -166,15 +165,7 @@ export const ResourceTree: React.FC = ({ container }: Resourc const myNotebooksTree: TreeNode = buildNotebookDirectoryNode( myNotebooksContentRoot, (item: NotebookContentItem) => { - container.openNotebook(item).then((hasOpened) => { - if ( - hasOpened && - userContext.features.notebooksTemporarilyDown === false && - userContext.features.phoenix === false - ) { - mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item); - } - }); + container.openNotebook(item); } ); @@ -189,15 +180,7 @@ export const ResourceTree: React.FC = ({ container }: Resourc const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode( gitHubNotebooksContentRoot, (item: NotebookContentItem) => { - container.openNotebook(item).then((hasOpened) => { - if ( - hasOpened && - userContext.features.notebooksTemporarilyDown === false && - userContext.features.phoenix === false - ) { - mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item); - } - }); + container.openNotebook(item); }, true ); @@ -397,6 +380,11 @@ export const ResourceTree: React.FC = ({ container }: Resourc }, ]; + //disallow renaming of temporary notebook workspace + if (item?.path === useNotebook.getState().notebookBasePath) { + items = items.filter((item) => item.label !== "Rename"); + } + // For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File" if (GitHubUtils.fromContentUri(item.path)) { items = items.filter( @@ -528,7 +516,7 @@ export const ResourceTree: React.FC = ({ container }: Resourc isNotebookEnabled && userContext.apiType === "Mongo" && isPublicInternetAccessAllowed() && - !userContext.features.notebooksTemporarilyDown + useNotebook.getState().isPhoenixFeatures ) { children.push({ label: "Schema (Preview)", diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index a5e75ded3..1080da312 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -808,6 +808,11 @@ export class ResourceTreeAdapter implements ReactAdapter { }, ]; + //disallow renaming of temporary notebook workspace + if (item?.path === useNotebook.getState().notebookBasePath) { + items = items.filter((item) => item.label !== "Rename"); + } + // For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File" if (GitHubUtils.fromContentUri(item.path)) { items = items.filter( diff --git a/src/Explorer/useDatabases.ts b/src/Explorer/useDatabases.ts index 1f9886a7d..7d1760af3 100644 --- a/src/Explorer/useDatabases.ts +++ b/src/Explorer/useDatabases.ts @@ -18,6 +18,7 @@ interface DatabasesState { findCollection: (databaseId: string, collectionId: string) => ViewModels.Collection; isLastCollection: () => boolean; loadDatabaseOffers: () => Promise; + loadAllOffers: () => Promise; isFirstResourceCreated: () => boolean; findSelectedDatabase: () => ViewModels.Database; validateDatabaseId: (id: string) => boolean; @@ -97,6 +98,19 @@ export const useDatabases: UseStore = create((set, get) => ({ }) ); }, + loadAllOffers: async () => { + await Promise.all( + get().databases?.map(async (database: ViewModels.Database) => { + await database.loadOffer(); + await database.loadCollections(); + await Promise.all( + (database.collections() || []).map(async (collection: ViewModels.Collection) => { + await collection.loadOffer(); + }) + ); + }) + ); + }, isFirstResourceCreated: () => { const databases = get().databases; diff --git a/src/Explorer/useSelectedNode.ts b/src/Explorer/useSelectedNode.ts index 15f953641..acbac86ea 100644 --- a/src/Explorer/useSelectedNode.ts +++ b/src/Explorer/useSelectedNode.ts @@ -1,3 +1,5 @@ +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"; @@ -12,6 +14,7 @@ export interface SelectedNodeState { collectionId?: string, subnodeKinds?: ViewModels.CollectionTabKind[] ) => boolean; + isConnectedToContainer: () => boolean; } export const useSelectedNode: UseStore = create((set, get) => ({ @@ -59,4 +62,7 @@ export const useSelectedNode: UseStore = create((set, get) => subnodeKinds.includes(selectedSubnodeKind) ); }, + isConnectedToContainer: (): boolean => { + return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected; + }, })); diff --git a/src/GitHub/GitHubOAuthService.ts b/src/GitHub/GitHubOAuthService.ts index fb9288c7c..7a41076d7 100644 --- a/src/GitHub/GitHubOAuthService.ts +++ b/src/GitHub/GitHubOAuthService.ts @@ -1,8 +1,8 @@ import ko from "knockout"; import postRobot from "post-robot"; +import { GetGithubClientId } from "Utils/GitHubUtils"; import { HttpStatusCodes } from "../Common/Constants"; import { handleError } from "../Common/ErrorHandlingUtils"; -import { configContext } from "../ConfigContext"; import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; import { JunoClient } from "../Juno/JunoClient"; import { logConsoleInfo } from "../Utils/NotificationConsoleUtils"; @@ -55,7 +55,7 @@ export class GitHubOAuthService { const params = { scope, - client_id: configContext.GITHUB_CLIENT_ID, + client_id: GetGithubClientId(), redirect_uri: new URL("./connectToGitHub.html", window.location.href).href, state: this.resetState(), }; diff --git a/src/Juno/JunoClient.ts b/src/Juno/JunoClient.ts index e7513b6a6..7725e886e 100644 --- a/src/Juno/JunoClient.ts +++ b/src/Juno/JunoClient.ts @@ -1,7 +1,8 @@ import ko from "knockout"; -import { validateEndpoint } from "Utils/EndpointValidation"; +import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation"; +import { GetGithubClientId } from "Utils/GitHubUtils"; import { HttpHeaders, HttpStatusCodes } from "../Common/Constants"; -import { allowedJunoOrigins, configContext } from "../ConfigContext"; +import { configContext } from "../ConfigContext"; import * as DataModels from "../Contracts/DataModels"; import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; import { IGitHubResponse } from "../GitHub/GitHubClient"; @@ -523,7 +524,7 @@ export class JunoClient { private static getGitHubClientParams(): URLSearchParams { const githubParams = new URLSearchParams({ - client_id: configContext.GITHUB_CLIENT_ID, + client_id: GetGithubClientId(), }); if (configContext.GITHUB_CLIENT_SECRET) { diff --git a/src/Phoenix/PhoenixClient.ts b/src/Phoenix/PhoenixClient.ts index eca5adf5b..ae57b5a3e 100644 --- a/src/Phoenix/PhoenixClient.ts +++ b/src/Phoenix/PhoenixClient.ts @@ -1,9 +1,15 @@ -import { validateEndpoint } from "Utils/EndpointValidation"; -import { ContainerStatusType, HttpHeaders, HttpStatusCodes, Notebook } from "../Common/Constants"; +import promiseRetry, { AbortError } from "p-retry"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointValidation"; +import { + Areas, + ConnectionStatusType, ContainerStatusType, HttpHeaders, HttpStatusCodes, Notebook +} from "../Common/Constants"; import { getErrorMessage } from "../Common/ErrorHandlingUtils"; import * as Logger from "../Common/Logger"; -import { allowedJunoOrigins, configContext } from "../ConfigContext"; +import { configContext } from "../ConfigContext"; import { + ContainerConnectionInfo, ContainerInfo, IContainerData, IPhoenixConnectionInfoResult, @@ -11,11 +17,17 @@ import { IResponse } from "../Contracts/DataModels"; import { useNotebook } from "../Explorer/Notebook/useNotebook"; +import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../UserContext"; import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; export class PhoenixClient { private containerHealthHandler: NodeJS.Timeout; + private retryOptions: promiseRetry.Options = { + retries: Notebook.retryAttempts, + maxTimeout: Notebook.retryAttemptDelayMs, + minTimeout: Notebook.retryAttemptDelayMs, + }; public async allocateContainer(provisionData: IProvisionData): Promise> { return this.executeContainerAssignmentOperation(provisionData, "allocate"); @@ -30,8 +42,8 @@ export class PhoenixClient { operation: string ): Promise> { try { - const response = await fetch(`${this.getPhoenixContainerPoolingEndPoint()}/${operation}`, { - method: "POST", + const response = await fetch(`${this.getPhoenixControlPlanePathPrefix()}/containerconnections`, { + method: operation === "allocate" ? "POST" : "PATCH", headers: PhoenixClient.getHeaders(), body: JSON.stringify(provisionData), }); @@ -50,7 +62,7 @@ export class PhoenixClient { } } - public async initiateContainerHeartBeat(containerData: { forwardingId: string; dbAccountName: string }) { + public async initiateContainerHeartBeat(containerData: IContainerData) { if (this.containerHealthHandler) { clearTimeout(this.containerHealthHandler); } @@ -65,28 +77,48 @@ export class PhoenixClient { private async getContainerStatusAsync(containerData: IContainerData): Promise { try { - const response = await window.fetch( - `${this.getPhoenixContainerPoolingEndPoint()}/${containerData.dbAccountName}/${containerData.forwardingId}`, - { - method: "GET", - headers: PhoenixClient.getHeaders(), + 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, + notebookServerInfo: containerStatus?.notebookServerInfo, + 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), + }); + useNotebook.getState().resetContainerConnection(connectionStatus); + useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); + throw new AbortError(response.statusText); } - ); - if (response.status === HttpStatusCodes.OK) { - const containerStatus = await response.json(); - return { - durationLeftInMinutes: containerStatus?.durationLeftInMinutes, - notebookServerInfo: containerStatus?.notebookServerInfo, - status: ContainerStatusType.Active, - }; - } - return { - durationLeftInMinutes: undefined, - notebookServerInfo: undefined, - status: ContainerStatusType.Disconnected, + throw new Error(response.statusText); }; + return await promiseRetry(runContainerStatusAsync, this.retryOptions); } catch (error) { - Logger.logError(getErrorMessage(error), "PhoenixClient/getContainerStatus"); + TelemetryProcessor.traceFailure(Action.PhoenixHeartBeat, { + dataExplorerArea: Areas.Notebook, + }); + Logger.logError(getErrorMessage(error), ""); + const connectionStatus: ContainerConnectionInfo = { + status: ConnectionStatusType.Failed, + }; + useNotebook.getState().resetContainerConnection(connectionStatus); + useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); return { durationLeftInMinutes: undefined, notebookServerInfo: undefined, @@ -95,7 +127,7 @@ export class PhoenixClient { } } - private async getContainerHealth(delayMs: number, containerData: { forwardingId: string; dbAccountName: string }) { + private async getContainerHealth(delayMs: number, containerData: IContainerData) { const containerInfo = await this.getContainerStatusAsync(containerData); useNotebook.getState().setContainerStatus(containerInfo); if (useNotebook.getState().containerStatus?.status === ContainerStatusType.Active) { @@ -103,6 +135,19 @@ export class PhoenixClient { } } + public async isDbAcountWhitelisted(): Promise { + try { + const response = await window.fetch(`${this.getPhoenixControlPlanePathPrefix()}`, { + method: "GET", + headers: PhoenixClient.getHeaders(), + }); + return response.status === HttpStatusCodes.OK; + } catch (error) { + Logger.logError(getErrorMessage(error), "PhoenixClient/IsDbAcountWhitelisted"); + return false; + } + } + public static getPhoenixEndpoint(): string { const phoenixEndpoint = userContext.features.phoenixEndpoint ?? userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT; if (validateEndpoint(phoenixEndpoint, allowedJunoOrigins)) { @@ -114,8 +159,9 @@ export class PhoenixClient { return phoenixEndpoint; } - public getPhoenixContainerPoolingEndPoint(): string { - return `${PhoenixClient.getPhoenixEndpoint()}/api/controlplane/toolscontainer`; + public getPhoenixControlPlanePathPrefix(): string { + return `${PhoenixClient.getPhoenixEndpoint()}/api/controlplane/toolscontainer/cosmosaccounts${userContext.databaseAccount.id + }`; } private static getHeaders(): HeadersInit { diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 0840fc598..4b498c03b 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -11,7 +11,8 @@ export type Features = { autoscaleDefault: boolean; partitionKeyDefault: boolean; partitionKeyDefault2: boolean; - phoenix: boolean; + phoenixNotebooks: boolean; + phoenixFeatures: boolean; notebooksDownBanner: boolean; readonly enableSDKoperations: boolean; readonly enableSpark: boolean; @@ -32,7 +33,6 @@ export type Features = { readonly ttl90Days: boolean; readonly mongoProxyEndpoint?: string; readonly mongoProxyAPIs?: string; - readonly notebooksTemporarilyDown: boolean; readonly enableThroughputCap: boolean; }; @@ -82,8 +82,8 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear autoscaleDefault: "true" === get("autoscaledefault"), partitionKeyDefault: "true" === get("partitionkeytest"), partitionKeyDefault2: "true" === get("pkpartitionkeytest"), - notebooksTemporarilyDown: "true" === get("notebookstemporarilydown", "true"), - phoenix: "true" === get("phoenix"), + phoenixNotebooks: "true" === get("phoenixnotebooks"), + phoenixFeatures: "true" === get("phoenixfeatures"), notebooksDownBanner: "true" === get("notebooksDownBanner"), enableThroughputCap: "true" === get("enablethroughputcap"), }; diff --git a/src/SelfServe/SqlX/SqlX.rp.ts b/src/SelfServe/SqlX/SqlX.rp.ts index 5d1b830d0..d224990bb 100644 --- a/src/SelfServe/SqlX/SqlX.rp.ts +++ b/src/SelfServe/SqlX/SqlX.rp.ts @@ -6,6 +6,7 @@ import { RefreshResult } from "../SelfServeTypes"; import SqlX from "./SqlX"; import { FetchPricesResponse, + PriceMapAndCurrencyCode, RegionsResponse, SqlxServiceResource, UpdateDedicatedGatewayRequestParameters, @@ -178,18 +179,18 @@ const getFetchPricesPathForRegion = (subscriptionId: string): string => { return `/subscriptions/${subscriptionId}/providers/Microsoft.CostManagement/fetchPrices`; }; -export const getPriceMap = async (regions: Array): Promise>> => { +export const getPriceMapAndCurrencyCode = async (regions: Array): Promise => { const telemetryData = { feature: "Calculate approximate cost", - function: "getPriceMap", + function: "getPriceMapAndCurrencyCode", description: "fetch prices API call", selfServeClassName: SqlX.name, }; - const getPriceMapTimestamp = selfServeTraceStart(telemetryData); + const getPriceMapAndCurrencyCodeTimestamp = selfServeTraceStart(telemetryData); try { const priceMap = new Map>(); - + let currencyCode; for (const region of regions) { const regionPriceMap = new Map(); @@ -207,17 +208,21 @@ export const getPriceMap = async (regions: Array): Promise>; +let currencyCode: string; let regions: Array; const calculateCost = (skuName: string, instanceCount: number): Description => { @@ -237,7 +238,7 @@ const calculateCost = (skuName: string, instanceCount: number): Description => { selfServeTraceSuccess(telemetryData, calculateCostTimestamp); return { - textTKey: `${costPerHour} USD`, + textTKey: `${costPerHour} ${currencyCode}`, type: DescriptionType.Text, }; } catch (err) { @@ -346,7 +347,9 @@ export default class SqlX extends SelfServeBaseClass { }); regions = await getRegions(); - priceMap = await getPriceMap(regions); + const priceMapAndCurrencyCode = await getPriceMapAndCurrencyCode(regions); + priceMap = priceMapAndCurrencyCode.priceMap; + currencyCode = priceMapAndCurrencyCode.currencyCode; const response = await getCurrentProvisioningState(); if (response.status && response.status !== "Deleting") { diff --git a/src/SelfServe/SqlX/SqlxTypes.ts b/src/SelfServe/SqlX/SqlxTypes.ts index a150ccbb1..7ca2fe264 100644 --- a/src/SelfServe/SqlX/SqlxTypes.ts +++ b/src/SelfServe/SqlX/SqlxTypes.ts @@ -36,9 +36,15 @@ export type FetchPricesResponse = { Count: number; }; +export type PriceMapAndCurrencyCode = { + priceMap: Map>; + currencyCode: string; +}; + export type PriceItem = { retailPrice: number; skuName: string; + currencyCode: string; }; export type RegionsResponse = { diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 134340ad4..71bf6cb5a 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -50,7 +50,6 @@ export enum Action { SubscriptionSwitch, TenantSwitch, DefaultTenantSwitch, - ResetNotebookWorkspace, CreateNotebookWorkspace, NotebookErrorNotification, CreateSparkCluster, @@ -82,6 +81,9 @@ export enum Action { NotebooksInsertTextCellBelowFromMenu, NotebooksMoveCellUpFromMenu, NotebooksMoveCellDownFromMenu, + PhoenixConnection, + PhoenixHeartBeat, + PhoenixResetWorkspace, DeleteCellFromMenu, OpenTerminal, CreateMongoCollectionWithWildcardIndex, diff --git a/src/Terminal/JupyterLabAppFactory.ts b/src/Terminal/JupyterLabAppFactory.ts index a1eef5bbf..d4edb9c0e 100644 --- a/src/Terminal/JupyterLabAppFactory.ts +++ b/src/Terminal/JupyterLabAppFactory.ts @@ -2,15 +2,61 @@ * JupyterLab applications based on jupyterLab components */ import { ServerConnection, TerminalManager } from "@jupyterlab/services"; +import { IMessage } from "@jupyterlab/services/lib/terminal/terminal"; import { Terminal } from "@jupyterlab/terminal"; import { Panel, Widget } from "@phosphor/widgets"; +import { userContext } from "UserContext"; export class JupyterLabAppFactory { - public static async createTerminalApp(serverSettings: ServerConnection.ISettings) { + private isShellStarted: boolean | undefined; + private checkShellStarted: ((content: string | undefined) => void) | undefined; + private onShellExited: () => void; + + private isShellExited(content: string | undefined) { + return content?.includes("cosmosuser@"); + } + + private isMongoShellStarted(content: string | undefined) { + this.isShellStarted = content?.includes("MongoDB shell version"); + } + + private isCassandraShellStarted(content: string | undefined) { + this.isShellStarted = content?.includes("Connected to") && content?.includes("cqlsh"); + } + + constructor(closeTab: () => void) { + this.onShellExited = closeTab; + this.isShellStarted = false; + this.checkShellStarted = undefined; + + switch (userContext.apiType) { + case "Mongo": + this.checkShellStarted = this.isMongoShellStarted; + break; + case "Cassandra": + this.checkShellStarted = this.isCassandraShellStarted; + break; + } + } + + public async createTerminalApp(serverSettings: ServerConnection.ISettings) { const manager = new TerminalManager({ serverSettings: serverSettings, }); const session = await manager.startNew(); + session.messageReceived.connect(async (_, message: IMessage) => { + const content = message.content && message.content[0]?.toString(); + + if (this.checkShellStarted && message.type == "stdout") { + //Close the terminal tab once the shell closed messages are received + if (!this.isShellStarted) { + this.checkShellStarted(content); + } else if (this.isShellExited(content)) { + this.onShellExited(); + } + } + }, this); + const term = new Terminal(session, { theme: "dark", shutdownOnClose: true }); if (!term) { diff --git a/src/Terminal/TerminalProps.ts b/src/Terminal/TerminalProps.ts index 4fe3c539c..5122a6cb7 100644 --- a/src/Terminal/TerminalProps.ts +++ b/src/Terminal/TerminalProps.ts @@ -10,4 +10,5 @@ export interface TerminalProps { authType: AuthType; apiType: ApiType; subscriptionId: string; + tabId: string; } diff --git a/src/Terminal/index.ts b/src/Terminal/index.ts index dae3059b5..f71792fab 100644 --- a/src/Terminal/index.ts +++ b/src/Terminal/index.ts @@ -1,5 +1,6 @@ import { ServerConnection } from "@jupyterlab/services"; import "@jupyterlab/terminal/style/index.css"; +import { MessageTypes } from "Contracts/ExplorerContracts"; import postRobot from "post-robot"; import { HttpHeaders } from "../Common/Constants"; import { Action } from "../Shared/Telemetry/TelemetryConstants"; @@ -54,13 +55,20 @@ const initTerminal = async (props: TerminalProps) => { const startTime = TelemetryProcessor.traceStart(Action.OpenTerminal, data); try { - await JupyterLabAppFactory.createTerminalApp(serverSettings); + await new JupyterLabAppFactory(() => closeTab(props.tabId)).createTerminalApp(serverSettings); TelemetryProcessor.traceSuccess(Action.OpenTerminal, data, startTime); } catch (error) { TelemetryProcessor.traceFailure(Action.OpenTerminal, data, startTime); } }; +const closeTab = (tabId: string): void => { + window.parent.postMessage( + { type: MessageTypes.CloseTab, data: { tabId: tabId }, signature: "pcIframe" }, + window.document.referrer + ); +}; + const main = async (): Promise => { postRobot.on( "props", diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts index 060b24f3f..635d46e2f 100644 --- a/src/Utils/GalleryUtils.ts +++ b/src/Utils/GalleryUtils.ts @@ -10,7 +10,6 @@ import { SortBy, } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; import Explorer from "../Explorer/Explorer"; -import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil"; import { useNotebook } from "../Explorer/Notebook/useNotebook"; import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; @@ -229,7 +228,7 @@ export function downloadItem( undefined, "Download", async () => { - if (NotebookUtil.isPhoenixEnabled()) { + if (useNotebook.getState().isPhoenixNotebooks) { await container.allocateContainer(); } const notebookServerInfo = useNotebook.getState().notebookServerInfo; diff --git a/src/Utils/GitHubUtils.ts b/src/Utils/GitHubUtils.ts index 13e5f828b..59b14f9a8 100644 --- a/src/Utils/GitHubUtils.ts +++ b/src/Utils/GitHubUtils.ts @@ -1,4 +1,9 @@ // https://github.com///tree/ + +import { JunoEndpoints } from "Common/Constants"; +import { configContext } from "ConfigContext"; +import { userContext } from "UserContext"; + // The url when users visit a repo/branch on github.com export const RepoUriPattern = /https:\/\/github.com\/([^/]*)\/([^/]*)\/tree\/([^?]*)/; @@ -60,3 +65,15 @@ export function toContentUri(owner: string, repo: string, branch: string, path: export function toRawContentUri(owner: string, repo: string, branch: string, path: string): string { return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`; } + +export function GetGithubClientId(): string { + const junoEndpoint = userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT; + if ( + junoEndpoint === JunoEndpoints.Test || + junoEndpoint === JunoEndpoints.Test2 || + junoEndpoint === JunoEndpoints.Test3 + ) { + return configContext.GITHUB_TEST_ENV_CLIENT_ID; + } + return configContext.GITHUB_CLIENT_ID; +} diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 30445225c..245fc1fdb 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -1,3 +1,4 @@ +import { useTabs } from "hooks/useTabs"; import { useEffect, useState } from "react"; import { applyExplorerBindings } from "../applyExplorerBindings"; import { AuthType } from "../AuthType"; @@ -69,16 +70,38 @@ export function useKnockoutExplorer(platform: Platform): Explorer { async function configureHosted(): Promise { const win = (window as unknown) as HostedExplorerChildFrame; + let explorer: Explorer; if (win.hostedConfig.authType === AuthType.EncryptedToken) { - return configureHostedWithEncryptedToken(win.hostedConfig); + explorer = configureHostedWithEncryptedToken(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.ResourceToken) { - return configureHostedWithResourceToken(win.hostedConfig); + explorer = configureHostedWithResourceToken(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.ConnectionString) { - return configureHostedWithConnectionString(win.hostedConfig); + explorer = configureHostedWithConnectionString(win.hostedConfig); } else if (win.hostedConfig.authType === AuthType.AAD) { - return configureHostedWithAAD(win.hostedConfig); + explorer = await configureHostedWithAAD(win.hostedConfig); + } else { + throw new Error(`Unknown hosted config: ${win.hostedConfig}`); } - throw new Error(`Unknown hosted config: ${win.hostedConfig}`); + + window.addEventListener( + "message", + (event) => { + if (isInvalidParentFrameOrigin(event)) { + return; + } + + if (!shouldProcessMessage(event)) { + return; + } + + if (event.data?.type === MessageTypes.CloseTab) { + useTabs.getState().closeTabsByComparator((tab) => tab.tabId === event.data?.data?.tabId); + } + }, + false + ); + + return explorer; } async function configureHostedWithAAD(config: AAD): Promise { @@ -261,6 +284,8 @@ async function configurePortal(): Promise { } } else if (shouldForwardMessage(message, event.origin)) { sendMessage(message); + } else if (event.data?.type === MessageTypes.CloseTab) { + useTabs.getState().closeTabsByComparator((tab) => tab.tabId === event.data?.data?.tabId); } }, false @@ -339,8 +364,11 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) { userContext.features.partitionKeyDefault2 = true; } - if (inputs.flights.indexOf(Flights.Phoenix) !== -1) { - userContext.features.phoenix = true; + 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;