From acfef1832200f75023684234d175e2764ff1f9bc Mon Sep 17 00:00:00 2001 From: Eric Johnson Date: Thu, 11 Apr 2024 12:33:06 -0700 Subject: [PATCH 1/5] Upgrade to version 0.8 --- build/azure-pipelines.yml | 2 +- build/scripts/CreateBuildInfo.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 66f6fd4..0ed251e 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -20,7 +20,7 @@ parameters: - release variables: - MSIXVersion: '0.700' + MSIXVersion: '0.800' solution: '**/DevHomeAzureExtension.sln' appxPackageDir: 'AppxPackages' testOutputArtifactDir: 'TestResults' diff --git a/build/scripts/CreateBuildInfo.ps1 b/build/scripts/CreateBuildInfo.ps1 index 7c6f4b6..6cb9d4d 100644 --- a/build/scripts/CreateBuildInfo.ps1 +++ b/build/scripts/CreateBuildInfo.ps1 @@ -5,7 +5,7 @@ Param( ) $Major = "0" -$Minor = "7" +$Minor = "8" $Patch = "99" # default to 99 for local builds $versionSplit = $Version.Split("."); From 959bf8c51f83ea2ba0c3905c2ef5344123054f24 Mon Sep 17 00:00:00 2001 From: David Bennett Date: Tue, 16 Apr 2024 08:34:02 -0700 Subject: [PATCH 2/5] Workaround for GetQueryResultCountAsync not working as expected (#156) * Stop using GetQueryResultCountAsync * Add Query Tiles '>25' when count is 25 * Update query result limit to 26 for round number in query tiles list. * Update tile display to be > 25 --- src/AzureExtension/DataManager/AzureDataManager.cs | 5 ++--- src/AzureExtension/Widgets/AzureQueryTilesWidget.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/AzureExtension/DataManager/AzureDataManager.cs b/src/AzureExtension/DataManager/AzureDataManager.cs index c565f67..2f81db3 100644 --- a/src/AzureExtension/DataManager/AzureDataManager.cs +++ b/src/AzureExtension/DataManager/AzureDataManager.cs @@ -40,7 +40,7 @@ public partial class AzureDataManager : IAzureDataManager, IDisposable public static readonly int PullRequestResultLimit = 25; // Max number of query results to fetch for a given query. - public static readonly int QueryResultLimit = 25; + public static readonly int QueryResultLimit = 26; // Most data that has not been updated within this time will be removed. private static readonly TimeSpan DataRetentionTime = TimeSpan.FromDays(1); @@ -327,7 +327,6 @@ private async Task UpdateDataForQueriesAsync(DataStoreOperationParameters parame } var queryId = new Guid(azureUri.Query); - var count = await witClient.GetQueryResultCountAsync(project.Name, queryId); var queryResult = await witClient.QueryByIdAsync(project.InternalId, queryId); if (queryResult == null) { @@ -481,7 +480,7 @@ private async Task UpdateDataForQueriesAsync(DataStoreOperationParameters parame }; var serializedJson = JsonSerializer.Serialize(workItemsObj, serializerOptions); - Query.GetOrCreate(DataStore, azureUri.Query, project.Id, parameters.DeveloperId.LoginId, getQueryResult.Name, serializedJson, count); + Query.GetOrCreate(DataStore, azureUri.Query, project.Id, parameters.DeveloperId.LoginId, getQueryResult.Name, serializedJson, workItemIds.Count); } // Foreach AzureUri return; diff --git a/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs b/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs index 663d127..ff4e47c 100644 --- a/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs +++ b/src/AzureExtension/Widgets/AzureQueryTilesWidget.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using System.Text.Json.Nodes; using DevHomeAzureExtension.Client; using DevHomeAzureExtension.DataManager; @@ -270,10 +271,16 @@ public override void LoadContentData() workItemCount = (int)queryInfo.QueryResultCount; } + var workItemCountDisplay = workItemCount.ToString(CultureInfo.InvariantCulture); + if (workItemCount > 25) + { + workItemCountDisplay = $">25"; + } + var tile = new JsonObject { { "title", tiles[pos].Title }, - { "counter", workItemCount }, + { "counter", workItemCountDisplay }, { "backgroundImage", IconLoader.GetIconAsBase64("BlueBackground.png") }, { "url", tiles[pos].AzureUri.ToString() }, }; From 4f3bfede9368f3710a37f1e877a750f34fbb7f43 Mon Sep 17 00:00:00 2001 From: Huzaifa Danish Date: Tue, 16 Apr 2024 15:00:29 -0700 Subject: [PATCH 3/5] Dev Box - Adding customization support (#152) Co-authored-by: Huzaifa Danish --- src/AzureExtension/AzureExtension.csproj | 1 + src/AzureExtension/DevBox/Constants.cs | 19 +- src/AzureExtension/DevBox/DevBoxInstance.cs | 16 +- src/AzureExtension/DevBox/DevBoxProvider.cs | 7 +- .../DevBox/Helpers/DevBoxOperationHelper.cs | 43 ++++ .../DevBox/Helpers/TaskJSONToCSClasses.cs | 81 ++++++ .../DevBox/Helpers/TaskYAMLToCSClasses.cs | 85 ++++++ .../DevBox/Helpers/WingetConfigWrapper.cs | 242 ++++++++++++++++++ .../Strings/en-US/Resources.resw | 10 +- 9 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 src/AzureExtension/DevBox/Helpers/TaskJSONToCSClasses.cs create mode 100644 src/AzureExtension/DevBox/Helpers/TaskYAMLToCSClasses.cs create mode 100644 src/AzureExtension/DevBox/Helpers/WingetConfigWrapper.cs diff --git a/src/AzureExtension/AzureExtension.csproj b/src/AzureExtension/AzureExtension.csproj index 03fbea3..7c46ce7 100644 --- a/src/AzureExtension/AzureExtension.csproj +++ b/src/AzureExtension/AzureExtension.csproj @@ -71,6 +71,7 @@ + diff --git a/src/AzureExtension/DevBox/Constants.cs b/src/AzureExtension/DevBox/Constants.cs index aaf3788..6aa1720 100644 --- a/src/AzureExtension/DevBox/Constants.cs +++ b/src/AzureExtension/DevBox/Constants.cs @@ -21,6 +21,13 @@ public static class Constants public const string ARGQuery = "{\"query\": \"Resources | where type in~ ('microsoft.devcenter/projects') | where properties['provisioningState'] =~ 'Succeeded' | project id, location, tenantId, name, properties, type\"," + " \"options\":{\"allowPartialScopes\":true}}"; + /// + /// API version used for enumeration and start, stop, and restart APIs + /// + /// For stable api's + /// for preview api's + public const string APIVersion = "api-version=2024-05-01-preview"; + /// /// DevCenter API to get all devboxes /// @@ -32,6 +39,11 @@ public static class Constants public const string OperationsParameter = "operations"; + /// + /// Dev Box API to run the winget customization task + /// + public const string CustomizationAPI = "/customizationgroups/AzureExt"; + /// /// Gets the Regex pattern for the name of a DevBox. This pattern is used to validate the name and the project name of a DevBox before attempting /// to create it. @@ -50,13 +62,6 @@ public static class Constants /// public const string ManagementPlaneScope = "https://management.azure.com/user_impersonation"; - /// - /// API version used for enumeration and start, stop, and restart APIs - /// - /// For stable api's - /// for preview api's - public const string APIVersion = "api-version=2024-05-01-preview"; - public const string Pools = "pools"; public const string Projects = "projects"; diff --git a/src/AzureExtension/DevBox/DevBoxInstance.cs b/src/AzureExtension/DevBox/DevBoxInstance.cs index 121874d..7cd3691 100644 --- a/src/AzureExtension/DevBox/DevBoxInstance.cs +++ b/src/AzureExtension/DevBox/DevBoxInstance.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Diagnostics; using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; using System.Text.Json; using AzureExtension.Contracts; using AzureExtension.DevBox.DevBoxJsonToCsClasses; @@ -133,7 +135,8 @@ private async Task GetRemoteLaunchURIsAsync(Uri boxURI) } public ComputeSystemOperations SupportedOperations => - ComputeSystemOperations.Start | ComputeSystemOperations.ShutDown | ComputeSystemOperations.Delete | ComputeSystemOperations.Restart; + ComputeSystemOperations.Start | ComputeSystemOperations.ShutDown | ComputeSystemOperations.Delete | + ComputeSystemOperations.Restart | ComputeSystemOperations.ApplyConfiguration; public string SupplementalDisplayName => $"{Resources.GetResource(SupplementalDisplayNamePrefix)}: {DevBoxState.ProjectName}"; @@ -484,6 +487,12 @@ public IAsyncOperation> GetComputeSystemPrope }).AsAsyncOperation(); } + public IApplyConfigurationOperation CreateApplyConfigurationOperation(string configuration) + { + var taskAPI = $"{DevBoxState.Uri}{Constants.CustomizationAPI}{DateTime.Now.ToFileTimeUtc()}?{Constants.APIVersion}"; + return new WingetConfigWrapper(configuration, taskAPI, _devBoxManagementService, AssociatedDeveloperId, _log); + } + // Unsupported operations public IAsyncOperation RevertSnapshotAsync(string options) { @@ -540,9 +549,4 @@ public IAsyncOperation ModifyPropertiesAsync(strin return new ComputeSystemOperationResult(new NotImplementedException(), Resources.GetResource(Constants.DevBoxMethodNotImplementedKey), "Method not implemented"); }).AsAsyncOperation(); } - - // Apply configuration isn't supported yet for Dev Boxes. This functionality will be created before the feature is released at build. - // Dev Home should not call this method and should use the Supported Operations to determine what operations are available. - // Currently, the supported operations are Start, Shutdown, Restart, and Delete. - public IApplyConfigurationOperation CreateApplyConfigurationOperation(string configuration) => throw new NotImplementedException(); } diff --git a/src/AzureExtension/DevBox/DevBoxProvider.cs b/src/AzureExtension/DevBox/DevBoxProvider.cs index ef6a903..770d8d1 100644 --- a/src/AzureExtension/DevBox/DevBoxProvider.cs +++ b/src/AzureExtension/DevBox/DevBoxProvider.cs @@ -141,16 +141,15 @@ public IAsyncOperation GetComputeSystemsAsync(IDeveloperId var errorMessage = string.Empty; if (ex.InnerException != null && ex.InnerException.Message.Contains("Account has previously been signed out of this application")) { - errorMessage = Resources.GetResource(Constants.RetrivalFailKey) + Resources.GetResource(Constants.SessionExpiredKey); + errorMessage = Resources.GetResource(Constants.RetrivalFailKey, developerId.LoginId) + Resources.GetResource(Constants.SessionExpiredKey); } else if (ex.Message.Contains("A passthrough token was detected without proper resource provider context")) { - errorMessage = Resources.GetResource(Constants.RetrivalFailKey) + Resources.GetResource(Constants.UnconfiguredKey); + errorMessage = Resources.GetResource(Constants.RetrivalFailKey, developerId.LoginId) + Resources.GetResource(Constants.UnconfiguredKey); } else { - errorMessage = Resources.GetResource(Constants.RetrivalFailKey) + ex.Message; - return new ComputeSystemsResult(ex, Resources.GetResource(Constants.RetrivalFailKey), ex.Message); + errorMessage = Resources.GetResource(Constants.RetrivalFailKey, developerId.LoginId) + ex.Message; } _log.Error(errorMessage); diff --git a/src/AzureExtension/DevBox/Helpers/DevBoxOperationHelper.cs b/src/AzureExtension/DevBox/Helpers/DevBoxOperationHelper.cs index cde5daa..553ad9d 100644 --- a/src/AzureExtension/DevBox/Helpers/DevBoxOperationHelper.cs +++ b/src/AzureExtension/DevBox/Helpers/DevBoxOperationHelper.cs @@ -1,6 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text; +using AzureExtension.DevBox.Models; +using Microsoft.Windows.DevHome.SDK; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + namespace AzureExtension.DevBox.Helpers; public static class DevBoxOperationHelper @@ -27,4 +33,41 @@ public static string ActionToPerformToString(DevBoxActionToPerform action) _ => null, }; } + + public static string Base64Encode(string plainText) + { + var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText); + return System.Convert.ToBase64String(plainTextBytes); + } + + public static ConfigurationUnitState JSONStatusToUnitStatus(string status) + { + return status switch + { + "NotStarted" => ConfigurationUnitState.Pending, + "Running" => ConfigurationUnitState.InProgress, + "Skipped" => ConfigurationUnitState.Skipped, + "Succeeded" => ConfigurationUnitState.Completed, + "Failed" => ConfigurationUnitState.Unknown, + "TimedOut" => ConfigurationUnitState.Unknown, + _ => ConfigurationUnitState.Unknown, + + // Not implemented by the REST API + // "WaitingForUserInputUac" => ConfigurationUnitState.Unknown, + // "WaitingForUserSession" => ConfigurationUnitState.Unknown, + }; + } + + public static ConfigurationSetState JSONStatusToSetStatus(string status) + { + return status switch + { + "NotStarted" => ConfigurationSetState.Pending, + "Running" => ConfigurationSetState.InProgress, + "Succeeded" => ConfigurationSetState.Completed, + "Failed" => ConfigurationSetState.Unknown, + "ValidationFailed" => ConfigurationSetState.Unknown, + _ => ConfigurationSetState.Unknown, + }; + } } diff --git a/src/AzureExtension/DevBox/Helpers/TaskJSONToCSClasses.cs b/src/AzureExtension/DevBox/Helpers/TaskJSONToCSClasses.cs new file mode 100644 index 0000000..9fe05c3 --- /dev/null +++ b/src/AzureExtension/DevBox/Helpers/TaskJSONToCSClasses.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureExtension.DevBox.Helpers; + +// Example of a task JSON response that will be deserialized into the BaseClass class +// { +// "tasks": [ +// { +// "name": "winget", +// "parameters": { +// "inlineConfigurationBase64": "" +// }, +// "runAs": "User", +// "id": "d473decc-0e0e-4452-9a17-f93e4fe5fc2b", +// "logUri": ", +// "status": "Succeeded", +// "startTime": "2024-04-09T20:09:03.0135721+00:00", +// "endTime": "2024-04-09T20:09:25.047521+00:00" +// }, +// { +// "name": "winget", +// "parameters": { +// "inlineConfigurationBase64": "" +// }, +// "runAs": "User", +// "id": "760eb6f5-fb72-452f-9b3c-d0c189b78c00", +// "logUri": "", +// "status": "Succeeded", +// "startTime": "2024-04-09T20:09:25.8467306+00:00", +// "endTime": "2024-04-09T20:09:38.5302446+00:00" +// } +// ], +// "uri": "", +// "name": "AzureExt133571668748121674", +// "status": "Running", +// "startTime": "2024-04-09T20:09:03.0136738+00:00" +// } +// +/// +/// Represents the classes for the customization task JSON response. +/// +public class TaskJSONToCSClasses +{ + public class BaseClass + { + public List Tasks { get; set; } = new(); + + public string Uri { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public string StartTime { get; set; } = string.Empty; + } + + public class TaskItem + { + public string Name { get; set; } = string.Empty; + + public Parameters? Parameters { get; set; } + + public string RunAs { get; set; } = string.Empty; + + public string Id { get; set; } = string.Empty; + + public string LogUri { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public string StartTime { get; set; } = string.Empty; + + public string EndTime { get; set; } = string.Empty; + } + + public class Parameters + { + public string InlineConfigurationBase64 { get; set; } = string.Empty; + } +} diff --git a/src/AzureExtension/DevBox/Helpers/TaskYAMLToCSClasses.cs b/src/AzureExtension/DevBox/Helpers/TaskYAMLToCSClasses.cs new file mode 100644 index 0000000..3239f24 --- /dev/null +++ b/src/AzureExtension/DevBox/Helpers/TaskYAMLToCSClasses.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AzureExtension.DevBox.Helpers; + +// Note: For every addition of a new resource type, the corresponding classes should be added here. +// Example of a YAML file that will be deserialized +// properties: +// resources: +// - resource: Microsoft.WinGet.DSC/WinGetPackage +// directives: +// description: Installing SublimeHQ.SublimeText.4 +// allowPrerelease: true +// settings: +// id: "SublimeHQ.SublimeText.4" +// source: winget +// id: 'SublimeHQ.SublimeText.4 | Install: Sublime Text 4' +// - resource: Microsoft.WinGet.DSC/WinGetPackage +// directives: +// description: Installing Git +// allowPrerelease: true +// settings: +// id: "Git.Git" +// source: winget +// id: Git.Git +// - resource: GitDsc/GitClone +// directives: +// description: 'Cloning: devhome.git' +// allowPrerelease: true +// settings: +// httpsUrl: https://github.com/microsoft/devhome.git +// rootDirectory: C:\Users\Public\repos\devhome.git +// id: 'Clone devhome.git: C:\Users\Public\repos\devhome.git' +// dependsOn: +// - 'Git.Git | Install: Git' +// configurationVersion: 0.2.0 +// +/// +/// Represents the classes for the YAML customization task. +/// +public class TaskYAMLToCSClasses +{ + public class BasePackage + { + public Properties? Properties { get; set; } + } + + public class Properties + { + public List? Resources { get; set; } + + public string ConfigurationVersion { get; set; } = string.Empty; + + public void SetResources(List? resources) => Resources = resources; + } + + public class ResourceItem + { + public string Resource { get; set; } = string.Empty; + + public Directives? Directives { get; set; } + + public Settings? Settings { get; set; } + + public string Id { get; set; } = string.Empty; + } + + public class Directives + { + public string Description { get; set; } = string.Empty; + + public bool? AllowPrerelease { get; set; } + } + + public class Settings + { + public string Id { get; set; } = string.Empty; + + public string HttpsUrl { get; set; } = string.Empty; + + public string RootDirectory { get; set; } = string.Empty; + + public string Source { get; set; } = string.Empty; + } +} diff --git a/src/AzureExtension/DevBox/Helpers/WingetConfigWrapper.cs b/src/AzureExtension/DevBox/Helpers/WingetConfigWrapper.cs new file mode 100644 index 0000000..4701658 --- /dev/null +++ b/src/AzureExtension/DevBox/Helpers/WingetConfigWrapper.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using AzureExtension.Contracts; +using DevHomeAzureExtension.Helpers; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace AzureExtension.DevBox.Helpers; + +public class WingetConfigWrapper : IApplyConfigurationOperation +{ + // Example of the JSON payload for the customization task + // { + // "tasks": [ + // { + // "name": "winget", + // "runAs": "User", + // "parameters": { + // "inlineConfigurationBase64": "..." + // }, + // }, + // ] + // } + public const string WingetTaskJsonBaseStart = "{\"tasks\": ["; + + public const string WingetTaskJsonTaskStart = @"{ + ""name"": ""Quickstart-Catalog-Tasks/winget"", + ""runAs"": ""User"", + ""parameters"": { + ""inlineConfigurationBase64"": """; + + public const string WingetTaskJsonTaskEnd = "\"}},"; + + public const string WingetTaskJsonBaseEnd = "]}"; + + public const string ConfigApplyFailedKey = "DevBox_ConfigApplyFailedKey"; + + public const string ValidationFailedKey = "DevBox_ValidationFailedKey"; + + public event TypedEventHandler ActionRequired = (s, e) => { }; + + public event TypedEventHandler ConfigurationSetStateChanged = (s, e) => { }; + + private JsonSerializerOptions _taskJsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private string _fullTaskJSON = string.Empty; + + private List _units = new(); + + private OpenConfigurationSetResult _openConfigurationSetResult = new(null, null, null, 0, 0); + + private ApplyConfigurationSetResult _applyConfigurationSetResult = new(null, null); + + private string _restAPI; + + private IDevBoxManagementService _managementService; + + private IDeveloperId _devId; + + private Serilog.ILogger _log; + + private ConfigurationUnitState[] _oldUnitState = Array.Empty(); + + private bool _pendingNotificationShown; + + public WingetConfigWrapper(string configuration, string taskAPI, IDevBoxManagementService devBoxManagementService, IDeveloperId associatedDeveloperId, Serilog.ILogger log) + { + _restAPI = taskAPI; + _managementService = devBoxManagementService; + _devId = associatedDeveloperId; + _log = log; + Initialize(configuration); + } + + public void Initialize(string configuration) + { + List units = new(); + + // Remove " dependsOn: -'Git.Git | Install: Git'" from the configuration + // This is a workaround as the current implementation does not support dependsOn + configuration = configuration.Replace("dependsOn:", string.Empty); + configuration = configuration.Replace("- 'Git.Git | Install: Git'", string.Empty); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var baseDSC = deserializer.Deserialize(configuration); + + // Move the resources to a separate list + var resources = baseDSC?.Properties?.Resources; + + // Remove the resources from the baseDSC + // They will be added back, each as an individual task since we + // cannot get individual task statuses for a single comprehensive task + baseDSC?.Properties?.SetResources(null); + + if (resources != null) + { + // Start collecting the individual tasks, starting with the base + StringBuilder fullTask = new(WingetTaskJsonBaseStart); + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitEmptyCollections) + .Build(); + + foreach (var resource in resources) + { + if (resource.Resource is not null && resource.Directives is not null) + { + if (resource.Resource.Equals("Microsoft.WinGet.DSC/WinGetPackage", System.StringComparison.OrdinalIgnoreCase)) + { + units.Add(new("WinGetPackage", resource.Id, ConfigurationUnitState.Unknown, false, null, null, ConfigurationUnitIntent.Apply)); + } + else if (resource.Resource.EndsWith("GitDsc/GitClone", System.StringComparison.OrdinalIgnoreCase)) + { + units.Add(new("GitClone", resource.Id, ConfigurationUnitState.Unknown, false, null, null, ConfigurationUnitIntent.Apply)); + } + } + + // Add the resource back as an individual task + var tempDsc = baseDSC; + tempDsc?.Properties?.SetResources(new List { resource }); + var yaml = serializer.Serialize(tempDsc); + var encodedConfiguration = DevBoxOperationHelper.Base64Encode(yaml); + fullTask.Append(WingetTaskJsonTaskStart + encodedConfiguration + WingetTaskJsonTaskEnd); + } + + // Remove the last comma after the last task + fullTask.Length--; + fullTask.Append(WingetTaskJsonBaseEnd); + _fullTaskJSON = fullTask.ToString(); + + _units = units; + _oldUnitState = Enumerable.Repeat(ConfigurationUnitState.Unknown, units.Count).ToArray(); + } + } + + private void SetStateForCustomizationTask(TaskJSONToCSClasses.BaseClass response) + { + var setState = DevBoxOperationHelper.JSONStatusToSetStatus(response.Status); + _log.Information($"Set Status: {response.Status}"); + + // No need to show the pending status more than once + if (_pendingNotificationShown && setState == ConfigurationSetState.Pending) + { + return; + } + + switch (response.Status) + { + case "NotStarted": + ConfigurationSetStateChanged?.Invoke(this, new(new(ConfigurationSetChangeEventType.SetStateChanged, setState, ConfigurationUnitState.Unknown, null, null))); + _pendingNotificationShown = true; + break; + + case "Running": + for (var i = 0; i < _units.Count; i++) + { + var task = _units[i]; + var unitState = DevBoxOperationHelper.JSONStatusToUnitStatus(response.Tasks[i].Status); + if (_oldUnitState[i] != unitState) + { + ConfigurationSetStateChangedEventArgs args = new(new(ConfigurationSetChangeEventType.UnitStateChanged, setState, unitState, null, task)); + ConfigurationSetStateChanged?.Invoke(this, args); + _oldUnitState[i] = unitState; + } + + _log.Information($"Unit Status: {unitState}"); + } + + break; + + case "Succeeded": + List unitResults = new(); + for (var i = 0; i < _units.Count; i++) + { + var task = _units[i]; + unitResults.Add(new(task, ConfigurationUnitState.Completed, false, false, null)); + } + + _applyConfigurationSetResult = new(null, unitResults); + break; + + case "ValidationFailed": + _openConfigurationSetResult = new(new FormatException(Resources.GetResource(ValidationFailedKey)), null, null, 0, 0); + break; + + case "Failed": + // To Do: Add case. Currently the API only checks if Winget started the task. + // Does not check if the task failed. + // Send UnitStateChanged event for the failed task with ResultInfo and ResultSource as UnitProcessing + // _applyConfigurationSetResult = new(new ApplicationException(Resources.GetResource(ConfigApplyFailedKey)), null); + break; + } + } + + IAsyncOperation IApplyConfigurationOperation.StartAsync() + { + return Task.Run(async () => + { + try + { + _log.Information($"Applying config {_fullTaskJSON}"); + + HttpContent httpContent = new StringContent(_fullTaskJSON, Encoding.UTF8, "application/json"); + var result = await _managementService.HttpsRequestToDataPlane(new Uri(_restAPI), _devId, HttpMethod.Put, httpContent); + + var setStatus = string.Empty; + while (setStatus != "Succeeded" && setStatus != "Failed" && setStatus != "ValidationFailed") + { + await Task.Delay(TimeSpan.FromSeconds(15)); + var poll = await _managementService.HttpsRequestToDataPlane(new Uri(_restAPI), _devId, HttpMethod.Get, null); + var rawResponse = poll.JsonResponseRoot.ToString(); + var response = JsonSerializer.Deserialize(rawResponse, _taskJsonSerializerOptions); + setStatus = response?.Status; + + if (response is not null) + { + SetStateForCustomizationTask(response); + } + } + + return new ApplyConfigurationResult(_openConfigurationSetResult, _applyConfigurationSetResult); + } + catch (Exception ex) + { + _log.Error(ex, $"Unable to apply configuration {_fullTaskJSON}"); + return new ApplyConfigurationResult(ex, Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey, ex.Message), ex.Message); + } + }).AsAsyncOperation(); + } +} diff --git a/src/AzureExtension/Strings/en-US/Resources.resw b/src/AzureExtension/Strings/en-US/Resources.resw index 627f0ab..93f7bc9 100644 --- a/src/AzureExtension/Strings/en-US/Resources.resw +++ b/src/AzureExtension/Strings/en-US/Resources.resw @@ -424,11 +424,19 @@ Error shown when user session has expired and re-login is needed. - Unable to retrieve Dev Boxes. + Unable to retrieve all Dev Boxes for {0}. Error shown when Dev Boxes retrival failed. Dev Boxes aren't configured for the current account. Error shown when Dev Boxes aren't configured for the current account. + + Applying the configuration failed + Error message when applying the configuration failed. + + + Validation failed for configuration + Error message when validation fails for a configuration file. + \ No newline at end of file From 7fe3569258f4d9804d8f24306019951bc9e02f40 Mon Sep 17 00:00:00 2001 From: safmswork <31971252+safmswork@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:52:14 -0700 Subject: [PATCH 4/5] Add ability to Pin Dev Environments (#153) * adding pin to taskbar * add more * more changes * fix whitespace * Add Windowsapp version check * PR feedback * PR feedback * PR feedback * fix unit test --------- Co-authored-by: Safet Hrbinic --- src/AzureExtension/AzureExtension.csproj | 2 +- .../Contracts/IPackagesService.cs | 4 + src/AzureExtension/DevBox/DevBoxInstance.cs | 211 +++++++++++++++++- src/AzureExtension/DevBox/DevBoxProvider.cs | 4 + .../Services/DevBox/PackagesService.cs | 13 ++ test/AzureExtension/DevBox/DevBoxTests.cs | 1 + 6 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/AzureExtension/AzureExtension.csproj b/src/AzureExtension/AzureExtension.csproj index 7c46ce7..c5b971c 100644 --- a/src/AzureExtension/AzureExtension.csproj +++ b/src/AzureExtension/AzureExtension.csproj @@ -58,7 +58,7 @@ - + diff --git a/src/AzureExtension/Contracts/IPackagesService.cs b/src/AzureExtension/Contracts/IPackagesService.cs index f9b5d02..0f4c9e3 100644 --- a/src/AzureExtension/Contracts/IPackagesService.cs +++ b/src/AzureExtension/Contracts/IPackagesService.cs @@ -3,7 +3,11 @@ namespace AzureExtension.Contracts; +using Windows.ApplicationModel; + public interface IPackagesService { public bool IsPackageInstalled(string packageName); + + public PackageVersion GetPackageInstalledVersion(string packageName); } diff --git a/src/AzureExtension/DevBox/DevBoxInstance.cs b/src/AzureExtension/DevBox/DevBoxInstance.cs index 7cd3691..c4fe8c3 100644 --- a/src/AzureExtension/DevBox/DevBoxInstance.cs +++ b/src/AzureExtension/DevBox/DevBoxInstance.cs @@ -3,9 +3,11 @@ using System; using System.Diagnostics; +using System.Globalization; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; using System.Text.Json; +using System.Web; using AzureExtension.Contracts; using AzureExtension.DevBox.DevBoxJsonToCsClasses; using AzureExtension.DevBox.Helpers; @@ -14,7 +16,9 @@ using DevHomeAzureExtension.Helpers; using Microsoft.Windows.DevHome.SDK; using Serilog; +using Windows.ApplicationModel; using Windows.Foundation; +using Windows.Management.Deployment; using Windows.Storage; using Windows.Storage.Streams; @@ -38,7 +42,7 @@ public enum DevBoxActionToPerform /// It contains the DevBox details such as name, id, state, CPU, memory, and OS. And all the /// operations that can be performed on the DevBox. /// -public class DevBoxInstance : IComputeSystem +public class DevBoxInstance : IComputeSystem, IComputeSystem2 { private readonly IDevBoxManagementService _devBoxManagementService; @@ -56,6 +60,22 @@ public class DevBoxInstance : IComputeSystem private const string DevBoxMultipleConcurrentOperationsNotSupportedKey = "DevBox_MultipleConcurrentOperationsNotSupport"; + private static readonly CompositeFormat ProtocolPinString = CompositeFormat.Parse("ms-cloudpc:pin?location={0}&request={1}&cpcid={2}&workspaceName={3}&environment={4}&username={5}&version=0.0&source=DevHome"); + + // This is the version of the Windows App package that supports protocol associations for pinning + private static readonly PackageVersion MinimumWindowsAppVersion = new(1, 3, 243, 0); + + // These exit codes must be kept in sync with WindowsApp + private const int ExitCodeInvalid = -1; + + private const int ExitCodeFailure = 0; + + private const int ExitCodeSuccess = 1; + + private const int ExitCodePinned = 2; + + private const int ExitCodeUnpinned = 3; + private readonly object _operationLock = new(); public bool IsOperationInProgress { get; private set; } @@ -66,6 +86,12 @@ public class DevBoxInstance : IComputeSystem public string DisplayName { get; private set; } + public string? WorkspaceId { get; private set; } + + public string? Username { get; private set; } + + public string? Environment { get; private set; } + public DevBoxMachineState DevBoxState { get; private set; } public DevBoxActionToPerform CurrentActionToPerform { get; private set; } @@ -134,9 +160,50 @@ private async Task GetRemoteLaunchURIsAsync(Uri boxURI) RemoteConnectionData = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions); } - public ComputeSystemOperations SupportedOperations => - ComputeSystemOperations.Start | ComputeSystemOperations.ShutDown | ComputeSystemOperations.Delete | - ComputeSystemOperations.Restart | ComputeSystemOperations.ApplyConfiguration; + public ComputeSystemOperations SupportedOperations => GetOperations(); + + // Is lversion greater than rversion + public bool IsPackageVersionGreaterThan(PackageVersion lversion, PackageVersion rversion) + { + if (lversion.Major != rversion.Major) + { + return lversion.Major > rversion.Major; + } + + if (lversion.Minor != rversion.Minor) + { + return lversion.Minor > rversion.Minor; + } + + if (lversion.Build != rversion.Build) + { + return lversion.Build > rversion.Build; + } + + if (lversion.Revision != rversion.Revision) + { + return lversion.Revision > rversion.Revision; + } + + return false; + } + + private ComputeSystemOperations GetOperations() + { + ComputeSystemOperations operations = ComputeSystemOperations.Start | ComputeSystemOperations.ShutDown | ComputeSystemOperations.Delete | + ComputeSystemOperations.Restart | ComputeSystemOperations.ApplyConfiguration; + + if (_packagesService.IsPackageInstalled(Constants.WindowsAppPackageFamilyName)) + { + PackageVersion version = _packagesService.GetPackageInstalledVersion(Constants.WindowsAppPackageFamilyName); + if (IsPackageVersionGreaterThan(version, MinimumWindowsAppVersion)) + { + operations |= ComputeSystemOperations.PinToStartMenu | ComputeSystemOperations.PinToTaskbar; + } + } + + return operations; + } public string SupplementalDisplayName => $"{Resources.GetResource(SupplementalDisplayNamePrefix)}: {DevBoxState.ProjectName}"; @@ -549,4 +616,140 @@ public IAsyncOperation ModifyPropertiesAsync(strin return new ComputeSystemOperationResult(new NotImplementedException(), Resources.GetResource(Constants.DevBoxMethodNotImplementedKey), "Method not implemented"); }).AsAsyncOperation(); } + + private string ValidateWindowsAppParameters() + { + if (string.IsNullOrEmpty(WorkspaceId) || string.IsNullOrEmpty(DisplayName) || string.IsNullOrEmpty(Environment) || string.IsNullOrEmpty(Username)) + { + return $"ValidateWindowsAppParameters failed with workspaceid={WorkspaceId} displayname={DisplayName} environment={Environment} username={Username}"; + } + else + { + return string.Empty; + } + } + + public IAsyncOperation DoPinActionAsync(string location, string pinAction) + { + return Task.Run(() => + { + var validationString = ValidateWindowsAppParameters(); + if (!string.IsNullOrEmpty(validationString)) + { + _log.Error(validationString); + return new ComputeSystemOperationResult(new InvalidDataException(), Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey), validationString); + } + + var exitcode = ExitCodeInvalid; + var psi = new ProcessStartInfo(); + psi.UseShellExecute = true; + psi.FileName = string.Format(CultureInfo.InvariantCulture, ProtocolPinString, location, pinAction, WorkspaceId, DisplayName, Environment, Username); + Process? p = Process.Start(psi); + if (p != null) + { + p.WaitForExit(); + exitcode = p.ExitCode; + if (exitcode == ExitCodeSuccess) + { + return new ComputeSystemOperationResult(); + } + } + + var errorString = $"DoPinActionAsync with location {location} and action {pinAction} failed with exitcode: {exitcode}"; + _log.Error(errorString); + return new ComputeSystemOperationResult(new NotSupportedException(), Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey), errorString); + }).AsAsyncOperation(); + } + + public IAsyncOperation PinToStartMenuAsync() + { + return DoPinActionAsync("startMenu", "pin"); + } + + public IAsyncOperation UnpinFromStartMenuAsync() + { + return DoPinActionAsync("startMenu", "unpin"); + } + + public IAsyncOperation PinToTaskbarAsync() + { + return DoPinActionAsync("taskbar", "pin"); + } + + public IAsyncOperation UnpinFromTaskbarAsync() + { + return DoPinActionAsync("taskbar", "unpin"); + } + + public IAsyncOperation GetPinStatusAsync(string location) + { + return Task.Run(() => + { + var validationString = ValidateWindowsAppParameters(); + if (!string.IsNullOrEmpty(validationString)) + { + _log.Error(validationString); + return new ComputeSystemPinnedResult(new NotSupportedException(), Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey), validationString); + } + + var exitcode = ExitCodeInvalid; + var psi = new ProcessStartInfo(); + psi.UseShellExecute = true; + psi.FileName = string.Format(CultureInfo.InvariantCulture, ProtocolPinString, location, "status", WorkspaceId, DisplayName, Environment, Username); + Process? p = Process.Start(psi); + if (p != null) + { + p.WaitForExit(); + exitcode = p.ExitCode; + if (exitcode == ExitCodePinned) + { + return new ComputeSystemPinnedResult(true); + } + else if (exitcode == ExitCodeUnpinned) + { + return new ComputeSystemPinnedResult(false); + } + } + + var errorString = $"GetPinStatusAsync from location {location} failed with exitcode: {exitcode}"; + _log.Error(errorString); + return new ComputeSystemPinnedResult(new NotSupportedException(), Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey), errorString); + }).AsAsyncOperation(); + } + + public IAsyncOperation GetIsPinnedToStartMenuAsync() + { + return GetPinStatusAsync("startmenu"); + } + + public IAsyncOperation GetIsPinnedToTaskbarAsync() + { + return GetPinStatusAsync("taskbar"); + } + + public async void LoadWindowsAppParameters() + { + try + { + if (RemoteConnectionData is null) + { + await GetRemoteLaunchURIsAsync(new Uri(DevBoxState.Uri)); + } + + var uriString = RemoteConnectionData?.CloudPcConnectionUrl; + if (string.IsNullOrEmpty(uriString)) + { + return; + } + + var launchUri = new Uri(uriString); + WorkspaceId = HttpUtility.ParseQueryString(launchUri.Query)["cpcid"]; + Username = HttpUtility.ParseQueryString(launchUri.Query)["username"]; + Environment = HttpUtility.ParseQueryString(launchUri.Query)["environment"]; + } + catch (Exception ex) + { + _log.Error(ex, $"LoadWindowsAppParameters failed"); + } + } } diff --git a/src/AzureExtension/DevBox/DevBoxProvider.cs b/src/AzureExtension/DevBox/DevBoxProvider.cs index 770d8d1..b0a2a2e 100644 --- a/src/AzureExtension/DevBox/DevBoxProvider.cs +++ b/src/AzureExtension/DevBox/DevBoxProvider.cs @@ -85,6 +85,10 @@ private async Task ProcessAllDevBoxesInProjectAsync(DevBoxProject devBoxProject, // It was likely created by Dev Portals UI or some other non-Dev Home related UI. _devBoxCreationManager.StartDevBoxProvisioningStateMonitor(newDevBoxInstance.AssociatedDeveloperId, newDevBoxInstance); } + else + { + newDevBoxInstance.LoadWindowsAppParameters(); + } systems.Add(newDevBoxInstance); } diff --git a/src/AzureExtension/Services/DevBox/PackagesService.cs b/src/AzureExtension/Services/DevBox/PackagesService.cs index cda9614..5ca5edb 100644 --- a/src/AzureExtension/Services/DevBox/PackagesService.cs +++ b/src/AzureExtension/Services/DevBox/PackagesService.cs @@ -3,6 +3,7 @@ using AzureExtension.Contracts; using Microsoft.Windows.DevHome.SDK; +using Windows.ApplicationModel; namespace AzureExtension.Services.DevBox; @@ -15,4 +16,16 @@ public bool IsPackageInstalled(string packageName) var currentPackage = _packageManager.FindPackagesForUser(string.Empty, packageName).FirstOrDefault(); return currentPackage != null; } + + public PackageVersion GetPackageInstalledVersion(string packageName) + { + PackageVersion version = new PackageVersion(0, 0, 0, 0); + var currentPackage = _packageManager.FindPackagesForUser(string.Empty, packageName).FirstOrDefault(); + if (currentPackage != null) + { + version = currentPackage.Id.Version; + } + + return version; + } } diff --git a/test/AzureExtension/DevBox/DevBoxTests.cs b/test/AzureExtension/DevBox/DevBoxTests.cs index 775abb4..212a284 100644 --- a/test/AzureExtension/DevBox/DevBoxTests.cs +++ b/test/AzureExtension/DevBox/DevBoxTests.cs @@ -100,6 +100,7 @@ public async Task DevBox_DevBoxOperationsSucceed() { new StringContent(MockProjectJson), new StringContent(devBoxListPoweredOffJson), + new StringContent(devBoxListPoweredOffJson), new StringContent(MockTestOperationJson), // initial operation status with 'Running' status new StringContent(succeededJson), // ending operation status with 'Succeeded' status new StringContent(runningDevBoxJson), // ending operation status with 'Succeeded' status From e23cb27977e8b821fb349579df24b96cd2043dec Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 16 Apr 2024 21:19:23 -0700 Subject: [PATCH 5/5] Add adaptive card UI for Dev Box (#154) * add Flow to create a Dev Box from the azure extension * update state for different stages of creation and deletion that could happen * fix build and text * remove whitespace to fix build * fix get all dev boxes test --- src/AzureExtension/AzureExtension.csproj | 6 + .../Contracts/IDevBoxManagementService.cs | 5 +- src/AzureExtension/DevBox/Constants.cs | 4 +- src/AzureExtension/DevBox/DevBoxInstance.cs | 47 +- src/AzureExtension/DevBox/DevBoxProvider.cs | 91 +++- .../Models/CreateComputeSystemOperation.cs | 5 + .../Models/CreationAdaptiveCardSession.cs | 401 ++++++++++++++++++ .../DevBox/Models/DevBoxCreationParameters.cs | 12 +- .../DevBox/Templates/CreationForm.json | 109 +++++ .../DevBox/Templates/ReviewForm.json | 92 ++++ .../Services/DevBox/DevBoxCreationManager.cs | 13 +- .../DevBox/DevBoxManagementService.cs | 30 +- .../Services/DevBox/DevBoxOperationWatcher.cs | 8 +- .../Services/DevBox/TimeSpanService.cs | 2 +- .../Strings/en-US/Resources.resw | 72 ++++ test/AzureExtension/DevBox/DevBoxTests.cs | 2 + .../AzureExtension/DevBox/DevBoxTestsSetup.cs | 29 +- 17 files changed, 888 insertions(+), 40 deletions(-) create mode 100644 src/AzureExtension/DevBox/Models/CreationAdaptiveCardSession.cs create mode 100644 src/AzureExtension/DevBox/Templates/CreationForm.json create mode 100644 src/AzureExtension/DevBox/Templates/ReviewForm.json diff --git a/src/AzureExtension/AzureExtension.csproj b/src/AzureExtension/AzureExtension.csproj index c5b971c..8dabe10 100644 --- a/src/AzureExtension/AzureExtension.csproj +++ b/src/AzureExtension/AzureExtension.csproj @@ -45,6 +45,12 @@ Always + + Always + + + Always + diff --git a/src/AzureExtension/Contracts/IDevBoxManagementService.cs b/src/AzureExtension/Contracts/IDevBoxManagementService.cs index 0c61e81..a5b3b7f 100644 --- a/src/AzureExtension/Contracts/IDevBoxManagementService.cs +++ b/src/AzureExtension/Contracts/IDevBoxManagementService.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using AzureExtension.DevBox.DevBoxJsonToCsClasses; using AzureExtension.DevBox.Models; using Microsoft.Windows.DevHome.SDK; @@ -32,10 +33,10 @@ public interface IDevBoxManagementService /// /// Generates a list of objects that each contain a Dev Center project and the Dev Box pools associated with that project. /// - /// The Json recieved from a rest api that returns a list of Dev Center projects. + /// The Deserialized Json recieved from a rest api that returns a list of Dev Center projects. /// The DeveloperId associated with the request. /// A list of objects where each contain a project and its associated pools. - public Task> GetAllProjectsToPoolsMappingAsync(JsonElement projectsJson, IDeveloperId developerId); + public Task> GetAllProjectsToPoolsMappingAsync(DevBoxProjects projects, IDeveloperId developerId); /// /// Initiates a call to create a Dev Box in the Dev Center. diff --git a/src/AzureExtension/DevBox/Constants.cs b/src/AzureExtension/DevBox/Constants.cs index 6aa1720..12fe158 100644 --- a/src/AzureExtension/DevBox/Constants.cs +++ b/src/AzureExtension/DevBox/Constants.cs @@ -81,7 +81,7 @@ public static class Constants public static readonly TimeSpan OneMinutePeriod = TimeSpan.FromMinutes(1); - public static readonly TimeSpan FiveMinutePeriod = TimeSpan.FromMinutes(5); + public static readonly TimeSpan ThreeMinutePeriod = TimeSpan.FromMinutes(3); public static readonly TimeSpan OperationDeadline = TimeSpan.FromHours(2); @@ -114,6 +114,8 @@ public static class DevBoxProvisioningStates public const string Deleting = "Deleting"; public const string Updating = "Updating"; + + public const string Deleted = "Deleted"; } public static class DevBoxActionStates diff --git a/src/AzureExtension/DevBox/DevBoxInstance.cs b/src/AzureExtension/DevBox/DevBoxInstance.cs index c4fe8c3..d40d2e3 100644 --- a/src/AzureExtension/DevBox/DevBoxInstance.cs +++ b/src/AzureExtension/DevBox/DevBoxInstance.cs @@ -135,6 +135,7 @@ private void ProcessProperties() { try { + _log.Information($"Retrieving properties for Dev Box '{DisplayName}'"); var properties = new List(); var cpu = ComputeSystemProperty.Create(ComputeSystemPropertyKind.CpuCount, DevBoxState.HardwareProfile.VCPUs); var memory = ComputeSystemProperty.Create(ComputeSystemPropertyKind.AssignedMemorySizeInBytes, (ulong)DevBoxState.HardwareProfile.MemoryGB * Constants.BytesInGb); @@ -148,7 +149,7 @@ private void ProcessProperties() } catch (Exception ex) { - _log.Error(ex, $"Error processing properties for {DisplayName}"); + _log.Error(ex, $"Error processing properties for '{DisplayName}'"); } } } @@ -190,6 +191,17 @@ public bool IsPackageVersionGreaterThan(PackageVersion lversion, PackageVersion private ComputeSystemOperations GetOperations() { + var state = GetState(); + if (state == ComputeSystemState.Creating) + { + return ComputeSystemOperations.Delete | ComputeSystemOperations.PinToStartMenu | ComputeSystemOperations.PinToTaskbar; + } + + if ((state == ComputeSystemState.Deleting) || (state == ComputeSystemState.Deleted)) + { + return ComputeSystemOperations.None; + } + ComputeSystemOperations operations = ComputeSystemOperations.Start | ComputeSystemOperations.ShutDown | ComputeSystemOperations.Delete | ComputeSystemOperations.Restart | ComputeSystemOperations.ApplyConfiguration; @@ -224,8 +236,12 @@ private IAsyncOperation PerformRESTOperation(DevBo { lock (_operationLock) { - if (IsOperationInProgress) + // Only the delete operation can be performed while other operations are being performed. + if (IsOperationInProgress && + (CurrentActionToPerform != DevBoxActionToPerform.Delete) && + (action != DevBoxActionToPerform.Delete)) { + _log.Error("Multiple operations are not supported for DevBoxes. Only the Delete operation can be performed while other operations are in progress"); return new ComputeSystemOperationResult(new InvalidOperationException(), Resources.GetResource(DevBoxMultipleConcurrentOperationsNotSupportedKey), "Running multiple operations is not supported"); } @@ -238,7 +254,8 @@ private IAsyncOperation PerformRESTOperation(DevBo // The creation and delete operations do not require an operation string, but all the other operations do. var operationUri = operation.Length > 0 ? $"{DevBoxState.Uri}:{operation}?{Constants.APIVersion}" : $"{DevBoxState.Uri}?{Constants.APIVersion}"; - _log.Information($"Starting {DisplayName} with {operationUri}"); + + _log.Information($"Performing {operation} operation for '{DisplayName}' with {operationUri}"); var result = await _devBoxManagementService.HttpsRequestToDataPlane(new Uri(operationUri), AssociatedDeveloperId, method, null); var operationLocation = result.ResponseHeader.OperationLocation; @@ -248,6 +265,8 @@ private IAsyncOperation PerformRESTOperation(DevBo // See example Response: https://learn.microsoft.com/en-us/rest/api/devcenter/developer/dev-boxes/start-dev-box?view=rest-devcenter-developer-2023-04-01&tabs=HTTP var operationId = Guid.Parse(operationLocation!.Segments.Last()); + _log.Information($"Adding Dev Box '{DisplayName}' with to OperationMonitor"); + // Monitor the operations progress _devBoxOperationWatcher.StartDevCenterOperationMonitor(AssociatedDeveloperId, operationLocation!, operationId, action, OperationCallback); return new ComputeSystemOperationResult(); @@ -255,7 +274,7 @@ private IAsyncOperation PerformRESTOperation(DevBo catch (Exception ex) { UpdateStateForUI(); - _log.Error(ex, $"Unable to procress DevBox operation '{nameof(DevBoxOperation)}'"); + _log.Error(ex, $"Unable to process DevBox operation '{nameof(DevBoxOperation)}'"); return new ComputeSystemOperationResult(ex, Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey, ex.Message), ex.Message); } }).AsAsyncOperation(); @@ -273,7 +292,7 @@ private void RemoveOperationInProgressFlag() /// When a Dev Box is created it the Dev Center started to provision it. We use this method once the provisioning is complete. /// /// The new state of the Dev Box from the Dev Center - /// The status of the provisioing + /// The status of the provisioning public void ProvisioningMonitorCompleted(DevBoxMachineState? devBoxMachineState, ProvisioningStatus status) { if (!IsDevBoxBeingCreatedOrProvisioned) @@ -283,10 +302,13 @@ public void ProvisioningMonitorCompleted(DevBoxMachineState? devBoxMachineState, if (status == ProvisioningStatus.Succeeded && devBoxMachineState != null) { + _log.Information($"Dev Box provisioning succeeded for '{DisplayName}'"); DevBoxState = devBoxMachineState; } else { + _log.Information($"Dev Box provisioning failed for '{DisplayName}'"); + // If the provisioning failed, we'll set the state to failed and powerstate to unknown. // The PowerState being unknown will make the UI show the Dev Box state as unknown. DevBoxState.ProvisioningState = Constants.DevBoxProvisioningStates.Failed; @@ -303,6 +325,7 @@ public void ProvisioningMonitorCompleted(DevBoxMachineState? devBoxMachineState, /// The current status of the operation public void OperationCallback(DevCenterOperationStatus? status) { + _log.Information($"Dev Box operation Callback for '{DisplayName}' invoked with status '{status}'"); switch (status) { case DevCenterOperationStatus.NotStarted: @@ -329,6 +352,7 @@ private void SetStateAfterOperationCompletedSuccessfully() { try { + _log.Information($"Dev Box operation completed for '{DisplayName}'. ActionPerformed: '{CurrentActionToPerform}'"); if (CurrentActionToPerform == DevBoxActionToPerform.Delete) { // DevBox no longer exists, so we can't get the state from the Dev Center. @@ -387,6 +411,8 @@ public ComputeSystemState GetState() var actionState = DevBoxState.ActionState; var powerState = DevBoxState.PowerState; + _log.Information($"Getting State for devBox: '{DisplayName}', Provisioning: {provisioningState}, Action: {actionState}, Power: {powerState}"); + // This state is actually failed, but since ComputeSystemState doesn't have a failed state, we'll return unknown. if (provisioningState == Constants.DevBoxProvisioningStates.Failed || provisioningState == Constants.DevBoxProvisioningStates.ProvisionedWithWarning) @@ -403,6 +429,8 @@ public ComputeSystemState GetState() return ComputeSystemState.Creating; case Constants.DevBoxProvisioningStates.Deleting: return ComputeSystemState.Deleting; + case Constants.DevBoxProvisioningStates.Deleted: + return ComputeSystemState.Deleted; } } @@ -485,6 +513,7 @@ public IAsyncOperation ConnectAsync(string options { try { + _log.Information($"Retrieving remote connection data for '{DisplayName}'"); if (RemoteConnectionData is null) { await GetRemoteLaunchURIsAsync(new Uri(DevBoxState.Uri)); @@ -494,12 +523,15 @@ public IAsyncOperation ConnectAsync(string options psi.UseShellExecute = true; var isWindowsAppInstalled = _packagesService.IsPackageInstalled(Constants.WindowsAppPackageFamilyName); psi.FileName = isWindowsAppInstalled ? RemoteConnectionData?.CloudPcConnectionUrl : RemoteConnectionData?.WebUrl; + + _log.Information($"Launching DevBox '{DisplayName}' with connection data: {psi.FileName}. Windows App installed: {(isWindowsAppInstalled ? "True" : "False")}"); + Process.Start(psi); return new ComputeSystemOperationResult(); } catch (Exception ex) { - _log.Error(ex, $"Error connecting to {DisplayName}"); + _log.Error(ex, $"Error connecting to '{DisplayName}'"); return new ComputeSystemOperationResult(ex, Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey, ex.Message), string.Empty); } }).AsAsyncOperation(); @@ -524,6 +556,7 @@ public IAsyncOperation GetComputeSystemThumbnailAs { try { + _log.Information($"Retrieving thumbnail for '{DisplayName}'"); var uri = new Uri(Constants.ThumbnailURI); var storageFile = await StorageFile.GetFileFromApplicationUriAsync(uri); var randomAccessStream = await storageFile.OpenReadAsync(); @@ -540,7 +573,7 @@ public IAsyncOperation GetComputeSystemThumbnailAs } catch (Exception ex) { - _log.Error(ex, $"Error getting thumbnail for {DisplayName}"); + _log.Error(ex, $"Error getting thumbnail for '{DisplayName}'"); return new ComputeSystemThumbnailResult(ex, Resources.GetResource(Constants.DevBoxUnableToPerformOperationKey, ex.Message), ex.Message); } }).AsAsyncOperation(); diff --git a/src/AzureExtension/DevBox/DevBoxProvider.cs b/src/AzureExtension/DevBox/DevBoxProvider.cs index b0a2a2e..2920c9c 100644 --- a/src/AzureExtension/DevBox/DevBoxProvider.cs +++ b/src/AzureExtension/DevBox/DevBoxProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Text; using System.Text.Json; using AzureExtension.Contracts; @@ -26,8 +28,14 @@ public class DevBoxProvider : IComputeSystemProvider private readonly DevBoxInstanceFactory _devBoxInstanceFactory; + private readonly Dictionary _devBoxProjectsMap = new(); + private readonly ILogger _log = Log.ForContext("SourceContext", nameof(DevBoxProvider)); + private readonly Dictionary> _devBoxProjectAndPoolsMap = new(); + + private readonly Dictionary> _cachedDevBoxesMap = new(); + public DevBoxProvider( IDevBoxManagementService mgmtSvc, CreateComputeSystemOperationFactory createComputeSystemOperationFactory, @@ -70,6 +78,8 @@ private async Task ProcessAllDevBoxesInProjectAsync(DevBoxProject devBoxProject, { if (_devBoxCreationManager.TryGetDevBoxInstanceIfBeingCreated(devBoxState.UniqueId, out var devBox)) { + _log.Information($"DevBox with name: {devBox!.DevBoxState.Name} found and is currently being created and monitored by Dev Box provider"); + // If the Dev Box's creation operation or its provisioning state are being tracked by us, then don't make a new instance. // Add the one we're tracking. This is to avoid adding the same Dev Box twice. E.g User clicks Dev Homes refresh button while // the Dev Box is being created. @@ -81,6 +91,9 @@ private async Task ProcessAllDevBoxesInProjectAsync(DevBoxProject devBoxProject, if (newDevBoxInstance.IsDevBoxBeingCreatedOrProvisioned) { + _log.Information( + $"DevBox with name: {newDevBoxInstance!.DevBoxState.Name} is currently being provisioned and is not monitored by the DevBox provider. Adding DevBox To Operation watcher"); + // DevBox is being created but we aren't tracking it yet. So we'll start tracking its provisioning state until its fully provisioned. // It was likely created by Dev Portals UI or some other non-Dev Home related UI. _devBoxCreationManager.StartDevBoxProvisioningStateMonitor(newDevBoxInstance.AssociatedDeveloperId, newDevBoxInstance); @@ -101,9 +114,7 @@ private async Task ProcessAllDevBoxesInProjectAsync(DevBoxProject devBoxProject, /// DeveloperId to be used by the authentication token service public async Task> GetDevBoxesAsync(IDeveloperId developerId) { - var requestContent = new StringContent(Constants.ARGQuery, Encoding.UTF8, "application/json"); - var result = await _devBoxManagementService.HttpsRequestToManagementPlane(new Uri(Constants.ARGQueryAPI), developerId, HttpMethod.Post, requestContent); - var devBoxProjects = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions); + var devBoxProjects = await GetDevBoxProjectsAsync(developerId); var computeSystems = new List(); if (devBoxProjects?.Data != null) @@ -122,7 +133,10 @@ public async Task> GetDevBoxesAsync(IDeveloperId dev } } - return computeSystems; + // update the cache every time we retrieve new Dev Boxes. This is used so in the creation flow we don't need + // to retrieve the Dev Boxes again if the user already retrieved them in the environments page in Dev Home + _cachedDevBoxesMap[GetUniqueDeveloperId(developerId)] = computeSystems; + return _cachedDevBoxesMap[GetUniqueDeveloperId(developerId)]; } /// @@ -137,7 +151,11 @@ public IAsyncOperation GetComputeSystemsAsync(IDeveloperId { ArgumentNullException.ThrowIfNull(developerId); + _log.Information($"Attempting to retrieving all Dev Boxes for {developerId.LoginId}, at {DateTime.Now}"); + var computeSystems = await GetDevBoxesAsync(developerId); + + _log.Information($"Successfully retrieved all Dev Boxes for {developerId.LoginId}, at {DateTime.Now}"); return new ComputeSystemsResult(computeSystems); } catch (Exception ex) @@ -156,7 +174,7 @@ public IAsyncOperation GetComputeSystemsAsync(IDeveloperId errorMessage = Resources.GetResource(Constants.RetrivalFailKey, developerId.LoginId) + ex.Message; } - _log.Error(errorMessage); + _log.Error(ex, errorMessage); return new ComputeSystemsResult(ex, errorMessage, string.Empty); } }).AsAsyncOperation(); @@ -164,8 +182,11 @@ public IAsyncOperation GetComputeSystemsAsync(IDeveloperId public ICreateComputeSystemOperation? CreateCreateComputeSystemOperation(IDeveloperId developerId, string inputJson) { + _log.Information($"Attempting to create CreateComputeSystemOperation for {developerId.LoginId}"); + if (developerId is null || string.IsNullOrEmpty(inputJson)) { + _log.Error($"Unable to create Dev Box with DeveloperId: '{developerId?.LoginId ?? "null"}' and inputJson '{inputJson ?? "null"}'"); return null; } @@ -174,11 +195,31 @@ public IAsyncOperation GetComputeSystemsAsync(IDeveloperId return _createComputeSystemOperationFactory(developerId, parameters); } - // These methods are not implemented but will be implemented as part of the work to support DevBox creation before the feature is released before build. public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForDeveloperId(IDeveloperId developerId, ComputeSystemAdaptiveCardKind sessionKind) { - var exception = new NotImplementedException(); - return new ComputeSystemAdaptiveCardResult(exception, Resources.GetResource(Constants.DevBoxMethodNotImplementedKey), exception.Message); + return Task.Run(async () => + { + try + { + _log.Information($"Attempting to create adaptive card session for {developerId?.LoginId ?? "null"}"); + + ArgumentNullException.ThrowIfNull(developerId); + + var uniqueId = GetUniqueDeveloperId(developerId); + if (!_devBoxProjectAndPoolsMap.TryGetValue(uniqueId, out var _)) + { + _log.Information($"No cached Dev Boxes found for {developerId.LoginId}, getting new Dev Boxes"); + await GetDevBoxesAsync(developerId); + } + + return new ComputeSystemAdaptiveCardResult(new CreationAdaptiveCardSession(_devBoxProjectAndPoolsMap[uniqueId], _cachedDevBoxesMap[uniqueId])); + } + catch (Exception ex) + { + _log.Error(ex, "Unable to get the adaptive card session for the provided developerId"); + return new ComputeSystemAdaptiveCardResult(ex, ex.Message, ex.Message); + } + }).GetAwaiter().GetResult(); } public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForComputeSystem(IComputeSystem computeSystem, ComputeSystemAdaptiveCardKind sessionKind) @@ -186,4 +227,38 @@ public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForComputeSystem var exception = new NotImplementedException(); return new ComputeSystemAdaptiveCardResult(exception, Resources.GetResource(Constants.DevBoxMethodNotImplementedKey), exception.Message); } + + private async Task GetDevBoxProjectsAsync(IDeveloperId developerId) + { + _log.Information($"Attempting to get all projects for {developerId?.LoginId ?? "null"}"); + + ArgumentNullException.ThrowIfNull(developerId); + + var uniqueUserId = GetUniqueDeveloperId(developerId); + + if (_devBoxProjectsMap.TryGetValue(uniqueUserId, out var projects)) + { + _log.Information($"Found cached projects for {developerId.LoginId}, returning cached projects"); + return projects; + } + + var requestContent = new StringContent(Constants.ARGQuery, Encoding.UTF8, "application/json"); + var result = await _devBoxManagementService.HttpsRequestToManagementPlane(new Uri(Constants.ARGQueryAPI), developerId, HttpMethod.Post, requestContent); + var devBoxProjects = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions); + + // Get Associated pools the first time we get the projects + if (!_devBoxProjectAndPoolsMap.TryGetValue(uniqueUserId, out var _)) + { + _log.Information($"Found no cached pools for all projects for {developerId.LoginId}, retrieving pools"); + _devBoxProjectAndPoolsMap[uniqueUserId] = await _devBoxManagementService.GetAllProjectsToPoolsMappingAsync(devBoxProjects!, developerId); + } + + _devBoxProjectsMap.Add(uniqueUserId, devBoxProjects!); + return devBoxProjects!; + } + + private string GetUniqueDeveloperId(IDeveloperId developerId) + { + return $"{developerId.LoginId}#{developerId.Url}"; + } } diff --git a/src/AzureExtension/DevBox/Models/CreateComputeSystemOperation.cs b/src/AzureExtension/DevBox/Models/CreateComputeSystemOperation.cs index 974a5ff..5bc3d47 100644 --- a/src/AzureExtension/DevBox/Models/CreateComputeSystemOperation.cs +++ b/src/AzureExtension/DevBox/Models/CreateComputeSystemOperation.cs @@ -5,6 +5,7 @@ using AzureExtension.DevBox.Exceptions; using DevHomeAzureExtension.Helpers; using Microsoft.Windows.DevHome.SDK; +using Serilog; using Windows.Foundation; namespace AzureExtension.DevBox.Models; @@ -16,6 +17,8 @@ namespace AzureExtension.DevBox.Models; /// public class CreateComputeSystemOperation : ICreateComputeSystemOperation { + private readonly ILogger _log = Log.ForContext("SourceContext", nameof(CreateComputeSystemOperation)); + private CreateComputeSystemResult? _result; private const string OperationInProgressMessageKey = "DevBox_CreationOperationAlreadyInProgress"; @@ -60,6 +63,7 @@ public CreateComputeSystemOperation(IDevBoxCreationManager devBoxCreationManager if (IsOperationInProgress) { var exception = new DevBoxCreationException("Operation already in progress"); + _log.Error(exception, exception.Message); return new CreateComputeSystemResult(exception, Resources.GetResource(OperationInProgressMessageKey), exception.Message); } else if (IsCompleted) @@ -77,6 +81,7 @@ public CreateComputeSystemOperation(IDevBoxCreationManager devBoxCreationManager } catch (Exception ex) { + _log.Error(ex, "unable to start the Dev Box creation process"); _result = new CreateComputeSystemResult(ex, Resources.GetResource(OperationCompletedMessageKey), ex.Message); } diff --git a/src/AzureExtension/DevBox/Models/CreationAdaptiveCardSession.cs b/src/AzureExtension/DevBox/Models/CreationAdaptiveCardSession.cs new file mode 100644 index 0000000..efffa06 --- /dev/null +++ b/src/AzureExtension/DevBox/Models/CreationAdaptiveCardSession.cs @@ -0,0 +1,401 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using DevHomeAzureExtension.Helpers; +using Microsoft.Windows.DevHome.SDK; +using Serilog; +using Windows.Foundation; + +namespace AzureExtension.DevBox.Models; + +public enum SessionState +{ + InitialCreationForm, + ReviewForm, +} + +/// +/// Represents an adaptive card session that is presented to the user in Dev Homes environment creation UI. It is in charge +/// of retrieving and updating the adaptive card based on user input and interaction. +/// +public class CreationAdaptiveCardSession : IExtensionAdaptiveCardSession2 +{ + private readonly Serilog.ILogger _log = Log.ForContext("SourceContext", nameof(CreationAdaptiveCardSession)); + + private readonly string _pathToCreationFormTemplate = Path.Combine(AppContext.BaseDirectory, @"AzureExtension\DevBox\Templates\", "CreationForm.json"); + + private readonly string _pathToReviewFormTemplate = Path.Combine(AppContext.BaseDirectory, @"AzureExtension\DevBox\Templates\", "ReviewForm.json"); + + private readonly string _adaptiveCardNextButtonId = "DevHomeMachineConfigurationNextButton"; + + /// + /// An object that contains projects and a list of pools for that project + /// + private readonly List _devBoxProjectAndPoolContainer; + + private readonly List _listOfAllDevBoxPools = new(); + + private readonly JsonArray _arrayOfProjects = new(); + + private readonly Dictionary> _invalidDevBoxNames = new(); + + private int _currentSelectedProjectIndex; + + private int _currentSelectedPoolIndex; + + private string _currentTextBoxValue = string.Empty; + + private ProviderOperationResult _operationResult = new(ProviderOperationStatus.Success, null, string.Empty, string.Empty); + + private bool _shouldEndSession; + + /// + /// Gets the Json string that represents the user input that was passed to the adaptive card session. We'll keep this so we can pass it back to Dev Home + /// at the end of the session. + /// + private Dictionary _originalUserInputJson = new(); + + public event TypedEventHandler? Stopped; + + public void Dispose() + { + } + + public CreationAdaptiveCardSession(List containers, IEnumerable? devBoxes) + { + _devBoxProjectAndPoolContainer = containers; + BuildUsedDevBoxNamesPerProject(devBoxes); + BuildAllProjectsAndPoolJsonObjects(); + } + + private IExtensionAdaptiveCard? _creationAdaptiveCard; + + public bool ShouldEndSession { get; private set; } + + public ProviderOperationResult Initialize(IExtensionAdaptiveCard extensionUI) + { + _creationAdaptiveCard = extensionUI; + + return GetCreationFormAdaptiveCard(string.Empty, _currentSelectedProjectIndex, _currentSelectedPoolIndex); + } + + public IAsyncOperation OnAction(string action, string inputs) + { + return Task.Run(() => + { + try + { + var actionPayLoad = JsonSerializer.Deserialize>(action); + var inputPayLoad = JsonSerializer.Deserialize>(inputs); + + switch (_creationAdaptiveCard?.State) + { + case "initialCreationForm": + HandleActionWhenFormInInitialState(actionPayLoad!, inputPayLoad!); + break; + case "reviewForm": + HandleActionWhenFormInReviewState(actionPayLoad!); + break; + default: + _shouldEndSession = true; + _operationResult = new ProviderOperationResult( + ProviderOperationStatus.Failure, + new InvalidOperationException(nameof(action)), + Resources.GetResource("AdaptiveCardStateNotRecognizedError"), + $"Unexpected state:{_creationAdaptiveCard?.State}"); + break; + } + } + catch (Exception ex) + { + _log.Error(ex, "Unable to update the adaptive card session for creation"); + _shouldEndSession = true; + _operationResult = new ProviderOperationResult(ProviderOperationStatus.Failure, ex, ex.Message, ex.Message); + } + + if (_shouldEndSession) + { + // The session has now ended. We'll raise the Stopped event to notify anyone in Dev Home who was listening to this event, + // that the session has ended. + Stopped?.Invoke( + this, + new ExtensionAdaptiveCardSessionStoppedEventArgs(_operationResult, GetDataToCreateDevBox())); + } + + return _operationResult; + }).AsAsyncOperation(); + } + + /// + /// Loads the adaptive card template based on the session state. + /// + /// State the adaptive card session + /// A Json string representing the adaptive card + public string LoadTemplate(SessionState state) + { + var pathToTemplate = state switch + { + SessionState.InitialCreationForm => _pathToCreationFormTemplate, + SessionState.ReviewForm => _pathToReviewFormTemplate, + _ => _pathToCreationFormTemplate, + }; + + return File.ReadAllText(pathToTemplate, Encoding.Default); + } + + /// + /// Creates the initial form that will be displayed to the user. + /// + /// + /// This will initially show a textbox, a combo box with a list of projects and a button to refresh the users pools. + /// When the user selects a project the pools are not shown by default. Once they click the view pools button it submits + /// the adaptive card and we send back a new adaptive card that contains a new combo box with the list of pools. + /// + /// The text currently in the textbox. This is used so we can save input from the previous adaptive card when refreshing the adaptive card + /// The current index of the selected project in the projects combo box. This is used so we can save input from the previous adaptive card when refreshing the adaptive card + /// The current index of the selected pool in the pools combo box. This is used so we can save input from the previous adaptive card when refreshing the adaptive card + /// Result of the operation + private ProviderOperationResult GetCreationFormAdaptiveCard(string textboxText, int selectedProjectIndex, int selectedPoolIndex) + { + try + { + _log.Information("Building creation card with the following parameters: +" + + $"'{nameof(textboxText)}': {textboxText}, " + + $"'{nameof(selectedProjectIndex)}': {selectedProjectIndex}, " + + $"'{nameof(selectedPoolIndex)}': {selectedPoolIndex}"); + + var primaryButtonForCreationFlowText = Resources.GetResource("DevBox_PrimaryButtonLabelForCreationFlow"); + var secondaryButtonForCreationFlowText = Resources.GetResource("DevBox_SecondaryButtonLabelForCreationFlow"); + var settingsCardLabel = Resources.GetResource("DevBox_SettingsCardLabel"); + var enterNewEnvironmentNameLabel = Resources.GetResource("DevBox_EnterNewEnvironmentTextBoxLabel"); + var projectComboBoxLabel = Resources.GetResource("DevBox_ProjectComboBoxLabel"); + var poolComboBoxLabel = Resources.GetResource("DevBox_PoolComboBoxLabel"); + var getPoolsButtonLabel = Resources.GetResource("DevBox_PoolsButtonLabel"); + var devBoxTextBoxErrorMessage = Resources.GetResource("DevBox_TextBoxErrorMessage"); + var devBoxProjectComboBoxErrorMessage = Resources.GetResource("DevBox_ProjectComboBoxErrorMessage"); + var devBoxPoolsComboBoxErrorMessage = Resources.GetResource("DevBox_PoolsComboBoxErrorMessage"); + var devBoxPoolComboBoxLabel = Resources.GetResource("DevBox_PoolComboBoxLabel"); + var devBoxPoolsPlaceHolder = Resources.GetResource("DevBox_PoolsPlaceHolder"); + + _arrayOfProjects![_currentSelectedProjectIndex]!.AsObject().TryGetPropertyValue("title", out var projectName); + + var templateData = + $"{{\"PrimaryButtonLabelForCreationFlow\" : \"{primaryButtonForCreationFlowText}\"," + + $"\"SecondaryButtonLabelForCreationFlow\" : \"{secondaryButtonForCreationFlowText}\"," + + $"\"EnterNewEnvironmentTextBoxLabel\": \"{enterNewEnvironmentNameLabel}\"," + + $"\"ProjectComboBoxLabel\": \"{projectComboBoxLabel}\"," + + $"\"PoolComboBoxLabel\": \"{poolComboBoxLabel}\"," + + $"\"PoolsButtonLabel\": \"{getPoolsButtonLabel}\"," + + $"\"DevBoxTextBoxErrorMessage\": \"{devBoxTextBoxErrorMessage}\"," + + $"\"DevBoxProjectComboBoxErrorMessage\": \"{devBoxProjectComboBoxErrorMessage}\"," + + $"\"DevBoxPoolsComboBoxErrorMessage\": \"{devBoxPoolsComboBoxErrorMessage}\"," + + $"\"DevBoxPoolComboBoxLabel\": \"{devBoxPoolComboBoxLabel}\"," + + $"\"TextBoxText\": \"{textboxText}\"," + + $"\"DevBoxPoolsPlaceHolder\": \"{devBoxPoolsPlaceHolder}\"," + + $"\"SelectedProjectIndex\": \"{selectedProjectIndex}\"," + + $"\"SelectedPoolIndex\": \"{selectedPoolIndex}\"," + + $"\"DevBoxNameRegex\": \"{Constants.NameRegexPattern}\"," + + $"\"SelectionChangedDataForChildChoiceSet\": {JsonSerializer.Serialize(_listOfAllDevBoxPools)}," + + $"\"ProjectList\" : {_arrayOfProjects.ToJsonString()}," + + $"\"PoolsList\" : {_listOfAllDevBoxPools[selectedProjectIndex].ToJsonString()}" + + $"}}"; + + var template = LoadTemplate(SessionState.InitialCreationForm); + + return _creationAdaptiveCard!.Update(template, templateData, "initialCreationForm"); + } + catch (Exception ex) + { + _log.Error(ex, "Unable to get get creation template and data"); + var creationFormGenerationError = Resources.GetResource("DevBox_InitialCreationFormGenerationFailedError"); + return new ProviderOperationResult(ProviderOperationStatus.Failure, ex, creationFormGenerationError, ex.Message); + } + } + + /// + /// Creates the review form that will be displayed to the user. This will be an adaptive card that is displayed in Dev Homes + /// setup flow review page. + /// + /// Result of the operation + private ProviderOperationResult GetForReviewFormAdaptiveCardAsync() + { + try + { + _log.Information("Building adaptive card template and data for the review form"); + + var container = _devBoxProjectAndPoolContainer[_currentSelectedProjectIndex]; + var pool = container.Pools!.Value![_currentSelectedPoolIndex]; + + _log.Information($"New DevBox name: {_originalUserInputJson["NewEnvironmentName"]}" + + $"Project name: {container.Project!.Name}, Project index '{_currentSelectedProjectIndex}'" + + $"Pool Name: {pool.Name}, Pool index '{_currentSelectedPoolIndex}'"); + + // Setup localized strings for review form + var reviewPageNameLabel = Resources.GetResource("DevBox_ReviewPageNameLabel", ":"); + var reviewPageProjectNameLabel = Resources.GetResource("DevBox_ReviewPageProjectNameLabel", ":"); + var reviewPagePoolNameLabel = Resources.GetResource("DevBox_ReviewPagePoolNameLabel", ":"); + var primaryButtonForCreationFlowText = Resources.GetResource("PrimaryButtonLabelForCreationFlow"); + var secondaryButtonForCreationFlowText = Resources.GetResource("SecondaryButtonLabelForCreationFlow"); + var poolHardwareSpecs = Resources.GetResource("DevBox_PoolSubtitle", pool.HardwareProfile.VCPUs, pool.HardwareProfile.MemoryGB, pool.StorageProfile.OsDisk.DiskSizeGB); + + var reviewFormData = new JsonObject + { + { "NewEnvironmentName", _originalUserInputJson["NewEnvironmentName"] }, + { "SelectedProjectName", container.Project!.Name }, + { "SelectedPoolName", pool.Name }, + { "ReviewPageProjectNameLabel", reviewPageProjectNameLabel }, + { "ReviewPagePoolNameLabel", reviewPagePoolNameLabel }, + { "ReviewPageSelectedPoolSpecs", poolHardwareSpecs }, + { "ReviewPageNameLabel", reviewPageNameLabel }, + { "PrimaryButtonLabelForCreationFlow", primaryButtonForCreationFlowText }, + { "SecondaryButtonLabelForCreationFlow", secondaryButtonForCreationFlowText }, + }; + + var template = LoadTemplate(SessionState.ReviewForm); + + return _creationAdaptiveCard!.Update(LoadTemplate(SessionState.ReviewForm), reviewFormData.ToJsonString(), "reviewForm"); + } + catch (Exception ex) + { + _log.Error(ex, "Unable to get get review template and data"); + var reviewFormGenerationError = Resources.GetResource("DevBox_ReviewFormGenerationFailedError"); + return new ProviderOperationResult(ProviderOperationStatus.Failure, ex, reviewFormGenerationError, ex.Message); + } + } + + private void HandleActionWhenFormInInitialState(Dictionary actionPayload, Dictionary inputPayload) + { + if (inputPayload.TryGetValue("NewEnvironmentName", out var textBoxStr)) + { + _currentTextBoxValue = textBoxStr; + } + + // check if a project was selected and update the selected index if it was. + if (inputPayload.TryGetValue("ProjectsComboBox", out var projectIndexStr) && int.TryParse(projectIndexStr, out var projectIndex)) + { + _currentSelectedProjectIndex = projectIndex; + + // check if a pool was selected and update the selected index if it was. + if (inputPayload.TryGetValue("PoolsComboBox", out var poolIndexStr) && int.TryParse(poolIndexStr, out var poolIndex)) + { + // If the project index changes, when GetCreationFormAdaptiveCard is called the list of pools will change. So, the previous + // index is no longer valid. + _currentSelectedPoolIndex = poolIndex; + } + } + + _log.Information($"HandleActionWhenFormInInitialState retrieved inputPayload: +" + + $"{nameof(_currentTextBoxValue)}: {_currentTextBoxValue}" + + $"{nameof(_currentSelectedProjectIndex)}: {_currentSelectedProjectIndex}" + + $"{nameof(_currentSelectedPoolIndex)}: {_currentSelectedPoolIndex}"); + + actionPayload.TryGetValue("id", out var actionButtonId); + if ((actionButtonId != null) && actionButtonId.Equals(_adaptiveCardNextButtonId, StringComparison.OrdinalIgnoreCase)) + { + _log.Information("Dev Home is moving to the Review page. Sending the review form and saving the current user inputPayload"); + + // if OnAction's state is initialCreationForm and the user clicks the next button in Dev Home then the form was validated and they were able to + // enter a name for their DevBox, select a project, and select a ComboBox. + _originalUserInputJson = inputPayload; + _operationResult = GetForReviewFormAdaptiveCardAsync(); + } + } + + private void HandleActionWhenFormInReviewState(Dictionary actionPayload) + { + actionPayload.TryGetValue("id", out var actionButtonId); + + if ((actionButtonId != null) && actionButtonId.Equals(_adaptiveCardNextButtonId, StringComparison.OrdinalIgnoreCase)) + { + _log.Information("Dev Home is moving to summary page. Sending the review form"); + + // if OnAction's state is reviewForm, then the user has reviewed the form and Dev Home has started the creation process. + // we'll show the same form to the user in Dev Homes summary page. + _shouldEndSession = true; + _operationResult = GetForReviewFormAdaptiveCardAsync(); + } + else + { + _log.Information("Dev Home is moving back to the configure environment creation page. Sending the creatin form with"); + + // User is going back to creation form by clicking the previous button in Dev Homes review page. + _operationResult = GetCreationFormAdaptiveCard(_currentTextBoxValue, _currentSelectedProjectIndex, _currentSelectedPoolIndex); + } + } + + private void BuildAllProjectsAndPoolJsonObjects() + { + _log.Information("Building json object for projects and pools"); + + for (var i = 0; i < _devBoxProjectAndPoolContainer.Count; i++) + { + var container = _devBoxProjectAndPoolContainer[i]; + if (container?.Pools?.Value == null) + { + continue; + } + + // Add information for the specific project to the project array + var projectInfo = new JsonObject + { + { "title", container.Project!.Name }, + { "value", $"{i}" }, + }; + + _arrayOfProjects.Add(projectInfo); + + // Add all the pools for the project into a list that contains all pools. + // The index of the list will be directly related to the index of the project + // in the project array above. + var poolsArray = new JsonArray(); + for (var k = 0; k < container.Pools.Value.Count; k++) + { + var pool = container.Pools.Value[k]; + var poolHardwareSpecs = Resources.GetResource("DevBox_PoolSubtitle", pool.HardwareProfile.VCPUs, pool.HardwareProfile.MemoryGB, pool.StorageProfile.OsDisk.DiskSizeGB); + var poolInfo = new JsonObject + { + { "title", $"{pool.Name}" }, + { "subtitle", $"{poolHardwareSpecs}" }, + { "value", $"{k}" }, + }; + + poolsArray.Add(poolInfo); + } + + _listOfAllDevBoxPools.Add(poolsArray); + } + } + + private void BuildUsedDevBoxNamesPerProject(IEnumerable? devBoxes) + { + if (devBoxes == null) + { + return; + } + + foreach (var devBox in devBoxes) + { + if (devBox is DevBoxInstance instance) + { + if (!_invalidDevBoxNames.TryGetValue(instance.DevBoxState.ProjectName, out var _)) + { + _invalidDevBoxNames[instance.DevBoxState.ProjectName] = new(); + } + + _invalidDevBoxNames[instance.DevBoxState.ProjectName].Add(instance.DevBoxState.Name); + } + } + } + + private string GetDataToCreateDevBox() + { + var container = _devBoxProjectAndPoolContainer[_currentSelectedProjectIndex]; + var pool = container.Pools!.Value![_currentSelectedPoolIndex]; + + var creationParameters = new DevBoxCreationParameters(container!.Project!.Name, pool.Name, _originalUserInputJson["NewEnvironmentName"], container.Project.Properties.DevCenterUri); + return JsonSerializer.Serialize(creationParameters, Constants.JsonOptions); + } +} diff --git a/src/AzureExtension/DevBox/Models/DevBoxCreationParameters.cs b/src/AzureExtension/DevBox/Models/DevBoxCreationParameters.cs index 31556c8..86851b5 100644 --- a/src/AzureExtension/DevBox/Models/DevBoxCreationParameters.cs +++ b/src/AzureExtension/DevBox/Models/DevBoxCreationParameters.cs @@ -15,16 +15,24 @@ public class DevBoxCreationParameters public string PoolName { get; set; } = string.Empty; - public string DevBoxName { get; set; } = string.Empty; + public string NewEnvironmentName { get; set; } = string.Empty; public string DevCenterUri { get; set; } = string.Empty; + public DevBoxCreationParameters(string projectName, string poolName, string newEnvironmentName, string devCenterUri) + { + ProjectName = projectName; + PoolName = poolName; + NewEnvironmentName = newEnvironmentName; + DevCenterUri = devCenterUri; + } + public override string ToString() { var builder = new StringBuilder(); builder.AppendLine(CultureInfo.InvariantCulture, $"Project Name: {ProjectName} "); builder.AppendLine(CultureInfo.InvariantCulture, $"Pool Name: {PoolName} "); - builder.AppendLine(CultureInfo.InvariantCulture, $"DevBox Name: {DevBoxName} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"DevBox Name: {NewEnvironmentName} "); builder.AppendLine(CultureInfo.InvariantCulture, $"DevCenter Uri: {DevCenterUri} "); return builder.ToString(); } diff --git a/src/AzureExtension/DevBox/Templates/CreationForm.json b/src/AzureExtension/DevBox/Templates/CreationForm.json new file mode 100644 index 0000000..04a05c3 --- /dev/null +++ b/src/AzureExtension/DevBox/Templates/CreationForm.json @@ -0,0 +1,109 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "ColumnSet", + "spacing": "large", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "Input.Text", + "id": "NewEnvironmentName", + "label": "${EnterNewEnvironmentTextBoxLabel}", + "maxLength": 100, + "isRequired": true, + "errorMessage": "${DevBoxTextBoxErrorMessage}", + "regex": "${DevBoxNameRegex}", + "spacing": "large", + "value": "${TextBoxText}" + } + ] + } + ] + }, + { + "type": "ColumnSet", + "spacing": "large", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "Input.ChoiceSet", + "id": "ProjectsComboBox", + "label": "${ProjectComboBoxLabel}", + "devHomeChildChoiceSetId": "PoolsComboBox", + "devHomeSelectionChangedDataForChildChoiceSet": "${SelectionChangedDataForChildChoiceSet}", + "devHomeRefreshChildChoiceSetOnSelectionChanged": true, + "isRequired": true, + "errorMessage": "${DevBoxProjectComboBoxErrorMessage}", + "style": "compact", + "value": "${SelectedProjectIndex}", + "choices": [ + { + "$data": "${ProjectList}", + "title": "${title}", + "value": "${value}" + } + ] + } + ] + } + ] + }, + { + "type": "ColumnSet", + "spacing": "large", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + + { + "type": "Input.ChoiceSet", + "id": "PoolsComboBox", + "devHomeParentChoiceSetId": "ProjectsComboBox", + "label": "${DevBoxPoolComboBoxLabel}", + "isRequired": true, + "errorMessage": "${DevBoxPoolsComboBoxErrorMessage}", + "placeholder": "${DevBoxPoolsPlaceHolder}", + "style": "compact", + "value": "${SelectedPoolIndex}", + "devHomeChoicesData": [ + { + "$data": "${PoolsList}", + "title": "${title}", + "subtitle": "${subtitle}", + "value": "${value}" + } + ] + } + ] + } + ] + }, + { + "type": "ActionSet", + "id": "DevHomeTopLevelActionSet", + "actions": [ + { + "id": "DevHomeMachineConfigurationNextButton", + "type": "Action.Submit", + "title": "${PrimaryButtonLabelForCreationFlow}" + }, + { + "id": "DevHomeMachineConfigurationPreviousButton", + "type": "Action.Submit", + "title": "${SecondaryButtonLabelForCreationFlow}" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/AzureExtension/DevBox/Templates/ReviewForm.json b/src/AzureExtension/DevBox/Templates/ReviewForm.json new file mode 100644 index 0000000..9f3d4c5 --- /dev/null +++ b/src/AzureExtension/DevBox/Templates/ReviewForm.json @@ -0,0 +1,92 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "spacing": "medium", + "items": [ + { + "type": "TextBlock", + "text": "${ReviewPageNameLabel}", + "wrap": true, + "size": "medium" + }, + { + "type": "TextBlock", + "text": "${NewEnvironmentName}", + "wrap": true, + "size": "medium" + } + ] + }, + { + "type": "Column", + "width": "auto", + "spacing": "extraLarge", + "items": [ + { + "type": "TextBlock", + "text": "${ReviewPageProjectNameLabel}", + "wrap": true, + "size": "medium" + }, + { + "type": "TextBlock", + "text": "${SelectedProjectName}", + "wrap": true, + "size": "medium" + } + ] + }, + { + "type": "Column", + "width": "auto", + "spacing": "extraLarge", + "items": [ + { + "type": "TextBlock", + "text": "${ReviewPagePoolNameLabel}", + "wrap": true, + "size": "medium" + }, + { + "type": "TextBlock", + "text": "${SelectedPoolName}", + "wrap": true, + "size": "medium" + }, + { + "type": "TextBlock", + "text": "${ReviewPageSelectedPoolSpecs}", + "wrap": true, + "isSubtle": true, + "size": "medium" + } + ] + } + ] + }, + { + "type": "ActionSet", + "id": "DevHomeTopLevelActionSet", + "actions": [ + { + "id": "DevHomeMachineConfigurationNextButton", + "type": "Action.Submit", + "title": "${PrimaryButtonLabelForCreationFlow}" + }, + { + "id": "DevHomeMachineConfigurationPreviousButton", + "type": "Action.Submit", + "title": "${SecondaryButtonLabelForCreationFlow}" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/AzureExtension/Services/DevBox/DevBoxCreationManager.cs b/src/AzureExtension/Services/DevBox/DevBoxCreationManager.cs index c2f27da..bb0aa19 100644 --- a/src/AzureExtension/Services/DevBox/DevBoxCreationManager.cs +++ b/src/AzureExtension/Services/DevBox/DevBoxCreationManager.cs @@ -9,6 +9,7 @@ using DevHomeAzureExtension.Helpers; using Microsoft.Windows.DevHome.SDK; using Serilog; +using static System.Windows.Forms.VisualStyles.VisualStyleElement; namespace AzureExtension.Services.DevBox; @@ -48,23 +49,24 @@ public async Task StartCreateDevBoxOperation(CreateCo { try { + _log.Information($"Starting the create DevBox operation for new environment with new: {parameters.NewEnvironmentName}"); operation.UpdateProgress(Resources.GetResource(SendingCreationRequestProgressKey), Constants.IndefiniteProgress); var result = await _devBoxManagementService.CreateDevBox(parameters, developerId); - operation.UpdateProgress(Resources.GetResource(CreationResponseReceivedProgressKey, parameters.DevBoxName, parameters.ProjectName), Constants.IndefiniteProgress); + operation.UpdateProgress(Resources.GetResource(CreationResponseReceivedProgressKey, parameters.NewEnvironmentName, parameters.ProjectName), Constants.IndefiniteProgress); var devBoxState = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions)!; var devBox = _devBoxInstanceFactory(developerId, devBoxState); - operation.UpdateProgress(Resources.GetResource(DevCenterCreationStartedProgressKey, parameters.DevBoxName, parameters.ProjectName), Constants.IndefiniteProgress); + operation.UpdateProgress(Resources.GetResource(DevCenterCreationStartedProgressKey, parameters.NewEnvironmentName, parameters.ProjectName), Constants.IndefiniteProgress); var callback = DevCenterLongRunningOperationCallback(devBox); // Now we can start querying the Dev Center for the creation status of the Dev Box operation. This operation will continue until the Dev Box is ready for use. var operationUri = result.ResponseHeader.OperationLocation; - var operationid = Guid.Parse(operationUri!.Segments.Last()); + var operationId = Guid.Parse(operationUri!.Segments.Last()); - _devBoxOperationWatcher.StartDevCenterOperationMonitor(developerId, operationUri!, operationid, DevBoxActionToPerform.Create, callback); + _devBoxOperationWatcher.StartDevCenterOperationMonitor(developerId, operationUri!, operationId, DevBoxActionToPerform.Create, callback); // At this point the DevBox is partially created in the cloud. However the DevBox is not ready for use. Querying for all Dev Box will // return this DevBox via Json with its provisioningState set to "Provisioning". So, we'll keep track of the operation. @@ -76,7 +78,7 @@ public async Task StartCreateDevBoxOperation(CreateCo catch (Exception ex) { _log.Error(ex, $"unable to create the Dev Box with user options: {parameters}"); - return new CreateComputeSystemResult(ex, Resources.GetResource(CreationErrorProgressKey, parameters.DevBoxName, parameters.ProjectName), ex.Message); + return new CreateComputeSystemResult(ex, Resources.GetResource(CreationErrorProgressKey, parameters.NewEnvironmentName, parameters.ProjectName, ex.Message), ex.Message); } } @@ -84,6 +86,7 @@ public async Task StartCreateDevBoxOperation(CreateCo { return (DevCenterOperationStatus? status) => { + _log.Information($"Long running operation status: '{status}' received for DevBox: {devBox.DisplayName}, Id: {devBox.Id}."); switch (status) { case DevCenterOperationStatus.NotStarted: diff --git a/src/AzureExtension/Services/DevBox/DevBoxManagementService.cs b/src/AzureExtension/Services/DevBox/DevBoxManagementService.cs index b223337..113db94 100644 --- a/src/AzureExtension/Services/DevBox/DevBoxManagementService.cs +++ b/src/AzureExtension/Services/DevBox/DevBoxManagementService.cs @@ -25,6 +25,8 @@ public class DevBoxManagementService : IDevBoxManagementService private readonly ILogger _log = Log.ForContext("SourceContext", nameof(DevBoxManagementService)); + private readonly Dictionary> _projectAndPoolContainerMap = new(); + public DevBoxManagementService(IDevBoxAuthService authService) => _authService = authService; private const string DevBoxManagementServiceName = nameof(DevBoxManagementService); @@ -68,39 +70,45 @@ private async Task DevBoxHttpRequest(HttpClient client } /// - public async Task> GetAllProjectsToPoolsMappingAsync(JsonElement projectsJson, IDeveloperId developerId) + public async Task> GetAllProjectsToPoolsMappingAsync(DevBoxProjects projects, IDeveloperId developerId) { + var uniqueUserId = $"{developerId.LoginId}#{developerId.Url}"; + + if (_projectAndPoolContainerMap.TryGetValue(uniqueUserId, out var devBoxProjectAndPools)) + { + return devBoxProjectAndPools; + } + var projectsToPoolsMapping = new List(); - foreach (var projectJson in projectsJson.EnumerateArray()) + foreach (var project in projects.Data!) { try { - var projectObj = JsonSerializer.Deserialize(projectJson.ToString(), Constants.JsonOptions)!; - var properties = projectObj.Properties; - var uriToRetrievePools = $"{properties.DevCenterUri}{Constants.Projects}/{projectObj.Name}/{Constants.Pools}?{Constants.APIVersion}"; + var properties = project.Properties; + var uriToRetrievePools = $"{properties.DevCenterUri}{Constants.Projects}/{project.Name}/{Constants.Pools}?{Constants.APIVersion}"; var result = await HttpsRequestToDataPlane(new Uri(uriToRetrievePools), developerId, HttpMethod.Get); var pools = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions); - var container = new DevBoxProjectAndPoolContainer { Project = projectObj, Pools = pools }; + var container = new DevBoxProjectAndPoolContainer { Project = project, Pools = pools }; projectsToPoolsMapping.Add(container); } catch (Exception ex) { - projectJson.TryGetProperty("name", out var projectWithErrorName); - _log.Error(ex, $"unable to get pools for {projectWithErrorName}"); + _log.Error(ex, $"unable to get pools for {project.Name}"); } } + _projectAndPoolContainerMap.Add(uniqueUserId, projectsToPoolsMapping); return projectsToPoolsMapping; } /// public async Task CreateDevBox(DevBoxCreationParameters parameters, IDeveloperId developerId) { - if (!Regex.IsMatch(parameters.DevBoxName, Constants.NameRegexPattern)) + if (!Regex.IsMatch(parameters.NewEnvironmentName, Constants.NameRegexPattern)) { - throw new DevBoxNameInvalidException($"Unable to create Dev Box due to Invalid Dev Box name: {parameters.DevBoxName}"); + throw new DevBoxNameInvalidException($"Unable to create Dev Box due to Invalid Dev Box name: {parameters.NewEnvironmentName}"); } if (!Regex.IsMatch(parameters.ProjectName, Constants.NameRegexPattern)) @@ -108,7 +116,7 @@ public async Task CreateDevBox(DevBoxCreationParameter throw new DevBoxProjectNameInvalidException($"Unable to create Dev Box due to Invalid project name: {parameters.ProjectName}"); } - var uriToCreateDevBox = $"{parameters.DevCenterUri}{Constants.Projects}/{parameters.ProjectName}{Constants.DevBoxUserSegmentOfUri}/{parameters.DevBoxName}?{Constants.APIVersion}"; + var uriToCreateDevBox = $"{parameters.DevCenterUri}{Constants.Projects}/{parameters.ProjectName}{Constants.DevBoxUserSegmentOfUri}/{parameters.NewEnvironmentName}?{Constants.APIVersion}"; var contentJson = JsonSerializer.Serialize(new DevBoxCreationPoolName(parameters.PoolName)); var content = new StringContent(contentJson, Encoding.UTF8, "application/json"); return await HttpsRequestToDataPlane(new Uri(uriToCreateDevBox), developerId, HttpMethod.Put, content); diff --git a/src/AzureExtension/Services/DevBox/DevBoxOperationWatcher.cs b/src/AzureExtension/Services/DevBox/DevBoxOperationWatcher.cs index 5f24e6c..0eeb70a 100644 --- a/src/AzureExtension/Services/DevBox/DevBoxOperationWatcher.cs +++ b/src/AzureExtension/Services/DevBox/DevBoxOperationWatcher.cs @@ -71,7 +71,9 @@ public void StartDevCenterOperationMonitor(IDeveloperId developerId, Uri operati async (ThreadPoolTimer timer) => { try - { + { + _log.Information($"Starting Dev Box operation with action {actionToPerform}, Uri: {operationUri}, Id: {operationId}"); + // Query the Dev Center for the status of the Dev Box operation. var result = await _managementService.HttpsRequestToDataPlane(operationUri, developerId, HttpMethod.Get, null); var operation = JsonSerializer.Deserialize(result.JsonResponseRoot.ToString(), Constants.JsonOptions)!; @@ -131,7 +133,9 @@ public void StartDevBoxProvisioningStatusMonitor(IDeveloperId developerId, DevBo async (ThreadPoolTimer timer) => { try - { + { + _log.Information($"Starting the provisioning monitor for Dev Box with Name: '{devBoxInstance.DisplayName}' , Id: '{devBoxInstance.Id}'"); + // Query the Dev Center for the provisioning status of the Dev Box. This is needed for when the Dev Box was created outside of Dev Home. var devBoxUri = $"{devBoxInstance.DevBoxState.Uri}?{Constants.APIVersion}"; var result = await _managementService.HttpsRequestToDataPlane(new Uri(devBoxUri), developerId, HttpMethod.Get, null); diff --git a/src/AzureExtension/Services/DevBox/TimeSpanService.cs b/src/AzureExtension/Services/DevBox/TimeSpanService.cs index 268e9bc..70f38d8 100644 --- a/src/AzureExtension/Services/DevBox/TimeSpanService.cs +++ b/src/AzureExtension/Services/DevBox/TimeSpanService.cs @@ -28,7 +28,7 @@ public TimeSpan GetPeriodIntervalBasedOnAction(DevBoxActionToPerform actionToPer switch (actionToPerform) { case DevBoxActionToPerform.Create: - return Constants.FiveMinutePeriod; + return Constants.ThreeMinutePeriod; default: return Constants.OneMinutePeriod; } diff --git a/src/AzureExtension/Strings/en-US/Resources.resw b/src/AzureExtension/Strings/en-US/Resources.resw index 93f7bc9..824c4e5 100644 --- a/src/AzureExtension/Strings/en-US/Resources.resw +++ b/src/AzureExtension/Strings/en-US/Resources.resw @@ -431,6 +431,78 @@ Dev Boxes aren't configured for the current account. Error shown when Dev Boxes aren't configured for the current account. + + Choose an image to use + Label text for a list of cards that appear in the UI + + + Previous + Text to display to the user about what the secondary button does in the UI. + + + Next + Text to display to the user about what the primary button does in the UI + + + Enter a name for your Microsoft DevBox + Label text for textbox where users will enter the name for their Dev Box + + + Name{0} + Label text for textbox where users will enter the name for their Dev Box + + + Select a project + Label text for combo box where users will select the project their Dev Box will be created in + + + Project{0} + Label text in Dev Homes review page that will display the name of the project the user selected in the wizard flow. {0} is a punctuation like ':' + + + Pool{0} + Label text in Dev Homes review page that will display the name of the pool the user selected in the wizard flow. {0} is a punctuation like ':' + + + Select a pool + Label text for combo box where users will select the pool their Dev Box will be created in + + + View Pools + Label text for combo box where users will select the pool their Dev Box will be created in + + + Failed to generate the initial creation form + Error text to show the user when there was an error getting the project names for the dev box creation wizard + + + Failed to generate the review form + Error text to show the user when the was an error getting the review page for dev box creation wizard + + + Please enter a name that contains more than three characters, uses only alphanumeric characters and hyphens, and does not start with a hypen + Error text to show the user when the name of the new Dev Box they've entered does not match the criteria + + + A project must be selected + Error text to show the user when the user does not select a project name in the project combo box + + + A pool must be selected + Error text to show the user when the user does not select a pool name in the pools combo box + + + {0} vCPU {1} GB RAM {2} GB Storage + Text to describe the hardware specs for a machine + + + Adaptive card state not recognized + Error text to show when we don't recognize the state of the adaptive card that was given to us + + + Please select a pool closest to your physical location for optimal performance + text instructions to show the user in the select pools drop down box + Applying the configuration failed Error message when applying the configuration failed. diff --git a/test/AzureExtension/DevBox/DevBoxTests.cs b/test/AzureExtension/DevBox/DevBoxTests.cs index 212a284..07c285a 100644 --- a/test/AzureExtension/DevBox/DevBoxTests.cs +++ b/test/AzureExtension/DevBox/DevBoxTests.cs @@ -38,6 +38,7 @@ public void DevBox_GetAllDevBoxes() var contentList = new List { new StringContent(MockProjectJson), + new StringContent(MockTestPoolJson), new StringContent(MockDevBoxListJson), }; UpdateHttpClientResponseMock(contentList); @@ -99,6 +100,7 @@ public async Task DevBox_DevBoxOperationsSucceed() var contentList = new List { new StringContent(MockProjectJson), + new StringContent(MockTestPoolJson), new StringContent(devBoxListPoweredOffJson), new StringContent(devBoxListPoweredOffJson), new StringContent(MockTestOperationJson), // initial operation status with 'Running' status diff --git a/test/AzureExtension/DevBox/DevBoxTestsSetup.cs b/test/AzureExtension/DevBox/DevBoxTestsSetup.cs index 91c9459..b957845 100644 --- a/test/AzureExtension/DevBox/DevBoxTestsSetup.cs +++ b/test/AzureExtension/DevBox/DevBoxTestsSetup.cs @@ -111,6 +111,33 @@ private TestOptions TestOptions ""startTime"": ""2024-02-20T08:23:16.8547869+00:00"" }"; + private const string MockTestPoolJson = + @"{ + ""uri"": ""https://8a40af38-3b4c-4672-a6a4-5e964b1870ed-contosodevcenter.centralus.devcenter.azure.com/projects/myProject/users/b08e39b4-2ac6-4465-a35e-48322efb0f98/devboxes/MyDevBox"", + ""name"": ""MyDevBox"", + ""provisioningState"": ""Succeeded"", + ""projectName"": ""ContosoProject"", + ""poolName"": ""LargeDevWorkStationPool"", + ""location"": ""centralus"", + ""osType"": ""Windows"", + ""user"": ""b08e39b4-2ac6-4465-a35e-48322efb0f98"", + ""hardwareProfile"": { + ""vCPUs"": 8, + ""memoryGB"": 32 + }, + ""storageProfile"": { + ""osDisk"": { + ""diskSizeGB"": 1024 + } + }, + ""hibernateSupport"": ""Enabled"", + ""imageReference"": { + ""name"": ""DevImage"", + ""version"": ""1.0.0"", + ""publishedDate"": ""2022-03-01T00:13:23.323Z"" + } + }"; + private const string MockTestRemoteConnectionJson = @"{ ""webUrl"": ""https://devcenter.azure.com/projects/project/users/b214d34a-3feb-12ab-96bd-cca70d0c9d69/devboxes/DevBox1"", @@ -119,7 +146,7 @@ private TestOptions TestOptions private const string MockTestCreationParametersJson = @"{ - ""devBoxName"": ""MyDevBox"", + ""NewEnvironmentName"": ""MyDevBox"", ""projectName"": ""MyProject"", ""poolName"": ""MyPoolName"", ""devCenterUri"": ""https://devcenter.azure.com""