diff --git a/package-lock.json b/package-lock.json index 75dcb7203..d7528d728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@babel/plugin-proposal-decorators": "7.12.12", "@fluentui/react": "8.119.0", "@fluentui/react-components": "9.54.2", + "@jupyterlab/services": "6.0.2", "@jupyterlab/terminal": "3.0.3", "@microsoft/applicationinsights-web": "2.6.1", "@nteract/commutable": "7.5.1", diff --git a/package.json b/package.json index 77409d3ae..e9612db73 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@fluentui/react": "8.119.0", "@fluentui/react-components": "9.54.2", "@jupyterlab/terminal": "3.0.3", + "@jupyterlab/services": "6.0.2", "@microsoft/applicationinsights-web": "2.6.1", "@nteract/commutable": "7.5.1", "@nteract/connected-components": "6.8.2", diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index f6cc03fb4..ea8b90bb8 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -906,25 +906,28 @@ export default class Explorer { } public async openNotebookTerminal(kind: ViewModels.TerminalKind): Promise { - // if (useNotebook.getState().isPhoenixFeatures) { - // await this.allocateContainer(PoolIdType.DefaultPoolId); - // const notebookServerInfo = useNotebook.getState().notebookServerInfo; - // if (notebookServerInfo && notebookServerInfo.notebookServerEndpoint !== undefined) { - // this.connectToNotebookTerminal(kind); - // } else { - // useDialog - // .getState() - // .showOkModalDialog( - // "Failed to connect", - // "Failed to connect to temporary workspace. This could happen because of network issues. Please refresh the page and try again.", - // ); - // } - // } else { - this.connectToNotebookTerminal(kind); - // } + + if (userContext.features.enableCloudShell || !useNotebook.getState().isPhoenixFeatures) { + this.connectToTerminal(kind); + return; + } + + await this.allocateContainer(PoolIdType.DefaultPoolId); + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + + if (notebookServerInfo?.notebookServerEndpoint) { + this.connectToTerminal(kind); + } else { + useDialog + .getState() + .showOkModalDialog( + "Failed to connect", + "Failed to connect to temporary workspace. This could happen because of network issues. Please refresh the page and try again." + ); + } } - private connectToNotebookTerminal(kind: ViewModels.TerminalKind): void { + private connectToTerminal(kind: ViewModels.TerminalKind): void { let title: string; switch (kind) { diff --git a/src/Explorer/Tabs/CloudShellTab/Data.tsx b/src/Explorer/Tabs/CloudShellTab/Data.tsx new file mode 100644 index 000000000..ca239f6b2 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Data.tsx @@ -0,0 +1,160 @@ +import { v4 as uuidv4 } from 'uuid'; +import { configContext } from "../../../ConfigContext"; +import { armRequest } from "../../../Utils/arm/request"; +import { Authorization, ConnectTerminalResponse, NetworkType, OsType, ProvisionConsoleResponse, SessionType, Settings, ShellType } from "./DataModels"; + +const cloudshellToken = ""; + +export const validateUserSettings = (userSettings: Settings) => { + if (userSettings.sessionType !== SessionType.Ephemeral && userSettings.osType !== OsType.Linux) { + return false; + } else { + return true; + } +} + +// https://stackoverflow.com/q/38598280 (Is it possible to wrap a function and retain its types?) +export const trackedApiCall = , U>(apiCall: (...args: T) => Promise, name: string) => { + return async (...args: T): Promise => { + const startTime = Date.now(); + const result = await apiCall(...args); + const endTime = Date.now(); + return result; + }; +}; + +export const getUserRegion = trackedApiCall(async (subscriptionId: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/locations`, + method: "GET", + apiVersion: "2022-12-01" + }); + +}, "getUserRegion"); + +export const getUserSettings = trackedApiCall(async (): Promise => { + const resp = await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/providers/Microsoft.Portal/userSettings/cloudconsole`, + method: "GET", + apiVersion: "2023-02-01-preview", + customHeaders: { + "Authorization": cloudshellToken // Temporily use a hardcoded token + } + }); + + return { + location: resp?.properties?.preferredLocation, + sessionType: resp?.properties?.sessionType, + osType: resp?.properties?.preferredOsType + }; +}, "getUserSettings"); + +export const putEphemeralUserSettings = trackedApiCall(async (userSubscriptionId: string, userRegion: string) => { + const ephemeralSettings = { + properties: { + preferredOsType: OsType.Linux, + preferredShellType: ShellType.Bash, + preferredLocation: userRegion, + networkType: NetworkType.Default, + sessionType: SessionType.Ephemeral, + userSubscription: userSubscriptionId, + } + }; + + const resp = await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/providers/Microsoft.Portal/userSettings/cloudconsole`, + method: "PUT", + apiVersion: "2023-02-01-preview", + body: ephemeralSettings, + customHeaders: { + "Authorization": cloudshellToken // Temporily use a hardcoded token + } + }); + + return resp; + +}, "putEphemeralUserSettings"); + +export const verifyCloudshellProviderRegistration = async(subscriptionId: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell`, + method: "GET", + apiVersion: "2022-12-01", + customHeaders: { + "Authorization": cloudshellToken // Temporily use a hardcoded token + } + }); +}; + +export const registerCloudShellProvider = async (subscriptionId: string) => { + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register`, + method: "POST", + apiVersion: "2022-12-01", + customHeaders: { + "Authorization": cloudshellToken // Temporily use a hardcoded token + } + }); +}; + +export const provisionConsole = trackedApiCall(async (subscriptionId: string, location: string): Promise => { + const data = { + properties: { + osType: OsType.Linux + } + }; + + return await armRequest({ + host: configContext.ARM_ENDPOINT, + path: `providers/Microsoft.Portal/consoles/default`, + method: "PUT", + apiVersion: "2023-02-01-preview", + customHeaders: { + 'x-ms-console-preferred-location': location, + "Authorization": cloudshellToken // Temporily use a hardcoded token + }, + body: data, + }); +}, "provisionConsole"); + +export const connectTerminal = trackedApiCall(async (consoleUri: string, size: { rows: number, cols: number }): Promise => { + const targetUri = consoleUri + `/terminals?cols=${size.cols}&rows=${size.rows}&version=2019-01-01&shell=bash`; + const resp = await fetch(targetUri, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': '2', + 'Authorization': cloudshellToken, + 'x-ms-client-request-id': uuidv4(), + 'Accept-Language': getLocale(), + }, + body: "{}" // empty body is necessary + }); + return resp.json(); +}, "connectTerminal"); + +export const authorizeSession = trackedApiCall(async (consoleUri: string): Promise => { + const targetUri = consoleUri + "/authorize"; + const resp = await fetch(targetUri, { + method: "post", + headers: { + 'Accept': 'application/json', + 'Authorization': cloudshellToken, + 'Accept-Language': getLocale(), + "Content-Type": 'application/json' + }, + body: "{}" // empty body is necessary + }); + return resp.json(); +}, "authorizeSession"); + +export const getLocale = () => { + const langLocale = navigator.language; + return (langLocale && langLocale.length === 2 ? langLocale[1] : 'en-us'); +}; diff --git a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx new file mode 100644 index 000000000..68aaf580f --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx @@ -0,0 +1,47 @@ +export const enum OsType { + Linux = "linux", + Windows = "windows" +} + +export const enum ShellType { + Bash = "bash", + PowerShellCore = "pwsh" +} + +export const enum NetworkType { + Default = "Default", + Isolated = "Isolated" +} + +export const enum SessionType { + Mounted = "Mounted", + Ephemeral = "Ephemeral" +} + +export type Settings = { + location: string; + sessionType: SessionType; + osType: OsType; +}; + +export type ProvisionConsoleResponse = { + properties: { + osType: OsType; + provisioningState: string; + uri: string; + }; +}; + +export type Authorization = { + token: string; +}; + +export type ConnectTerminalResponse = { + id: string; + idleTimeout: string; + rootDirectory: string; + socketUri: string; + tokenUpdated: boolean; +}; + + diff --git a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx new file mode 100644 index 000000000..a62cf94a6 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx @@ -0,0 +1,237 @@ +import { Terminal } from "xterm"; +import { userContext } from "../../../UserContext"; +import { authorizeSession, connectTerminal, getUserRegion, getUserSettings, provisionConsole, putEphemeralUserSettings, registerCloudShellProvider, validateUserSettings, verifyCloudshellProviderRegistration } from "./Data"; + +export const startCloudShellterminal = async (xterminal: Terminal, intervalsToClearRef: any, authorizationToken: any) => { + + const tokenInterval = setInterval(async () => { + authorizationToken + }, 1000 * 60 * 10); + + const intervalsToClear = intervalsToClearRef.current ?? []; + intervalsToClear.push(tokenInterval); + + // validate that the subscription id is registered in the Cloudshell namespace + try { + const response: any = await verifyCloudshellProviderRegistration(userContext.subscriptionId); + if (response.registrationState !== "Registered") { + await registerCloudShellProvider(userContext.subscriptionId); + } + } catch (err) { + xterminal.writeln(''); + xterminal.writeln('Unable to verify cloudshell provider registration.'); + intervalsToClear.forEach((val) => window.clearInterval(+val)); + throw err; + } + + const region = await getUserRegion(userContext.subscriptionId).then((res) => { + const reqId = (res.headers as any).get("x-ms-routing-request-id"); + const location = reqId?.split(":")?.[0]?.toLowerCase() ?? ""; + const validRegions = new Set(["westus", "southcentralus", "eastus", "northeurope", "westeurope", "centralindia", "southeastasia", "westcentralus", "eastus2euap", "centraluseuap"]); + if (validRegions.has(location.toLowerCase())) { + return location; + } + if (location === "centralus") { + return "centraluseuap"; + } + if (location === "eastus2") { + return "eastus2euap"; + } + return "westus"; + }).catch((err) => { + xterminal.writeln(''); + xterminal.writeln('Unable to get user region.'); + return "westus"; + }); + + xterminal.writeln('Requested Region ' + region); + + try { + // do not use the subscription from the preferred settings use the one from the context + await putEphemeralUserSettings(userContext.subscriptionId, region); + } catch (err) { + xterminal.writeln('Unable to update user settings to ephemeral session.'); + throw err; + } + + // verify user settings after they have been updated to ephemeral + try { + const userSettings = await getUserSettings(); + const isValidUserSettings = validateUserSettings(userSettings); + if (!isValidUserSettings) { + throw new Error("Invalid user settings detected for ephemeral session."); + } + } catch (err) { + xterminal.writeln('Unable to verify user settings for ephemeral session.'); + throw err; + } + + // trigger callback to provision console internal + let provisionConsoleResponse; + try { + provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, region); + // statusPaneUpdateCommands.setTerminalUri(provisionConsoleResponse.properties.uri); + } catch (err) { + xterminal.writeln('Unable to provision console.'); + throw err; + } + + if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { + xterminal.writeln("Failed to provision console."); + throw new Error("Failed to provision console."); + } + + xterminal.writeln("Connecting to cloudshell..."); + xterminal.writeln("Please wait..."); + // connect the terminal + let connectTerminalResponse; + try { + connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, { rows: xterminal.rows, cols: xterminal.cols }); + } catch (err) { + xterminal.writeln(''); + xterminal.writeln('Unable to connect terminal.'); + throw err; + } + + const targetUri = provisionConsoleResponse.properties.uri + `/terminals?cols=${xterminal.cols}&rows=${xterminal.rows}&version=2019-01-01&shell=bash`; + const termId = connectTerminalResponse.id; + + let socketUri = connectTerminalResponse.socketUri.replace(":443/", ""); + const targetUriBody = targetUri.replace('https://', '').split('?')[0]; + if (socketUri.indexOf(targetUriBody) === -1) { + socketUri = 'wss://' + targetUriBody + '/' + termId; + } + if (targetUriBody.includes('servicebus')) { + const targetUriBodyArr = targetUriBody.split('/'); + socketUri = 'wss://' + targetUriBodyArr[0] + '/$hc/' + targetUriBodyArr[1] + '/terminals/' + termId; + } + + // // provision appropriate first party permissions to cloudshell instance + // await postTokens(provisionConsoleResponse.properties.uri, authorizationToken).catch((err) => { + // xterminal.writeln('Unable to provision first party permissions to cloudshell instance.'); + // intervalsToClear.forEach((val) => window.clearInterval(+val)); + // throw err; + // }); + + const socket = new WebSocket(socketUri); + + configureSocket(socket, socketUri, xterminal, intervalsToClear, 0); + + // authorize the session + try { + const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); + const cookieToken = authorizeResponse.token; + const a = document.createElement("img"); + a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); + } catch (err) { + xterminal.writeln('Unable to authroize the session'); + socket.close(); + throw err; + } + + xterminal.writeln("Connected to cloudshell."); + xterminal.focus(); + + return socket; +} + +let keepAliveID: NodeJS.Timeout = null; +let pingCount = 0; + +export const configureSocket = (socket: WebSocket, uri: string, terminal: any, intervals: NodeJS.Timer[], socketRetryCount: number) => { + let jsonData = ''; + socket.onopen = () => { + terminal.writeln("Socket Opened"); + const initializeCommand = + `curl -s https://ipinfo.io \n` + + `curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz \n` + + `tar -xvzf mongosh-2.3.8-linux-x64.tgz \n` + + `mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/ \n` + + `echo 'export PATH=$PATH:$HOME/mongosh/bin' >> ~/.bashrc \n` + + `source ~/.bashrc \n` + + `mongosh --version \n`; + + terminal.writeln(initializeCommand); + socket.send(initializeCommand); + + const keepSocketAlive = (socket: WebSocket) => { + if (socket.readyState === WebSocket.OPEN) { + if ((pingCount / 60) >= 20) { + socket.close(); + } else { + socket.send(''); + pingCount++; + keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000); + } + } + }; + keepSocketAlive(socket); + }; + + socket.onclose = () => { + terminal.writeln("Socket Closed"); + if (keepAliveID) { + clearTimeout(keepAliveID); + pingCount = 0; + } + intervals.forEach((val) => { + window.clearInterval(+val); + }); + + terminal.writeln("Session terminated. Please refresh the page to start a new session."); + }; + + socket.onerror = () => { + terminal.writeln("terminal reconnected"); + if (socketRetryCount < 10 && socket.readyState !== WebSocket.CLOSED) { + configureSocket(socket, uri, terminal, intervals, socketRetryCount + 1); + } else { + // log an error indicating socket connection failed + terminal.writeln("Socket connection closed"); + // close the socket + socket.close(); + } + }; + + socket.onmessage = (event: MessageEvent) => { + terminal.writeln("Socket onMessage"); + // if we are sending and receiving messages the terminal is not idle set ping count to 0 + pingCount = 0; + + // check if we are dealing with array buffer or string + let eventData = ''; + if (typeof event.data === "object") { + try { + const enc = new TextDecoder("utf-8"); + eventData = enc.decode(event.data as any); + } catch (e) { + // not array buffer + } + } + if (typeof event.data === 'string') { + eventData = event.data; + + terminal.write(eventData); + } + + // process as one line or process as multiline + if (eventData.includes("ie_us") && eventData.includes("ie_ue")) { + // process as one line + const statusData = eventData.split('ie_us')[1].split('ie_ue')[0]; + console.log(statusData); + } else if (eventData.includes("ie_us")) { + // check for start + jsonData += eventData.split('ie_us')[1]; + } else if (eventData.includes("ie_ue")) { + // check for end and process the command + jsonData += eventData.split('ie_ue')[0]; + console.log(jsonData); + jsonData = ''; + } else if (jsonData.length > 0) { + // check if the line is all data then just concatenate + jsonData += eventData; + } + }; + + return socket; +}; diff --git a/src/Explorer/Tabs/CloudShellTerminalComponent.tsx b/src/Explorer/Tabs/CloudShellTerminalComponent.tsx new file mode 100644 index 000000000..94255d9f0 --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTerminalComponent.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useRef } from "react"; +import { Terminal } from "xterm"; +import "xterm/css/xterm.css"; +import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; +import { startCloudShellterminal } from "./CloudShellTab/UseTerminal"; + + +export const CloudShellTerminalComponent: React.FC = () => { + const terminalRef = useRef(null); // Reference for terminal container + const xtermRef = useRef(null); // Reference for XTerm instance + const socketRef = useRef(null); // Reference for WebSocket + const intervalsToClearRef = useRef([]); + + useEffect(() => { + // Initialize XTerm instance + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + theme: { background: "#1d1f21", foreground: "#c5c8c6" }, + }); + + // Attach terminal to the DOM + if (terminalRef.current) { + term.open(terminalRef.current); + xtermRef.current = term; + } + + const authorizationHeader = getAuthorizationHeader() + socketRef.current = startCloudShellterminal(term, intervalsToClearRef, authorizationHeader.token); + + term.onData((data) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(data); + } + }); + + // Cleanup function to close WebSocket and dispose terminal + return () => { + if (socketRef.current) { + socketRef.current.close(); // Close WebSocket connection + } + term.dispose(); // Clean up XTerm instance + }; + + }, []); + + return
; +}; diff --git a/src/Explorer/Tabs/TerminalTab.tsx b/src/Explorer/Tabs/TerminalTab.tsx index 0abd5618a..993d5c113 100644 --- a/src/Explorer/Tabs/TerminalTab.tsx +++ b/src/Explorer/Tabs/TerminalTab.tsx @@ -10,10 +10,11 @@ import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; import { userContext } from "../../UserContext"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; +import { NotebookTerminalComponent } from "../Controls/Notebook/NotebookTerminalComponent"; import Explorer from "../Explorer"; -//import { useNotebook } from "../Notebook/useNotebook"; +import { useNotebook } from "../Notebook/useNotebook"; +import { CloudShellTerminalComponent } from "./CloudShellTerminalComponent"; import TabsBase from "./TabsBase"; -import XTermComponent from "./XTermComponent"; export interface TerminalTabOptions extends ViewModels.TabOptions { @@ -26,7 +27,7 @@ export interface TerminalTabOptions extends ViewModels.TabOptions { /** * Notebook terminal tab */ -class XTermAdapter implements ReactAdapter { +class NotebookTerminalComponentAdapter implements ReactAdapter { // parameters: true: show, false: hide public parameters: ko.Computed; constructor( @@ -44,65 +45,82 @@ class XTermAdapter implements ReactAdapter { + ); + } + return this.parameters() ? + ( userContext.features.enableCloudShell ? ( + + ) : ( + + ) ): ( + + ); + } +} + +/** + * CloudShell terminal tab + */ +class CloudShellTerminalComponentAdapter implements ReactAdapter { + // parameters: true: show, false: hide + public parameters: ko.Computed; + constructor( + private getDatabaseAccount: () => DataModels.DatabaseAccount, + private getTabId: () => string, + private getUsername: () => string, + private isAllPublicIPAddressesEnabled: ko.Observable, + private kind: ViewModels.TerminalKind, + ) {} + + public renderComponent(): JSX.Element { + if (!this.isAllPublicIPAddressesEnabled()) { + return ( + ); } return this.parameters() ? ( - - // + ) : ( ); } +} - private getShellNameForDisplay(terminalKind: ViewModels.TerminalKind): string { - switch (terminalKind) { - case ViewModels.TerminalKind.Postgres: - return "PostgreSQL"; - case ViewModels.TerminalKind.Mongo: - case ViewModels.TerminalKind.VCoreMongo: - return "MongoDB"; - default: - return ""; - } +export const getShellNameForDisplay = (terminalKind: ViewModels.TerminalKind): string => { + switch (terminalKind) { + case ViewModels.TerminalKind.Postgres: + return "PostgreSQL"; + case ViewModels.TerminalKind.Mongo: + case ViewModels.TerminalKind.VCoreMongo: + return "MongoDB"; + default: + return ""; } } export default class TerminalTab extends TabsBase { - public readonly html = '
'; + public readonly html = '
'; private container: Explorer; - private xtermAdapter: XTermAdapter; + private terminalComponentAdapter: any; private isAllPublicIPAddressesEnabled: ko.Observable; constructor(options: TerminalTabOptions) { super(options); this.container = options.container; this.isAllPublicIPAddressesEnabled = ko.observable(true); - this.xtermAdapter = new XTermAdapter( - () => null, - () => userContext?.databaseAccount, - () => this.tabId, - () => this.getUsername(), - this.isAllPublicIPAddressesEnabled, - options.kind, - ); - this.xtermAdapter.parameters = ko.computed(() => { - if ( - this.isTemplateReady() && - // useNotebook.getState().isNotebookEnabled && - // useNotebook.getState().notebookServerInfo?.notebookServerEndpoint && - this.isAllPublicIPAddressesEnabled() - ) { - return true; - } - return false; - }); + + this.initializeNotebookTerminalAdapter(options); if (options.kind === ViewModels.TerminalKind.Postgres) { checkFirewallRules( @@ -123,6 +141,36 @@ export default class TerminalTab extends TabsBase { } } + private initializeNotebookTerminalAdapter(options: TerminalTabOptions): void { + if (userContext.features.enableCloudShell) { + this.terminalComponentAdapter = new CloudShellTerminalComponentAdapter( + () => userContext?.databaseAccount, + () => this.tabId, + () => this.getUsername(), + this.isAllPublicIPAddressesEnabled, + options.kind + ); + } + else { + this.terminalComponentAdapter = new NotebookTerminalComponentAdapter( + () => this.getNotebookServerInfo(options), + () => userContext?.databaseAccount, + () => this.tabId, + () => this.getUsername(), + this.isAllPublicIPAddressesEnabled, + options.kind + ); + } + + this.terminalComponentAdapter.parameters = ko.computed(() => + this.isTemplateReady() && + (userContext.features.enableCloudShell || + (useNotebook.getState().isNotebookEnabled && + useNotebook.getState().notebookServerInfo?.notebookServerEndpoint)) && + this.isAllPublicIPAddressesEnabled() + ); + } + public getContainer(): Explorer { return this.container; } @@ -135,41 +183,41 @@ export default class TerminalTab extends TabsBase { this.updateNavbarWithTabsButtons(); } - // private getNotebookServerInfo(options: TerminalTabOptions): DataModels.NotebookWorkspaceConnectionInfo { - // let endpointSuffix: string; + private getNotebookServerInfo(options: TerminalTabOptions): DataModels.NotebookWorkspaceConnectionInfo { + let endpointSuffix: string; - // switch (options.kind) { - // case ViewModels.TerminalKind.Default: - // endpointSuffix = ""; - // break; + switch (options.kind) { + case ViewModels.TerminalKind.Default: + endpointSuffix = ""; + break; - // case ViewModels.TerminalKind.Mongo: - // endpointSuffix = "mongo"; - // break; + case ViewModels.TerminalKind.Mongo: + endpointSuffix = "mongo"; + break; - // case ViewModels.TerminalKind.Cassandra: - // endpointSuffix = "cassandra"; - // break; + case ViewModels.TerminalKind.Cassandra: + endpointSuffix = "cassandra"; + break; - // case ViewModels.TerminalKind.Postgres: - // endpointSuffix = "postgresql"; - // break; + case ViewModels.TerminalKind.Postgres: + endpointSuffix = "postgresql"; + break; - // case ViewModels.TerminalKind.VCoreMongo: - // endpointSuffix = "mongovcore"; - // break; + case ViewModels.TerminalKind.VCoreMongo: + endpointSuffix = "mongovcore"; + break; - // default: - // throw new Error(`Terminal kind: ${options.kind} not supported`); - // } + default: + throw new Error(`Terminal kind: ${options.kind} not supported`); + } - // const info: DataModels.NotebookWorkspaceConnectionInfo = useNotebook.getState().notebookServerInfo; - // return { - // authToken: info.authToken, - // notebookServerEndpoint: `${info.notebookServerEndpoint.replace(/\/+$/, "")}/${endpointSuffix}`, - // forwardingId: info.forwardingId, - // }; - // } + const info: DataModels.NotebookWorkspaceConnectionInfo = useNotebook.getState().notebookServerInfo; + return { + authToken: info.authToken, + notebookServerEndpoint: `${info.notebookServerEndpoint.replace(/\/+$/, "")}/${endpointSuffix}`, + forwardingId: info.forwardingId, + }; + } private getUsername(): string { if (userContext.apiType !== "VCoreMongo" || !userContext?.vcoreMongoConnectionParams?.adminLogin) { diff --git a/src/Explorer/Tabs/XTermComponent.tsx b/src/Explorer/Tabs/XTermComponent.tsx deleted file mode 100644 index 787aee9e7..000000000 --- a/src/Explorer/Tabs/XTermComponent.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { v4 as uuidv4 } from 'uuid'; -import { Terminal } from "xterm"; -import "xterm/css/xterm.css"; -import { userContext } from "../../UserContext"; -import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; - -const XTermComponent: React.FC = () => { - const terminalRef = useRef(null); // Reference for terminal container - const xtermRef = useRef(null); // Reference for XTerm instance - const socketRef = useRef(null); // Reference for WebSocket - const intervalsToClearRef = useRef([]); - - useEffect(() => { - // Initialize XTerm instance - const term = new Terminal({ - cursorBlink: true, - fontSize: 14, - theme: { background: "#1d1f21", foreground: "#c5c8c6" }, - }); - - // Attach terminal to the DOM - if (terminalRef.current) { - term.open(terminalRef.current); - xtermRef.current = term; - } - - term.writeln("Hello, World!"); - - const authorizationHeader = getAuthorizationHeader() - socketRef.current = startCloudShellterminal(term, intervalsToClearRef, authorizationHeader.token); - - term.onData((data) => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - socketRef.current.send(data); - } - }); - - // Cleanup function to close WebSocket and dispose terminal - return () => { - if (socketRef.current) { - socketRef.current.close(); // Close WebSocket connection - } - term.dispose(); // Clean up XTerm instance - }; - - }, []); - - return
; -}; - -const startCloudShellterminal = async (xterminal: Terminal, intervalsToClearRef: any, authorizationToken: any) => { - - // const allowedParentFrameAuthorities = ["localhost:1234", "localhost:3000", "portal.azure.com", "portal.azure.us", "rc.portal.azure.com", "ms.portal.azure.com", "canary.portal.azure.com", "canary-ms.portal.azure.com", "docs.microsoft.com", "review.docs.microsoft.com", "ppe.docs.microsoft.com", "ux.console.azure.us", "admin-local.teams.microsoft.net", "admin-ignite.microsoft.com", "wusportalprv.office.com", "portal-sdf.office.com", "ncuportalprv.office.com", "admin.microsoft.com", "portal.microsoft.com", "portal.office.com", "admin.microsoft365.com", "admin-sdf.exchange.microsoft.com", "admin.exchange.microsoft.com", "cloudconsole-ux-prod-usnatwest.appservice.eaglex.ic.gov", "cloudconsole-ux-prod-usnateast.appservice.eaglex.ic.gov", "portal.azure.eaglex.ic.gov", "cloudconsole-ux-prod-ussecwest.appservice.microsoft.scloud", "cloudconsole-ux-prod-usseceast.appservice.microsoft.scloud", "portal.azure.microsoft.scloud", "admin-local.teams.microsoft.net", "admin-dev.teams.microsoft.net", "admin-int.teams.microsoft.net", "admin.teams.microsoft.com", "preview.portal.azure.com", "learn.microsoft.com", "review.learn.microsoft.com", "ppe.learn.microsoft.com", "dev.learn.microsoft.com"]; - // const trustedParentOrigin = getTrustedParentOrigin(); - // let trustedAuthority = (trustedParentOrigin.split("//")[1] || "").toLowerCase(); - // const isTrustedOrigin = allowedParentFrameAuthorities.some(origin => origin === trustedAuthority); - - // if (!isTrustedOrigin) { - // const errorMessage = "The origin '" + trustedParentOrigin + "' is not trusted."; - // xterminal.writeln(''); - // xterminal.writeln(errorMessage); - // throw new Error(errorMessage); - // } - - // trustedAuthority = (trustedParentOrigin.indexOf("https") === 0 ? "https://" : "http://") + trustedAuthority; - - const tokenInterval = setInterval(async () => { - authorizationToken - }, 1000 * 60 * 10); - - const intervalsToClear = intervalsToClearRef.current ?? []; - intervalsToClear.push(tokenInterval); - - // validate that the subscription id is registered in the Cloudshell namespace - try { - const response: any = await verifyCloudshellProviderRegistration(userContext.subscriptionId, authorizationToken); - if (response.registrationState !== "Registered") { - await registerCloudShellProvider(userContext.subscriptionId, authorizationToken); - } - } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to verify cloudshell provider registration.'); - intervalsToClear.forEach((val) => window.clearInterval(+val)); - throw err; - } - - const region = await getUserRegion(authorizationToken, userContext.subscriptionId).then((res) => { - // const reqId = (res.headers as any).get("x-ms-routing-request-id"); - // const location = reqId?.split(":")?.[0]?.toLowerCase() ?? ""; - // const validRegions = new Set(["westus", "southcentralus", "eastus", "northeurope", "westeurope", "centralindia", "southeastasia", "westcentralus", "eastus2euap", "centraluseuap"]); - // if (validRegions.has(location.toLowerCase())) { - // return location; - // } - // if (location === "centralus") { - // return "centraluseuap"; - // } - // if (location === "eastus2") { - // return "eastus2euap"; - // } - // return "westus"; - // }).catch((err) => { - // xterminal.writeln(''); - // xterminal.writeln('Unable to get user region.'); - // return "westus"; - return "westus"; - }); - - //const cloudshellToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false, "b677c290-cf4b-4a8e-a60e-91ba650a4abe"); - - xterminal.writeln('Requested Region ' + region); - const cloudshellToken = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6ImltaTBZMnowZFlLeEJ0dEFxS19UdDVoWUJUayIsImtpZCI6ImltaTBZMnowZFlLeEJ0dEFxS19UdDVoWUJUayJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNzQwMDQ2ODAzLCJuYmYiOjE3NDAwNDY4MDMsImV4cCI6MTc0MDA1MjQ4OSwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2U4MGZmZGE4LTlmZDUtNDQ4ZC05M2VhLWY5YzgyM2ZjN2RkOC9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVpRQWEvOFpBQUFBVWd4YU5ram1kVWZ5VlhmR3kwODJzaEFkUEFQYkd6NW1TNDBjNW0zM3hjQTNCYmpOSTVjNTArVloxMFNCbzNGNjV2Rml6a2J4Z2VuNXk1dXQvWGVVZmUvNHMyb1lSdkczTnR4V2NJK09samI2aHRBQzNuSk5uQ1JINnNnUHNqd2VBZFkxcXZTTTFnMUtZVmZ5MG11Nm5aL0NYQWhCSkpoNWNLLzRNS0F5TzZvc2NDZjN0Q2N3dS9ZcXd6ZzIwbG9UIiwiYW1yIjpbInJzYSIsIm1mYSJdLCJhcHBpZCI6ImI2NzdjMjkwLWNmNGItNGE4ZS1hNjBlLTkxYmE2NTBhNGFiZSIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiZTM4YzBiOTgtMzQ5OS00YWQzLTkwN2EtYjc2NzJjNzdkZTQ3IiwiZmFtaWx5X25hbWUiOiJKYWluIiwiZ2l2ZW5fbmFtZSI6IlNvdXJhYmgiLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIyNDA0OmY4MDE6ODAyODozOjhjZTU6MTk2ZDpjNWE3OmQ3YTYiLCJuYW1lIjoiU291cmFiaCBKYWluIiwib2lkIjoiZTgwZmZkYTgtOWZkNS00NDhkLTkzZWEtZjljODIzZmM3ZGQ4Iiwib25wcmVtX3NpZCI6IlMtMS01LTIxLTIxNDY3NzMwODUtOTAzMzYzMjg1LTcxOTM0NDcwNy0yNzA3MDY2IiwicHVpZCI6IjEwMDMyMDAxMUE2OTQ1RjAiLCJyaCI6IjEuQVJvQXY0ajVjdkdHcjBHUnF5MTgwQkhiUjBaSWYza0F1dGRQdWtQYXdmajJNQk1hQU9nYUFBLiIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInNpZCI6IjAwMjAxMzA5LTJhNjAtY2M1Yy1iNTMxLTNiMGQwNWFkMWY3NSIsInN1YiI6Ijh4c0R4U0tqcmcycXdXaTNYM0pmLXkxUkNXUjZ2UDBEZ0pFbEtoTW05bTAiLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InNvdXJhYmhqYWluQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzb3VyYWJoamFpbkBtaWNyb3NvZnQuY29tIiwidXRpIjoiN3BZYmN4MDRja3liNHZlYy1tMUdBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiYjc5ZmJmNGQtM2VmOS00Njg5LTgxNDMtNzZiMTk0ZTg1NTA5Il0sInhtc19pZHJlbCI6IjI4IDEiLCJ4bXNfdGNkdCI6MTI4OTI0MTU0N30.A3eOAHuSDbA3w4n5r4xaMzpchoMuQMzAy7g7pyWGpY-zHsbUykUDYgbSOpAytMDzkcL9pbVCPlB8OxNnFOtgUn0lBRxmInCf-xWp38WoxSy_kqJ59i6PSmjSyNRVxHP70b3dNO3ZT6rkdvWWghaImTV-thQoSQyO7jYJrgEwhu8wNUV_uEQ67IGTKdylo0TupIxYW6VxpfMWfkVGaPRuZHnjQe14PwisZIJ9KJnTkgsszrv_fefbUkiE4dcG9PaWmIfSs7vLAsszNp2IozTo5VReZCztmxdTY1bNSRd2AKYb3wgywOTbB5DDzUxLLr2VofK946_eN8bHAm6uouiNOw"; - try { - // do not use the subscription from the preferred settings use the one from the context - await putEphemeralUserSettings(userContext.subscriptionId, region, `${cloudshellToken}`); - } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to update user settings to ephemeral session.'); - intervalsToClear.forEach(val => window.clearInterval(+val)); - throw err; - } - - // verify user settings after they have been updated to ephemeral - try { - const userSettings = await getUserSettings(cloudshellToken); - const isValidUserSettings = validateUserSettings(userSettings); - if (!isValidUserSettings) { - throw new Error("Invalid user settings detected for ephemeral session."); - } - } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to verify user settings for ephemeral session.'); - xterminal.forEach((val) => window.clearInterval(+val)); - throw err; - } - - // trigger callback to provision console internal - let provisionConsoleResponse; - try { - provisionConsoleResponse = await provisionConsole(userContext.subscriptionId, cloudshellToken, region); - // statusPaneUpdateCommands.setTerminalUri(provisionConsoleResponse.properties.uri); - } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to provision console.'); - intervalsToClear.forEach((val) => window.clearInterval(+val)); - throw err; - } - - if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { - intervalsToClear.forEach((val) => window.clearInterval(+val)); - xterminal.writeln("Failed to provision console."); - throw new Error("Failed to provision console."); - } - - xterminal.writeln("Connecting to cloudshell..."); - xterminal.writeln("Please wait..."); - // connect the terminal - let connectTerminalResponse; - try { - connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, cloudshellToken, { rows: xterminal.rows, cols: xterminal.cols }); - } catch (err) { - xterminal.writeln(''); - xterminal.writeln('Unable to connect terminal.'); - intervalsToClear.forEach((val) => window.clearInterval(+val)); - throw err; - } - - const targetUri = provisionConsoleResponse.properties.uri + `/terminals?cols=${xterminal.cols}&rows=${xterminal.rows}&version=2019-01-01&shell=bash`; - const termId = connectTerminalResponse.id; - //statusPaneUpdateCommands.setTermId(termId); - - let socketUri = connectTerminalResponse.socketUri.replace(":443/", ""); - const targetUriBody = targetUri.replace('https://', '').split('?')[0]; - if (socketUri.indexOf(targetUriBody) === -1) { - socketUri = 'wss://' + targetUriBody + '/' + termId; - } - if (targetUriBody.includes('servicebus')) { - const targetUriBodyArr = targetUriBody.split('/'); - socketUri = 'wss://' + targetUriBodyArr[0] + '/$hc/' + targetUriBodyArr[1] + '/terminals/' + termId; - } - - // // provision appropriate first party permissions to cloudshell instance - // await postTokens(provisionConsoleResponse.properties.uri, authorizationToken).catch((err) => { - // xterminal.writeln('Unable to provision first party permissions to cloudshell instance.'); - // intervalsToClear.forEach((val) => window.clearInterval(+val)); - // throw err; - // }); - - const socket = new WebSocket(socketUri); - - configureSocket(socket, socketUri, xterminal, intervalsToClear, 0); - - // authorize the session - try { - const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri, cloudshellToken); - const cookieToken = authorizeResponse.token; - const a = document.createElement("img"); - a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); - } catch (err) { - xterminal.writeln('Unable to authroize the session'); - intervalsToClear.forEach((val) => window.clearInterval(+val)); - socket.close(); - throw err; - } - - xterminal.writeln("Connected to cloudshell."); - xterminal.focus(); - - return socket; -} - -export const validateUserSettings = (userSettings: Settings) => { - if (userSettings.sessionType !== SessionType.Ephemeral && userSettings.osType !== OsType.Linux) { - return false; - } else { - return true; - } -} - -export const enum OsType { - Linux = "linux", - Windows = "windows" -} - -export const enum ShellType { - Bash = "bash", - PowerShellCore = "pwsh" -} - -export const enum NetworkType { - Default = "Default", - Isolated = "Isolated" -} - -export const enum SessionType { - Mounted = "Mounted", - Ephemeral = "Ephemeral" -} - -// https://stackoverflow.com/q/38598280 (Is it possible to wrap a function and retain its types?) -export const trackedApiCall = , U>(apiCall: (...args: T) => Promise, name: string) => { - return async (...args: T): Promise => { - const startTime = Date.now(); - const result = await apiCall(...args); - const endTime = Date.now(); - return result; - }; -}; - -export const getUserRegion = trackedApiCall(async (authToken: string, subscriptionId: string) => { - const locale = getLocale(); - const locationUri = getArmUri("management.azure.com")(`/subscriptions/${subscriptionId}/locations?api-version=2022-12-01`).toString(); - return await fetch(locationUri, { - method: "get", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': authToken, - 'Accept-Language': locale, - 'x-ms-correlation-request-id': uuidv4(), - } - }); -}, "getUserRegion"); - -export type Settings = { - location: string; - sessionType: SessionType; - osType: OsType; -}; - -export const getUserSettings = trackedApiCall(async (authToken: string): Promise => { - // figure out how to set the Accept-Language dynamically - const armUri = getArmUri("management.azure.com")(`/providers/Microsoft.Portal/userSettings/cloudconsole?api-version=2023-02-01-preview`).toString();; - const locale = getLocale(); - const resp = await fetch(armUri, { - method: "get", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': authToken, - 'Accept-Language': locale, - 'x-ms-correlation-request-id': uuidv4(), - } - }); - - const json = await resp?.json() as any; - return { - location: json?.properties?.preferredLocation, - sessionType: json?.properties?.sessionType, - osType: json?.properties?.preferredOsType - }; -}, "getUserSettings"); - -export const verifyCloudshellProviderRegistration = async(subscriptionId: string, authToken: string) => { - const targetUri = getArmUri("management.azure.com")(`/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell?api-version=2022-12-01`).toString(); - const locale = getLocale(); - return await fetch(targetUri, { - method: "get", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': authToken, - 'Accept-Language': locale, - } - }); -}; - -export const registerCloudShellProvider = async (subscriptionId: string, authToken: string) => { - const targetUri = getArmUri("management.azure.com")(`/subscriptions/${subscriptionId}/providers/Microsoft.CloudShell/register?api-version=2022-12-01`).toString(); - return await fetch(targetUri, { - method: "post", - headers: { - 'Content-Length': "0", - 'Content-Type': 'application/json', - 'Authorization': authToken - } - }); -}; - -// TODO: update accept language header -export const putEphemeralUserSettings = trackedApiCall(async (userSubscriptionId: string, userRegion: string, authorizationToken: string) => { - const ephemeralSettings = { - properties: { - preferredOsType: OsType.Linux, - preferredShellType: ShellType.Bash, - preferredLocation: userRegion, - networkType: NetworkType.Default, - sessionType: SessionType.Ephemeral, - userSubscription: userSubscriptionId, - } - }; - - const armUri = getArmUri("management.azure.com")(`/providers/Microsoft.Portal/userSettings/cloudconsole?api-version=2023-02-01-preview`).toString(); - await fetch(armUri, { - method: "put", - body: JSON.stringify(ephemeralSettings), - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': authorizationToken, - 'Accept-Language': getLocale(), - } - }); -}, "putEphemeralUserSettings"); - -type provisionConsoleResponse = { - properties: { - osType: OsType; - provisioningState: string; - uri: string; - }; -}; - -export const provisionConsole = trackedApiCall(async (subscriptionId: string, authorizationToken: string, location: string): Promise => { - const armUri = getArmUri("management.azure.com")(`providers/Microsoft.Portal/consoles/default?api-version=2023-02-01-preview&feature.azureconsole.sessiontype=mounted&feature.azureconsole.usersubscription=${subscriptionId}`).toString(); - - const data = { - properties: { - osType: OsType.Linux - } - }; - const resp = await fetch(armUri, { - method: "put", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': authorizationToken, - 'x-ms-console-preferred-location': location, - 'x-ms-correlation-request-id': uuidv4(), - 'Accept-Language': getLocale() - }, - body: JSON.stringify(data) - }); - return resp.json(); -}, "provisionConsole"); - -export type ConnectTerminalResponse = { - id: string; - idleTimeout: string; - rootDirectory: string; - socketUri: string; - tokenUpdated: boolean; -}; - -export const postTokens = trackedApiCall(async (consoleUri: string, authorizationToken: string) => { - const targetUri = consoleUri + '/accessToken'; - let token = aadAuth.armToken; - - await fetch(targetUri, { - method: "post", - headers: { - 'Accept': 'application/json', - 'Authorization': authorizationToken, - 'x-ms-client-request-id': uuidv4(), - 'Accept-Language': getLocale() - }, - body: JSON.stringify({ token }) - }); -}, "postTokens"); - -export const connectTerminal = trackedApiCall(async (consoleUri: string, authorizationToken: string, size: { rows: number, cols: number }): Promise => { - const targetUri = consoleUri + `/terminals?cols=${size.cols}&rows=${size.rows}&version=2019-01-01&shell=bash`; - const resp = await fetch(targetUri, { - method: "post", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Content-Length': '2', - 'Authorization': authorizationToken, - 'x-ms-client-request-id': uuidv4(), - 'Accept-Language': getLocale(), - }, - body: "{}" // empty body is necessary - }); - return resp.json(); -}, "connectTerminal"); - -export type Authorization = { - token: string; -}; - -export const authorizeSession = trackedApiCall(async (consoleUri: string, accessToken: string): Promise => { - const targetUri = consoleUri + "/authorize"; - const resp = await fetch(targetUri, { - method: "post", - headers: { - 'Accept': 'application/json', - 'Authorization': accessToken, - 'Accept-Language': getLocale(), - "Content-Type": 'application/json' - }, - body: "{}" // empty body is necessary - }); - return resp.json(); -}, "authorizeSession"); - - -export const getArmUri = (origin: string): (relativePath: string) => string => { -let originNoTrailingSlash = origin; -if (origin.endsWith("/")) { - originNoTrailingSlash = originNoTrailingSlash.slice(0, originNoTrailingSlash.length - 1); -} else { - origin += "/"; -} - -return (relativePath: string) => { - if (!relativePath) { - throw new Error(`relativePath is required: ${relativePath}`); - } - - return `https://${relativePath.charAt(0) === "/" ? originNoTrailingSlash : origin}${relativePath}`; -}; -} - -export const getLocale = () => { - const langLocale = navigator.language; - return (langLocale && langLocale.length === 2 ? langLocale[1] : 'en-us'); -}; - -export const getTrustedParentOrigin = () => { - const searchParams = new URLSearchParams(window.location.search); - return searchParams.get("trustedAuthority") || ''; -} - -let keepAliveID: NodeJS.Timeout = null; -let pingCount = 0; - -export const configureSocket = (socket: WebSocket, uri: string, terminal: any, intervals: NodeJS.Timer[], socketRetryCount: number) => { - let jsonData = ''; - socket.onopen = () => { - terminal.writeln("Socket Opened"); - const initializeCommand = - `curl -s https://ipinfo.io \n` + - `curl -LO https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz \n` + - `tar -xvzf mongosh-2.3.8-linux-x64.tgz \n` + - `mkdir -p ~/mongosh && mv mongosh-2.3.8-linux-x64/* ~/mongosh/ \n` + - `echo 'export PATH=$PATH:$HOME/mongosh/bin' >> ~/.bashrc \n` + - `source ~/.bashrc \n` + - `mongosh --version \n`; - - terminal.writeln(initializeCommand); - socket.send(initializeCommand); - - const keepSocketAlive = (socket: WebSocket) => { - if (socket.readyState === WebSocket.OPEN) { - if ((pingCount / 60) >= 20) { - socket.close(); - } else { - socket.send(''); - pingCount++; - keepAliveID = setTimeout(() => keepSocketAlive(socket), 1000); - } - } - }; - keepSocketAlive(socket); - }; - - socket.onclose = () => { - terminal.writeln("Socket Closed"); - if (keepAliveID) { - clearTimeout(keepAliveID); - pingCount = 0; - } - intervals.forEach((val) => { - window.clearInterval(+val); - }); - - terminal.writeln("Session terminated. Please refresh the page to start a new session."); - }; - - socket.onerror = () => { - terminal.writeln("terminal reconnected"); - if (socketRetryCount < 10 && socket.readyState !== WebSocket.CLOSED) { - configureSocket(socket, uri, terminal, intervals, socketRetryCount + 1); - } else { - // log an error indicating socket connection failed - terminal.writeln("Socket connection closed"); - // close the socket - socket.close(); - } - }; - - socket.onmessage = (event: MessageEvent) => { - terminal.writeln("Socket onMessage"); - // if we are sending and receiving messages the terminal is not idle set ping count to 0 - pingCount = 0; - - // check if we are dealing with array buffer or string - let eventData = ''; - if (typeof event.data === "object") { - try { - const enc = new TextDecoder("utf-8"); - eventData = enc.decode(event.data as any); - } catch (e) { - // not array buffer - } - } - if (typeof event.data === 'string') { - eventData = event.data; - - terminal.write(eventData); - } - - // process as one line or process as multiline - if (eventData.includes("ie_us") && eventData.includes("ie_ue")) { - // process as one line - const statusData = eventData.split('ie_us')[1].split('ie_ue')[0]; - console.log(statusData); - } else if (eventData.includes("ie_us")) { - // check for start - jsonData += eventData.split('ie_us')[1]; - } else if (eventData.includes("ie_ue")) { - // check for end and process the command - jsonData += eventData.split('ie_ue')[0]; - console.log(jsonData); - jsonData = ''; - } else if (jsonData.length > 0) { - // check if the line is all data then just concatenate - jsonData += eventData; - } - }; - - return socket; -}; - -export default XTermComponent; diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 175c4bcff..1e3129518 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -39,6 +39,7 @@ export type Features = { readonly copilotChatFixedMonacoEditorHeight: boolean; readonly enablePriorityBasedExecution: boolean; readonly disableConnectionStringLogin: boolean; + readonly enableCloudShell: boolean; // can be set via both flight and feature flag autoscaleDefault: boolean; @@ -110,6 +111,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), + enableCloudShell: "true" === get("enablecloudshell"), }; } diff --git a/src/Utils/arm/request.ts b/src/Utils/arm/request.ts index 399900ca3..21434fbf1 100644 --- a/src/Utils/arm/request.ts +++ b/src/Utils/arm/request.ts @@ -47,6 +47,7 @@ interface Options { body?: unknown; queryParams?: ARMQueryParams; contentType?: string; + customHeaders?: Record; } export async function armRequestWithoutPolling({ @@ -57,6 +58,7 @@ export async function armRequestWithoutPolling({ body: requestBody, queryParams, contentType, + customHeaders }: Options): Promise<{ result: T; operationStatusUrl: string }> { const url = new URL(path, host); url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion); @@ -65,7 +67,7 @@ export async function armRequestWithoutPolling({ queryParams.metricNames && url.searchParams.append("metricnames", queryParams.metricNames); } - if (!userContext.authorizationToken) { + if (!userContext.authorizationToken && !customHeaders["Authorization"]) { throw new Error("No authority token provided"); } @@ -74,6 +76,7 @@ export async function armRequestWithoutPolling({ headers: { Authorization: userContext.authorizationToken, [HttpHeaders.contentType]: contentType || "application/json", + ...customHeaders }, body: requestBody ? JSON.stringify(requestBody) : undefined, }); @@ -109,6 +112,7 @@ export async function armRequest({ body: requestBody, queryParams, contentType, + customHeaders }: Options): Promise { const armRequestResult = await armRequestWithoutPolling({ host, @@ -118,6 +122,7 @@ export async function armRequest({ body: requestBody, queryParams, contentType, + customHeaders }); const operationStatusUrl = armRequestResult.operationStatusUrl; if (operationStatusUrl) {