diff --git a/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx b/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx index 5de6a617b..cae33e705 100644 --- a/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx +++ b/src/Explorer/Tabs/CloudShellTab/CloudShellTabComponent.tsx @@ -18,10 +18,18 @@ export const CloudShellTerminalComponent: React.FC = ({ const fitAddon = new FitAddon(); useEffect(() => { - // Initialize XTerm instance - const term = new Terminal({ - cursorBlink: true, - theme: { background: "#1d1f21", foreground: "#c5c8c6" } + // Initialize XTerm instance + const term = new Terminal({ + cursorBlink: true, + cursorStyle: 'bar', + fontFamily: 'Courier New, monospace', + fontSize: 14, + theme: { + background: "#1e1e1e", + foreground: "#d4d4d4", + cursor: "#ffcc00" + }, + scrollback: 1000 }); term.loadAddon(fitAddon); @@ -30,8 +38,23 @@ export const CloudShellTerminalComponent: React.FC = ({ if (terminalRef.current) { term.open(terminalRef.current); xtermRef.current = term; + + // Ensure the CSS is injected only once + if (!document.getElementById("xterm-custom-style")) { + const style = document.createElement("style"); + style.id = "xterm-custom-style"; // Unique ID to prevent duplicates + style.innerHTML = ` + .xterm-text-layer { + transform: translateX(10px); /* Adds left padding */ + } + `; + document.head.appendChild(style); + } + } + + if (fitAddon) { + fitAddon.fit(); } - fitAddon.fit(); // Adjust terminal size on window resize const handleResize = () => fitAddon.fit(); @@ -47,14 +70,20 @@ export const CloudShellTerminalComponent: React.FC = ({ // Cleanup function to close WebSocket and dispose terminal return () => { + if (!socketRef.current) return; // Prevent errors if WebSocket is not initialized if (socketRef.current) { socketRef.current.close(); // Close WebSocket connection } window.removeEventListener('resize', handleResize); term.dispose(); // Clean up XTerm instance + + const styleElement = document.getElementById("xterm-custom-style"); + if (styleElement) { + styleElement.remove(); // Clean up CSS on unmount + } }; }, []); - return
; + return
; }; diff --git a/src/Explorer/Tabs/CloudShellTab/Data.tsx b/src/Explorer/Tabs/CloudShellTab/Data.tsx index 639e484b6..5f011735f 100644 --- a/src/Explorer/Tabs/CloudShellTab/Data.tsx +++ b/src/Explorer/Tabs/CloudShellTab/Data.tsx @@ -62,7 +62,7 @@ export const putEphemeralUserSettings = async (userSubscriptionId: string, userR preferredOsType: OsType.Linux, preferredShellType: ShellType.Bash, preferredLocation: userRegion, - networkType: NetworkType.Default, + networkType: (!vNetSettings || Object.keys(vNetSettings).length === 0) ? NetworkType.Default : (vNetSettings ? NetworkType.Isolated : NetworkType.Default), sessionType: SessionType.Ephemeral, userSubscription: userSubscriptionId, vnetSettings: vNetSettings ?? {} diff --git a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx index 0f9b8795b..6dffbeaf7 100644 --- a/src/Explorer/Tabs/CloudShellTab/DataModels.tsx +++ b/src/Explorer/Tabs/CloudShellTab/DataModels.tsx @@ -38,9 +38,9 @@ export type UserSettingProperties = { } export type VnetSettings = { - networkProfileResourceId: string; - relayNamespaceResourceId: string; - location: string; + networkProfileResourceId?: string; + relayNamespaceResourceId?: string; + location?: string; } export type ProvisionConsoleResponse = { diff --git a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx index 9d939326e..b0983ccbc 100644 --- a/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx +++ b/src/Explorer/Tabs/CloudShellTab/UseTerminal.tsx @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. */ -import { RelayNamespaceResponse, VnetModel } from "Explorer/Tabs/CloudShellTab/DataModels"; +import { RelayNamespaceResponse, VnetModel, VnetSettings } from "Explorer/Tabs/CloudShellTab/DataModels"; import { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { Terminal } from "xterm"; import { TerminalKind } from "../../../Contracts/ViewModels"; @@ -15,22 +15,37 @@ import { LogError, LogInfo } from "./LogFormatter"; export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => { // validate that the subscription id is registered in the CloudShell namespace + terminal.writeln(""); try { - const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId); - if (response.registrationState !== "Registered") { - await registerCloudShellProvider(userContext.subscriptionId); - } + terminal.writeln("\x1B[34mVerifying CloudShell provider registration...\x1B[0m"); + const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId); + if (response.registrationState !== "Registered") { + terminal.writeln("\x1B[33mCloudShell provider registration is not found. Registering now...\x1B[0m"); + await registerCloudShellProvider(userContext.subscriptionId); + terminal.writeln("\x1B[32mCloudShell provider registration completed successfully.\x1B[0m"); + } } catch (err) { - terminal.writeln(LogError('Unable to verify CloudShell provider registration.')); + terminal.writeln("\x1B[31mError: Unable to verify CloudShell provider registration.\x1B[0m"); throw err; } - const settings = await getUserSettings(); - let vNetSettings = {}; + terminal.writeln(""); + terminal.writeln("\x1B[34mFetching user settings...\x1B[0m"); + let settings; + try { + settings = await getUserSettings(); + } + catch (err) { + terminal.writeln("\x1B[33mNo user settings found. Proceeding with default settings.\x1B[0m"); + } + + let vNetSettings: VnetSettings; if(settings?.properties?.vnetSettings && Object.keys(settings?.properties?.vnetSettings).length > 0) { - terminal.writeln(" Network Profile Resource ID: " + settings.properties.vnetSettings.networkProfileResourceId); - terminal.writeln(" Relay Namespace Resource ID: " + settings.properties.vnetSettings.relayNamespaceResourceId); + terminal.writeln("\x1B[1mUsing existing VNet settings:\x1B[0m"); + terminal.writeln(" - Network Profile Resource ID: \x1B[32m" + settings.properties.vnetSettings.networkProfileResourceId + "\x1B[0m"); + terminal.writeln(" - Relay Namespace Resource ID: \x1B[32m" + settings.properties.vnetSettings.relayNamespaceResourceId + "\x1B[0m"); + terminal.writeln(" - VNet Location: \x1B[32m" + settings.properties.vnetSettings.location + "\x1B[0m"); vNetSettings = { networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId, @@ -40,103 +55,101 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter } else { - terminal.writeln("\x1B[1;31m No VNet settings found. Continuing with default settings\x1B[0m"); + terminal.writeln("\x1B[33mNo existing VNet settings found. Proceeding with default settings.\x1B[0m"); } - terminal.writeln("\x1B[1;37m Press '1' to continue with current or default setting.\x1B[0m"); - terminal.writeln("\x1B[1;37m Press '2' to configure new VNet to CloudShell.\x1B[0m"); - terminal.writeln("\x1B[1;37m Press '3' to Reset CloudShell VNet Settings.\x1B[0m"); - terminal.writeln("\x1B[1;37m Note: To learn how to configure VNet for CloudShell, go to this link https://aka.ms/cloudhellvnetsetup \x1B[0m"); + terminal.writeln(""); + + terminal.writeln("\x1B[1mSelect an option to continue:\x1B[0m"); + terminal.writeln("\x1B[33m1 - Use current/default VNet settings\x1B[0m"); + terminal.writeln("\x1B[33m2 - Configure a new VNet for CloudShell\x1B[0m"); + terminal.writeln("\x1B[33m3 - Reset CloudShell VNet settings to default\x1B[0m"); + terminal.writeln("\x1B[34mFor details on configuring VNet for CloudShell, visit: https://aka.ms/cloudshellvnetsetup\x1B[0m"); terminal.focus(); let isDefaultSetting = false; const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => { - + terminal.writeln("") if (key === "1") { - terminal.writeln("\x1B[1;32m Pressed 1, Continuing with current/default settings.\x1B[0m"); - - isDefaultSetting = true; + terminal.writeln("\x1B[34mYou selected option 1: Proceeding with current/default settings.\x1B[0m"); handleKeyPress.dispose(); } else if (key === "2") { - isDefaultSetting = false; + terminal.writeln("\x1B[34mYou selected option 2: Please provide the following details.\x1B[0m"); handleKeyPress.dispose(); + + const subscriptionId = await askQuestion(terminal, "Enter VNet Subscription ID"); + const resourceGroup = await askQuestion(terminal, "Enter VNet Resource Group"); + const vNetName = await askQuestion(terminal, "Enter VNet Name"); + + terminal.writeln("\x1B[34mFetching VNet details...\x1B[0m"); + const vNetConfig = await getARMInfo(`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vNetName}`); + + terminal.writeln(" - VNet Location: \x1B[32m" + vNetConfig.location + "\x1B[0m"); + terminal.writeln(" - Available Network Profiles:"); + const ipConfigurationProfiles = vNetConfig.properties.subnets.reduce<{ id: string }[]>( + (acc, subnet) => acc.concat(subnet.properties.ipConfigurationProfiles || []), + []); + for (let i = 0; i < ipConfigurationProfiles.length; i++) { + const match = ipConfigurationProfiles[i].id.match(/\/networkProfiles\/([^/]+)/); + const result = match ? `${match[1]}` : null; + terminal.writeln(" - \x1B[32m" + result + "\x1B[0m"); + } + + const networkProfile = await askQuestion(terminal, "Network Profile to use"); + + const relays = await getARMInfo( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces`, + "2024-01-01"); + terminal.writeln(" - Available Network Relays:"); + for (let i = 0; i < relays.value.length; i++) { + terminal.writeln(" - \x1B[32m" + relays.value[i].name + "\x1B[0m"); + } + + const relayName = await askQuestion(terminal, "Network Relay to use:"); + + const networkProfileResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfile.replace(/[\n\r]/g, "")}`; + const relayResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces/${relayName.replace(/[\n\r]/g, "")}`; + + vNetSettings = { + networkProfileResourceId: networkProfileResourceId, + relayNamespaceResourceId: relayResourceId, + location: vNetConfig.location + }; } else if (key === "3") { - isDefaultSetting = true; + terminal.writeln("\x1B[34mYou selected option 3: Resetting VNet settings to default.\x1B[0m"); + vNetSettings = {}; handleKeyPress.dispose(); } else { - terminal.writeln("\x1B[1;31m Entered Wrong Input, only 1 or 2 are allowed. Exiting...\x1B[0m"); + terminal.writeln("\x1B[31mInvalid selection. Only options 1, 2, and 3 are allowed. Exiting...\x1B[0m"); setTimeout(() => terminal.dispose(), 2000); // Close terminal after 2 sec handleKeyPress.dispose(); return; } - if (!isDefaultSetting) { - terminal.writeln("\x1B[1;32m Pressed 2, Enter below details:\x1B[0m"); - const subscriptionId = await askQuestion(terminal, "Existing VNet Subscription ID"); - const resourceGroup = await askQuestion(terminal, "Existing VNet Resource Group"); - const vNetName = await askQuestion(terminal, "Existing VNet Name"); + terminal.writeln("\x1B[34mDetermining CloudShell region...\x1B[0m"); + const region = vNetSettings?.location ?? userContext.databaseAccount?.location; + terminal.writeln(" - Identified Database Account Region: \x1B[32m" + region + "\x1B[0m"); - const vNetConfig = await getARMInfo(`/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/virtualNetworks/${vNetName}`); - - terminal.writeln("Suggested Network Profiles:"); - const ipConfigurationProfiles = vNetConfig.properties.subnets.reduce<{ id: string }[]>( - (acc, subnet) => acc.concat(subnet.properties.ipConfigurationProfiles || []), - [] - ); - for (let i = 0; i < ipConfigurationProfiles.length; i++) { - const match = ipConfigurationProfiles[i].id.match(/\/networkProfiles\/([^/]+)/); - const result = match ? `/${match[1]}` : null; - terminal.writeln(result); - } - - const networkProfile = await askQuestion(terminal, "Associated Network Profile"); - - const relays = await getARMInfo( - `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces`, - "2024-01-01"); - terminal.writeln("Suggested Network Relays:"); - for (let i = 0; i < relays.value.length; i++) { - terminal.writeln(relays.value[i].name); - } - - const relayName = await askQuestion(terminal, "Network Relay"); - - const networkProfileResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/networkProfiles/${networkProfile}`; - const relayResourceId = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.Relay/namespaces/${relayName}`; - - // const vNetRules = userContext.databaseAccount.properties?.virtualNetworkRules; - // if(vNetRules && vNetRules.length > 0) { - // for (let i = 0; i < vNetRules.length; i++) { - // const vNetName = vNetRules[i].id; - - // terminal.writeln(vNetName); - // } - // } - - vNetSettings = { - networkProfileResourceId: networkProfileResourceId, - relayNamespaceResourceId: relayResourceId, - location: userContext.databaseAccount.location - }; - } - - const region = userContext.databaseAccount?.location; - terminal.writeln(LogInfo(` Database Account Region identified as '${region}'`)); - const defaultCloudShellRegion = "westus"; const resolvedRegion = getNormalizedRegion(region, defaultCloudShellRegion); + terminal.writeln("\x1B[33mAttempting to provision CloudShell in region: \x1B[1m" + resolvedRegion + "\x1B[0m"); + try { + terminal.writeln("\x1B[34mProvisioning CloudShell session...\x1B[0m"); var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(resolvedRegion, terminal, vNetSettings); } catch (err) { terminal.writeln(LogError(err)); - terminal.writeln(LogError(`Unable to provision console in request region, Falling back to default region i.e. ${defaultCloudShellRegion}`)); + terminal.writeln("\x1B[31mError: Unable to provision CloudShell in the requested region.\x1B[0m"); + terminal.writeln("\x1B[33mFalling back to default region: " + defaultCloudShellRegion + "\x1B[0m"); + terminal.writeln("\x1B[34mAttempting to provision CloudShell in the default region...\x1B[0m"); + var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(defaultCloudShellRegion, terminal, vNetSettings); } @@ -162,12 +175,15 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter // authorize the session try { + terminal.writeln("\x1B[34mAuthorizing CloudShell session...\x1B[0m"); + const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); const cookieToken = authorizeResponse.token; const a = document.createElement("img"); a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); + terminal.writeln("\x1B[32mAuthorization successful. Establishing connection...\x1B[0m"); } catch (err) { - terminal.writeln(LogError('Unable to authorize the session')); + terminal.writeln("\x1B[31mError: Unable to authorize CloudShell session.\x1B[0m"); socket.close(); throw err; } @@ -182,7 +198,8 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter const askQuestion = (terminal: any, question: string) => { return new Promise((resolve) => { - terminal.write(`\x1B[1;34m${question}: \x1B[0m`); + const prompt = `\x1B[1;34m${question}: \x1B[0m`; + terminal.writeln(prompt); terminal.focus(); let response = ""; @@ -194,11 +211,18 @@ const askQuestion = (terminal: any, question: string) => { } else if (data === "\u007F" || data === "\b") { // Handle backspace if (response.length > 0) { response = response.slice(0, -1); - terminal.write("\b \b"); // Move cursor back, clear character + terminal.write("\x1B[D \x1B[D");// Move cursor back, clear character } - } else { + } else if (data.charCodeAt(0) >= 32) { // Ignore control characters response += data; - terminal.write(data); // Display the typed or pasted characters + terminal.write(data); // Display typed characters + } + }); + + // Prevent cursor movement beyond the prompt + terminal.onKey(({ domEvent} : { domEvent: KeyboardEvent }) => { + if (domEvent.key === "ArrowLeft" && response.length === 0) { + domEvent.preventDefault(); // Stop moving left beyond the question } }); }); @@ -299,10 +323,12 @@ const provisionCloudShellSession = async( vNetSettings: object ): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => { return new Promise((resolve, reject) => { + terminal.writeln(""); // Show consent message inside the terminal - terminal.writeln(`\x1B[1;33m ⚠️ Are you agreeing to continue with CloudShell terminal at ${resolvedRegion}.\x1B[0m`); - terminal.writeln("\x1B[1;37m Press 'Y' to continue or 'N' to exit.\x1B[0m"); - + terminal.writeln(`\x1B[1;33m⚠️ CloudShell is not available in your database account region.\x1B[0m`); + terminal.writeln(`\x1B[1;33mWould you like to continue with CloudShell in ${resolvedRegion} instead?\x1B[0m`); + terminal.writeln("\x1B[1;37mPress 'Y' to proceed or 'N' to cancel.\x1B[0m"); + terminal.focus(); // Listen for user input const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => { @@ -310,8 +336,9 @@ const provisionCloudShellSession = async( handleKeyPress.dispose(); if (key.toLowerCase() === "y") { - terminal.writeln("\x1B[1;32mConsent given. Requesting CloudShell. !\x1B[0m"); - terminal.writeln(LogInfo('Applying fresh user settings...')); + terminal.writeln("\x1B[1;32m✔ Consent received. Requesting CloudShell...\x1B[0m"); + terminal.writeln(""); + terminal.writeln(LogInfo('Applying User Settings...')); try { await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings); } catch (err) { @@ -327,19 +354,20 @@ const provisionCloudShellSession = async( terminal.writeln(LogError('Unable to provision console.')); return reject(err); } - + + // Add check for provisioning state if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { terminal.writeln(LogError("Failed to provision console.")); return reject(new Error("Failed to provision console.")); } - terminal.writeln(LogInfo("Connecting to CloudShell Terminal...")); + terminal.writeln("\x1B[34mConnecting to CloudShell Terminal...\x1B[0m"); // connect the terminal let connectTerminalResponse; try { connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, { rows: terminal.rows, cols: terminal.cols }); } catch (err) { - terminal.writeln(LogError('Unable to connect terminal.')); + terminal.writeln(LogError(`Unable to connect terminal. ${err}`)); return reject(err); } @@ -359,7 +387,7 @@ const provisionCloudShellSession = async( } else if (key.toLowerCase() === "n") { - terminal.writeln("\x1B[1;31m Consent denied. Exiting...\x1B[0m"); + terminal.writeln("\x1B[1;31mConsent denied. Exiting...\x1B[0m"); setTimeout(() => terminal.dispose(), 2000); // Close terminal after 2 sec return resolve({}); }