ui and functional changes

This commit is contained in:
Sourabh Jain
2025-03-25 00:24:17 +05:30
parent 10b0da2190
commit 8b4eaa95ea
4 changed files with 156 additions and 99 deletions

View File

@@ -18,10 +18,18 @@ export const CloudShellTerminalComponent: React.FC<CloudShellTerminalProps> = ({
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
useEffect(() => { useEffect(() => {
// Initialize XTerm instance // Initialize XTerm instance
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
theme: { background: "#1d1f21", foreground: "#c5c8c6" } cursorStyle: 'bar',
fontFamily: 'Courier New, monospace',
fontSize: 14,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#ffcc00"
},
scrollback: 1000
}); });
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
@@ -30,8 +38,23 @@ export const CloudShellTerminalComponent: React.FC<CloudShellTerminalProps> = ({
if (terminalRef.current) { if (terminalRef.current) {
term.open(terminalRef.current); term.open(terminalRef.current);
xtermRef.current = term; 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 // Adjust terminal size on window resize
const handleResize = () => fitAddon.fit(); const handleResize = () => fitAddon.fit();
@@ -47,14 +70,20 @@ export const CloudShellTerminalComponent: React.FC<CloudShellTerminalProps> = ({
// Cleanup function to close WebSocket and dispose terminal // Cleanup function to close WebSocket and dispose terminal
return () => { return () => {
if (!socketRef.current) return; // Prevent errors if WebSocket is not initialized
if (socketRef.current) { if (socketRef.current) {
socketRef.current.close(); // Close WebSocket connection socketRef.current.close(); // Close WebSocket connection
} }
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
term.dispose(); // Clean up XTerm instance term.dispose(); // Clean up XTerm instance
const styleElement = document.getElementById("xterm-custom-style");
if (styleElement) {
styleElement.remove(); // Clean up CSS on unmount
}
}; };
}, []); }, []);
return <div ref={terminalRef} style={{ width: "100%", height: "500px" }} />; return <div ref={terminalRef} style={{ width: "100%", height: "500px"}} />;
}; };

View File

@@ -62,7 +62,7 @@ export const putEphemeralUserSettings = async (userSubscriptionId: string, userR
preferredOsType: OsType.Linux, preferredOsType: OsType.Linux,
preferredShellType: ShellType.Bash, preferredShellType: ShellType.Bash,
preferredLocation: userRegion, preferredLocation: userRegion,
networkType: NetworkType.Default, networkType: (!vNetSettings || Object.keys(vNetSettings).length === 0) ? NetworkType.Default : (vNetSettings ? NetworkType.Isolated : NetworkType.Default),
sessionType: SessionType.Ephemeral, sessionType: SessionType.Ephemeral,
userSubscription: userSubscriptionId, userSubscription: userSubscriptionId,
vnetSettings: vNetSettings ?? {} vnetSettings: vNetSettings ?? {}

View File

@@ -38,9 +38,9 @@ export type UserSettingProperties = {
} }
export type VnetSettings = { export type VnetSettings = {
networkProfileResourceId: string; networkProfileResourceId?: string;
relayNamespaceResourceId: string; relayNamespaceResourceId?: string;
location: string; location?: string;
} }
export type ProvisionConsoleResponse = { export type ProvisionConsoleResponse = {

View File

@@ -2,7 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved. * 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 { listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { Terminal } from "xterm"; import { Terminal } from "xterm";
import { TerminalKind } from "../../../Contracts/ViewModels"; import { TerminalKind } from "../../../Contracts/ViewModels";
@@ -15,22 +15,37 @@ import { LogError, LogInfo } from "./LogFormatter";
export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => { export const startCloudShellTerminal = async (terminal: Terminal, shellType: TerminalKind) => {
// validate that the subscription id is registered in the CloudShell namespace // validate that the subscription id is registered in the CloudShell namespace
terminal.writeln("");
try { try {
const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId); terminal.writeln("\x1B[34mVerifying CloudShell provider registration...\x1B[0m");
if (response.registrationState !== "Registered") { const response: any = await verifyCloudShellProviderRegistration(userContext.subscriptionId);
await registerCloudShellProvider(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) { } 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; throw err;
} }
const settings = await getUserSettings(); terminal.writeln("");
let vNetSettings = {}; 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) { if(settings?.properties?.vnetSettings && Object.keys(settings?.properties?.vnetSettings).length > 0) {
terminal.writeln(" Network Profile Resource ID: " + settings.properties.vnetSettings.networkProfileResourceId); terminal.writeln("\x1B[1mUsing existing VNet settings:\x1B[0m");
terminal.writeln(" Relay Namespace Resource ID: " + settings.properties.vnetSettings.relayNamespaceResourceId); 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 = { vNetSettings = {
networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId, networkProfileResourceId: settings.properties.vnetSettings.networkProfileResourceId,
@@ -40,103 +55,101 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
} }
else 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("");
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[1mSelect an option to continue:\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("\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(); terminal.focus();
let isDefaultSetting = false; let isDefaultSetting = false;
const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => { const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => {
terminal.writeln("")
if (key === "1") { if (key === "1") {
terminal.writeln("\x1B[1;32m Pressed 1, Continuing with current/default settings.\x1B[0m"); terminal.writeln("\x1B[34mYou selected option 1: Proceeding with current/default settings.\x1B[0m");
isDefaultSetting = true;
handleKeyPress.dispose(); handleKeyPress.dispose();
} }
else if (key === "2") { else if (key === "2") {
isDefaultSetting = false; terminal.writeln("\x1B[34mYou selected option 2: Please provide the following details.\x1B[0m");
handleKeyPress.dispose(); 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<VnetModel>(`/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<RelayNamespaceResponse>(
`/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") { else if (key === "3") {
isDefaultSetting = true; terminal.writeln("\x1B[34mYou selected option 3: Resetting VNet settings to default.\x1B[0m");
vNetSettings = {}; vNetSettings = {};
handleKeyPress.dispose(); handleKeyPress.dispose();
} }
else { 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 setTimeout(() => terminal.dispose(), 2000); // Close terminal after 2 sec
handleKeyPress.dispose(); handleKeyPress.dispose();
return; return;
} }
if (!isDefaultSetting) { terminal.writeln("\x1B[34mDetermining CloudShell region...\x1B[0m");
terminal.writeln("\x1B[1;32m Pressed 2, Enter below details:\x1B[0m"); const region = vNetSettings?.location ?? userContext.databaseAccount?.location;
const subscriptionId = await askQuestion(terminal, "Existing VNet Subscription ID"); terminal.writeln(" - Identified Database Account Region: \x1B[32m" + region + "\x1B[0m");
const resourceGroup = await askQuestion(terminal, "Existing VNet Resource Group");
const vNetName = await askQuestion(terminal, "Existing VNet Name");
const vNetConfig = await getARMInfo<VnetModel>(`/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<RelayNamespaceResponse>(
`/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 defaultCloudShellRegion = "westus";
const resolvedRegion = getNormalizedRegion(region, defaultCloudShellRegion); const resolvedRegion = getNormalizedRegion(region, defaultCloudShellRegion);
terminal.writeln("\x1B[33mAttempting to provision CloudShell in region: \x1B[1m" + resolvedRegion + "\x1B[0m");
try { try {
terminal.writeln("\x1B[34mProvisioning CloudShell session...\x1B[0m");
var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(resolvedRegion, terminal, vNetSettings); var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(resolvedRegion, terminal, vNetSettings);
} }
catch (err) { catch (err) {
terminal.writeln(LogError(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); var { socketUri, provisionConsoleResponse, targetUri } = await provisionCloudShellSession(defaultCloudShellRegion, terminal, vNetSettings);
} }
@@ -162,12 +175,15 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
// authorize the session // authorize the session
try { try {
terminal.writeln("\x1B[34mAuthorizing CloudShell session...\x1B[0m");
const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri); const authorizeResponse = await authorizeSession(provisionConsoleResponse.properties.uri);
const cookieToken = authorizeResponse.token; const cookieToken = authorizeResponse.token;
const a = document.createElement("img"); const a = document.createElement("img");
a.src = targetUri + "&token=" + encodeURIComponent(cookieToken); a.src = targetUri + "&token=" + encodeURIComponent(cookieToken);
terminal.writeln("\x1B[32mAuthorization successful. Establishing connection...\x1B[0m");
} catch (err) { } catch (err) {
terminal.writeln(LogError('Unable to authorize the session')); terminal.writeln("\x1B[31mError: Unable to authorize CloudShell session.\x1B[0m");
socket.close(); socket.close();
throw err; throw err;
} }
@@ -182,7 +198,8 @@ export const startCloudShellTerminal = async (terminal: Terminal, shellType: Ter
const askQuestion = (terminal: any, question: string) => { const askQuestion = (terminal: any, question: string) => {
return new Promise<string>((resolve) => { return new Promise<string>((resolve) => {
terminal.write(`\x1B[1;34m${question}: \x1B[0m`); const prompt = `\x1B[1;34m${question}: \x1B[0m`;
terminal.writeln(prompt);
terminal.focus(); terminal.focus();
let response = ""; let response = "";
@@ -194,11 +211,18 @@ const askQuestion = (terminal: any, question: string) => {
} else if (data === "\u007F" || data === "\b") { // Handle backspace } else if (data === "\u007F" || data === "\b") { // Handle backspace
if (response.length > 0) { if (response.length > 0) {
response = response.slice(0, -1); 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; 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 vNetSettings: object
): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => { ): Promise<{ socketUri?: string; provisionConsoleResponse?: any; targetUri?: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
terminal.writeln("");
// Show consent message inside the terminal // 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;33m⚠ CloudShell is not available in your database account region.\x1B[0m`);
terminal.writeln("\x1B[1;37m Press 'Y' to continue or 'N' to exit.\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(); terminal.focus();
// Listen for user input // Listen for user input
const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => { const handleKeyPress = terminal.onKey(async ({ key }: { key: string }) => {
@@ -310,8 +336,9 @@ const provisionCloudShellSession = async(
handleKeyPress.dispose(); handleKeyPress.dispose();
if (key.toLowerCase() === "y") { if (key.toLowerCase() === "y") {
terminal.writeln("\x1B[1;32mConsent given. Requesting CloudShell. !\x1B[0m"); terminal.writeln("\x1B[1;32mConsent received. Requesting CloudShell...\x1B[0m");
terminal.writeln(LogInfo('Applying fresh user settings...')); terminal.writeln("");
terminal.writeln(LogInfo('Applying User Settings...'));
try { try {
await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings); await putEphemeralUserSettings(userContext.subscriptionId, resolvedRegion, vNetSettings);
} catch (err) { } catch (err) {
@@ -327,19 +354,20 @@ const provisionCloudShellSession = async(
terminal.writeln(LogError('Unable to provision console.')); terminal.writeln(LogError('Unable to provision console.'));
return reject(err); return reject(err);
} }
// Add check for provisioning state
if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") { if (provisionConsoleResponse.properties.provisioningState !== "Succeeded") {
terminal.writeln(LogError("Failed to provision console.")); terminal.writeln(LogError("Failed to provision console."));
return reject(new Error("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 // connect the terminal
let connectTerminalResponse; let connectTerminalResponse;
try { try {
connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, { rows: terminal.rows, cols: terminal.cols }); connectTerminalResponse = await connectTerminal(provisionConsoleResponse.properties.uri, { rows: terminal.rows, cols: terminal.cols });
} catch (err) { } catch (err) {
terminal.writeln(LogError('Unable to connect terminal.')); terminal.writeln(LogError(`Unable to connect terminal. ${err}`));
return reject(err); return reject(err);
} }
@@ -359,7 +387,7 @@ const provisionCloudShellSession = async(
} else if (key.toLowerCase() === "n") { } 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 setTimeout(() => terminal.dispose(), 2000); // Close terminal after 2 sec
return resolve({}); return resolve({});
} }