diff --git a/playground/Stress/Stress.AppHost/Program.cs b/playground/Stress/Stress.AppHost/Program.cs index bd48964f5ea..c3e558c66e0 100644 --- a/playground/Stress/Stress.AppHost/Program.cs +++ b/playground/Stress/Stress.AppHost/Program.cs @@ -3,6 +3,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var builder = DistributedApplication.CreateBuilder(args); builder.Services.AddHttpClient(); @@ -113,7 +116,121 @@ await ExecuteCommandForAllResourcesAsync(c.ServiceProvider, "resource-start", c.CancellationToken); return CommandResults.Success(); }, - commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled }); + commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled }) + .WithCommand("confirmation-interaction", "Confirmation interactions", executeCommand: async commandContext => + { + var interactionService = commandContext.ServiceProvider.GetRequiredService(); + var resultTask1 = interactionService.PromptConfirmationAsync("Command confirmation", "Are you sure?", cancellationToken: commandContext.CancellationToken); + var resultTask2 = interactionService.PromptConfirmationAsync("Command confirmation", "Are you really sure?", new MessageBoxInteractionOptions { Intent = MessageIntent.Warning }, cancellationToken: commandContext.CancellationToken); + + await Task.WhenAll(resultTask1, resultTask2); + + if (resultTask1.Result.Data != true || resultTask2.Result.Data != true) + { + return CommandResults.Failure("Canceled"); + } + + _ = interactionService.PromptMessageBoxAsync("Command executed", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success, PrimaryButtonText = "Yeah!" }); + return CommandResults.Success(); + }) + .WithCommand("messagebar-interaction", "Messagebar interactions", executeCommand: async commandContext => + { + await Task.Yield(); + + var interactionService = commandContext.ServiceProvider.GetRequiredService(); + _ = interactionService.PromptMessageBarAsync("Success bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success }); + _ = interactionService.PromptMessageBarAsync("Information bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Information }); + _ = interactionService.PromptMessageBarAsync("Warning bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Warning }); + _ = interactionService.PromptMessageBarAsync("Error bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Error }); + _ = interactionService.PromptMessageBarAsync("Confirmation bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Confirmation }); + _ = interactionService.PromptMessageBarAsync("No dismiss", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Information, ShowDismiss = false }); + + return CommandResults.Success(); + }) + .WithCommand("html-interaction", "HTML interactions", executeCommand: async commandContext => + { + var interactionService = commandContext.ServiceProvider.GetRequiredService(); + + _ = interactionService.PromptMessageBarAsync("Success bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success }); + _ = interactionService.PromptMessageBarAsync("Success bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success, EscapeMessageHtml = false }); + + _ = interactionService.PromptMessageBoxAsync("Success bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success }); + _ = interactionService.PromptMessageBoxAsync("Success bar", "The command successfully executed.", new MessageBoxInteractionOptions { Intent = MessageIntent.Success, EscapeMessageHtml = false }); + + _ = await interactionService.PromptInputAsync("Text request", "Provide your name", "Name", "Enter your name"); + _ = await interactionService.PromptInputAsync("Text request", "Provide your name", "Name", "Enter your name", new InputsDialogInteractionOptions { EscapeMessageHtml = false }); + + return CommandResults.Success(); + }) + .WithCommand("value-interaction", "Value interactions", executeCommand: async commandContext => + { + var interactionService = commandContext.ServiceProvider.GetRequiredService(); + var result = await interactionService.PromptInputAsync( + title: "Text request", + message: "Provide your name", + inputLabel: "Name", + placeHolder: "Enter your name", + cancellationToken: commandContext.CancellationToken); + + if (result.Canceled) + { + return CommandResults.Failure("Canceled"); + } + + var resourceLoggerService = commandContext.ServiceProvider.GetRequiredService(); + var logger = resourceLoggerService.GetLogger(commandContext.ResourceName); + + var input = result.Data!; + logger.LogInformation("Input: {Label} = {Value}", input.Label, input.Value); + + return CommandResults.Success(); + }) + .WithCommand("input-interaction", "Input interactions", executeCommand: async commandContext => + { + var interactionService = commandContext.ServiceProvider.GetRequiredService(); + var inputs = new List + { + new InteractionInput { InputType = InputType.Text, Label = "Name", Placeholder = "Enter name", Required = true }, + new InteractionInput { InputType = InputType.Password, Label = "Password", Placeholder = "Enter password", Required = true }, + new InteractionInput { InputType = InputType.Select, Label = "Dinner", Placeholder = "Select dinner", Required = true, Options = + [ + KeyValuePair.Create("pizza", "Pizza"), + KeyValuePair.Create("fried-chicken", "Fried chicken"), + KeyValuePair.Create("burger", "Burger"), + KeyValuePair.Create("salmon", "Salmon"), + KeyValuePair.Create("chicken-pie", "Chicken pie"), + KeyValuePair.Create("sushi", "Sushi"), + KeyValuePair.Create("tacos", "Tacos"), + KeyValuePair.Create("pasta", "Pasta"), + KeyValuePair.Create("salad", "Salad"), + KeyValuePair.Create("steak", "Steak"), + KeyValuePair.Create("vegetarian", "Vegetarian"), + KeyValuePair.Create("sausage", "Sausage"), + KeyValuePair.Create("lasagne", "Lasagne"), + KeyValuePair.Create("fish-pie", "Fish pie"), + KeyValuePair.Create("soup", "Soup"), + KeyValuePair.Create("beef-stew", "Beef stew"), + ] }, + new InteractionInput { InputType = InputType.Number, Label = "Number of people", Placeholder = "Enter number of people", Value = "2", Required = true }, + new InteractionInput { InputType = InputType.Checkbox, Label = "Remember me", Placeholder = "What does this do?", Required = true }, + }; + var result = await interactionService.PromptInputsAsync("Input request", "Provide your name", inputs, cancellationToken: commandContext.CancellationToken); + + if (result.Canceled) + { + return CommandResults.Failure("Canceled"); + } + + var resourceLoggerService = commandContext.ServiceProvider.GetRequiredService(); + var logger = resourceLoggerService.GetLogger(commandContext.ResourceName); + + foreach (var updatedInput in result.Data!) + { + logger.LogInformation("Input: {Label} = {Value}", updatedInput.Label, updatedInput.Value); + } + + return CommandResults.Success(); + }); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging @@ -158,3 +275,5 @@ static async Task ExecuteCommandForAllResourcesAsync(IServiceProvider servicePro } await Task.WhenAll(commandTasks).ConfigureAwait(false); } + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/playground/Stress/Stress.TelemetryService/TelemetryStresser.cs b/playground/Stress/Stress.TelemetryService/TelemetryStresser.cs index 76e47614dbe..3f303e8b1f4 100644 --- a/playground/Stress/Stress.TelemetryService/TelemetryStresser.cs +++ b/playground/Stress/Stress.TelemetryService/TelemetryStresser.cs @@ -73,9 +73,11 @@ private static async Task ExportMetrics(ILogger logger, Metad } }; - logger.LogDebug("Exporting metrics"); var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); - logger.LogDebug($"Export complete. Rejected count: {response.PartialSuccess?.RejectedDataPoints ?? 0}"); + if (response.PartialSuccess is { RejectedDataPoints: > 0 } result) + { + logger.LogDebug($"Export complete. Rejected count: {result.RejectedDataPoints}"); + } } public static Resource CreateResource(string? name = null, string? instanceId = null) diff --git a/playground/TestShop/TestShop.AppHost/Program.cs b/playground/TestShop/TestShop.AppHost/Program.cs index 83a6b46af01..477a7c2a04c 100644 --- a/playground/TestShop/TestShop.AppHost/Program.cs +++ b/playground/TestShop/TestShop.AppHost/Program.cs @@ -75,7 +75,7 @@ .WithHttpHealthCheck("/health"); builder.AddProject("orderprocessor", launchProfileName: "OrderProcessor") - .WithReference(messaging).WaitFor(messaging); + .WithReference(messaging).WaitFor(messaging); builder.AddYarp("apigateway") .WithConfigFile("yarp.json") diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 3e770097d71..566798ab1bb 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -35,7 +35,6 @@ - Internal ResourceService\resource_service.proto diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor new file mode 100644 index 00000000000..1a8baffedcf --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -0,0 +1,86 @@ +@using Aspire.Dashboard.Components.Controls.Chart +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Model.Otlp +@using Aspire.Dashboard.Otlp.Model +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils +@using Aspire.Dashboard.Extensions +@using System.Globalization +@using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.ResourceService.Proto.V1 +@using Dialogs = Aspire.Dashboard.Resources.Dialogs +@implements IDialogContentComponent + +@inject IStringLocalizer Loc + + + + + @Dialog.Instance.Parameters.Title + + + + + + @if (!string.IsNullOrEmpty(Content.Interaction.Message)) + { +

@((MarkupString)Content.Interaction.Message)

+ } + + + + @foreach (var ss in Content.Inputs) + { + var localItem = ss; +
+ @switch (ss.InputType) + { + case InputType.Text: + + + break; + case InputType.Password: + + + break; + case InputType.Select: + + + break; + case InputType.Checkbox: + + break; + case InputType.Number: + + + break; + default: + @* Ignore unexpected InputTypes *@ + break; + } +
+ } +
+
+
+ + + + @Dialog.Instance.Parameters.PrimaryAction + + @if (!string.IsNullOrEmpty(Dialog.Instance.Parameters.SecondaryAction)) + { + + @Dialog.Instance.Parameters.SecondaryAction + + } + diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs new file mode 100644 index 00000000000..d369b64d8e5 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.ResourceService.Proto.V1; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class InteractionsInputDialog +{ + [Parameter] + public InteractionsInputsDialogViewModel Content { get; set; } = default!; + + [CascadingParameter] + public FluentDialog Dialog { get; set; } = default!; + + private EditContext _editContext = default!; + private ValidationMessageStore _validationMessages = default!; + + protected override void OnInitialized() + { + _editContext = new EditContext(Content); + _validationMessages = new ValidationMessageStore(_editContext); + + _editContext.OnValidationRequested += (s, e) => ValidateModel(); + _editContext.OnFieldChanged += (s, e) => ValidateField(e.FieldIdentifier); + } + + private void ValidateModel() + { + _validationMessages.Clear(); + + foreach (var inputModel in Content.Inputs) + { + var field = new FieldIdentifier(inputModel, nameof(inputModel.Value)); + if (IsMissingRequiredValue(inputModel)) + { + _validationMessages.Add(field, $"{inputModel.Label} is required."); + } + } + + _editContext.NotifyValidationStateChanged(); + } + + private void ValidateField(FieldIdentifier field) + { + _validationMessages.Clear(field); + + if (field.Model is InteractionInput inputModel) + { + if (IsMissingRequiredValue(inputModel)) + { + _validationMessages.Add(field, $"{inputModel.Label} is required."); + } + } + + _editContext.NotifyValidationStateChanged(); + } + + private static bool IsMissingRequiredValue(InteractionInput inputModel) + { + return inputModel.Required && + inputModel.InputType != InputType.Checkbox && + string.IsNullOrWhiteSpace(inputModel.Value); + } + + private async Task OkAsync() + { + if (_editContext.Validate()) + { + await Dialog.CloseAsync(Content); + } + } + + private async Task CancelAsync() + { + await Dialog.CancelAsync(); + } +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css new file mode 100644 index 00000000000..69826faaf88 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css @@ -0,0 +1,7 @@ +.interaction-input-dialog .interaction-input ::deep { + width: 100%; +} + +.interaction-input-dialog .interaction-input ::deep fluent-text-field, .interaction-input-dialog .interaction-input ::deep fluent-select { + width: 75%; +} diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs new file mode 100644 index 00000000000..e3dce4fcfd5 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -0,0 +1,461 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Net; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; +using Aspire.ResourceService.Proto.V1; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.FluentUI.AspNetCore.Components; +using Color = Microsoft.FluentUI.AspNetCore.Components.Color; +using MessageIntentDto = Aspire.ResourceService.Proto.V1.MessageIntent; +using MessageIntentUI = Microsoft.FluentUI.AspNetCore.Components.MessageIntent; + +namespace Aspire.Dashboard.Components.Interactions; + +public class InteractionsProvider : ComponentBase, IAsyncDisposable +{ + private record InteractionMessageBarReference(WatchInteractionsResponseUpdate Interaction, Message Message); + private record InteractionDialogReference(WatchInteractionsResponseUpdate Interaction, IDialogReference Dialog); + + private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private readonly KeyedInteractionCollection _pendingInteractions = new(); + private readonly List _openMessageBars = new(); + + private Task? _interactionsDisplayTask; + private Task? _watchInteractionsTask; + private TaskCompletionSource _interactionAvailableTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private InteractionDialogReference? _interactionDialogReference; + + [Inject] + public required IDashboardClient DashboardClient { get; init; } + + [Inject] + public required IDialogService DialogService { get; init; } + + [Inject] + public required IMessageService MessageService { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + protected override void OnInitialized() + { + // Exit quickly if the dashboard client is not enabled. For example, the dashboard is running in the standalone container. + if (!DashboardClient.IsEnabled) + { + return; + } + + _interactionsDisplayTask = Task.Run(async () => + { + var waitForInteractionAvailableTask = Task.CompletedTask; + + while (!_cts.IsCancellationRequested) + { + // If there are no pending interactions then wait on this task to get notified when one is added. + await waitForInteractionAvailableTask.WaitAsync(_cts.Token).ConfigureAwait(false); + + IDialogReference? currentDialogReference = null; + + await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); + try + { + if (_pendingInteractions.Count == 0) + { + // Task is set when a new interaction is added. + // Continue here will exit the async lock and wait for the task to complete. + waitForInteractionAvailableTask = _interactionAvailableTcs.Task; + continue; + } + + waitForInteractionAvailableTask = Task.CompletedTask; + var item = ((IList)_pendingInteractions)[0]; + _pendingInteractions.RemoveAt(0); + + Func> openDialog; + + if (item.MessageBox is { } messageBox) + { + var dialogParameters = CreateDialogParameters(item, messageBox.Intent); + dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => + { + var request = new WatchInteractionsRequestUpdate + { + InteractionId = item.InteractionId + }; + + if (dialogResult.Cancelled) + { + // There will be data in the dialog result on cancel if the secondary button is clicked. + if (dialogResult.Data != null) + { + messageBox.Result = false; + request.MessageBox = messageBox; + } + else + { + request.Complete = new InteractionComplete(); + } + } + else + { + messageBox.Result = true; + request.MessageBox = messageBox; + } + + await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + }); + + var content = new MessageBoxContent + { + Title = item.Title, + MarkupMessage = new MarkupString(item.Message), + }; + switch (messageBox.Intent) + { + case MessageIntentDto.Success: + content.IconColor = Color.Success; + content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.CheckmarkCircle(); + break; + case MessageIntentDto.Warning: + content.IconColor = Color.Warning; + content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Warning(); + break; + case MessageIntentDto.Error: + content.IconColor = Color.Error; + content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.DismissCircle(); + break; + case MessageIntentDto.Information: + content.IconColor = Color.Info; + content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Info(); + break; + case MessageIntentDto.Confirmation: + content.IconColor = Color.Success; + content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.QuestionCircle(); + break; + } + + openDialog = dialogService => ShowMessageBoxAsync(dialogService, content, dialogParameters); + } + else if (item.InputsDialog is { } inputs) + { + var vm = new InteractionsInputsDialogViewModel + { + Interaction = item, + Inputs = inputs.InputItems.ToList() + }; + + var dialogParameters = CreateDialogParameters(item, intent: null); + dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => + { + var request = new WatchInteractionsRequestUpdate + { + InteractionId = item.InteractionId + }; + + if (dialogResult.Cancelled) + { + request.Complete = new InteractionComplete(); + } + else + { + request.InputsDialog = item.InputsDialog; + } + + await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + }); + + openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); + } + else + { + Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase); + continue; + } + + await InvokeAsync(async () => + { + currentDialogReference = await openDialog(DialogService); + }); + + Debug.Assert(currentDialogReference != null, "Dialog should have been created in UI thread."); + _interactionDialogReference = new InteractionDialogReference(item, currentDialogReference); + } + finally + { + _semaphore.Release(); + } + + try + { + if (currentDialogReference != null) + { + await currentDialogReference.Result.WaitAsync(_cts.Token); + } + } + catch + { + // Ignore any exceptions that occur while waiting for the dialog to close. + } + } + }); + + _watchInteractionsTask = Task.Run(async () => + { + var interactions = DashboardClient.SubscribeInteractionsAsync(_cts.Token); + await foreach (var item in interactions) + { + await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); + try + { + switch (item.KindCase) + { + case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox: + case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog: + // New or updated interaction. + _pendingInteractions.Remove(item.InteractionId); + _pendingInteractions.Add(item); + + NotifyInteractionAvailable(); + break; + case WatchInteractionsResponseUpdate.KindOneofCase.MessageBar: + var messageBar = item.MessageBar; + + Message? message = null; + await InvokeAsync(async () => + { + message = await MessageService.ShowMessageBarAsync(options => + { + options.Title = WebUtility.HtmlEncode(item.Title); + options.Body = item.Message; // Message is already HTML encoded depending on options. + options.Intent = MapMessageIntent(messageBar.Intent); + options.Section = DashboardUIHelpers.MessageBarSection; + options.AllowDismiss = item.ShowDismiss; + + var primaryButtonText = item.PrimaryButtonText; + var secondaryButtonText = item.SecondaryButtonText; + if (messageBar.Intent == MessageIntentDto.Confirmation) + { + primaryButtonText = string.IsNullOrEmpty(primaryButtonText) ? "OK" : primaryButtonText; + secondaryButtonText = string.IsNullOrEmpty(secondaryButtonText) ? "Cancel" : secondaryButtonText; + } + + bool? result = null; + + if (!string.IsNullOrEmpty(primaryButtonText)) + { + options.PrimaryAction = new ActionButton + { + Text = primaryButtonText, + OnClick = m => + { + result = true; + m.Close(); + return Task.CompletedTask; + } + }; + } + if (item.ShowSecondaryButton && !string.IsNullOrEmpty(secondaryButtonText)) + { + options.SecondaryAction = new ActionButton + { + Text = secondaryButtonText, + OnClick = m => + { + result = false; + m.Close(); + return Task.CompletedTask; + } + }; + } + + options.OnClose = async m => + { + // Only send complete notification if in the open message bars list. + var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId); + if (openMessageBar != null) + { + var request = new WatchInteractionsRequestUpdate + { + InteractionId = item.InteractionId + }; + + if (result == null) + { + request.Complete = new InteractionComplete(); + } + else + { + messageBar.Result = result.Value; + request.MessageBar = messageBar; + } + + _openMessageBars.Remove(openMessageBar); + + await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + } + }; + }); + }); + + Debug.Assert(message != null, "Message should have been created in UI thread."); + _openMessageBars.Add(new InteractionMessageBarReference(item, message)); + break; + case WatchInteractionsResponseUpdate.KindOneofCase.Complete: + // Complete interaction. + _pendingInteractions.Remove(item.InteractionId); + + // Close the interaction's dialog if it is open. + if (_interactionDialogReference?.Interaction.InteractionId == item.InteractionId) + { + try + { + await InvokeAsync(async () => + { + await _interactionDialogReference.Dialog.CloseAsync(); + }); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Unexpected error when closing interaction {InteractionId} dialog reference.", item.InteractionId); + } + finally + { + _interactionDialogReference = null; + } + } + + var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId); + if (openMessageBar != null) + { + // Open message bars is used to decide whether to report completion to the server. + // It's already complete so remove before close. + _openMessageBars.Remove(openMessageBar); + + // InvokeAsync not necessary here. It is called internally. + openMessageBar.Message.Close(); + } + break; + default: + Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase); + break; + } + } + finally + { + _semaphore.Release(); + } + } + }); + } + + private static MessageIntentUI MapMessageIntent(MessageIntentDto intent) + { + switch (intent) + { + case MessageIntentDto.Success: + return MessageIntentUI.Success; + case MessageIntentDto.Warning: + return MessageIntentUI.Warning; + case MessageIntentDto.Error: + return MessageIntentUI.Error; + case MessageIntentDto.Information: + return MessageIntentUI.Info; + default: + return MessageIntentUI.Info; + } + } + + private DialogParameters CreateDialogParameters(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent) + { + var dialogParameters = new DialogParameters + { + ShowDismiss = interaction.ShowDismiss, + DismissTitle = Loc[nameof(Resources.Dialogs.DialogCloseButtonText)], + PrimaryAction = ResolvedPrimaryButtonText(interaction, intent), + SecondaryAction = ResolvedSecondaryButtonText(interaction), + PreventDismissOnOverlayClick = true, + Title = interaction.Title + }; + + return dialogParameters; + } + + private string ResolvedPrimaryButtonText(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent) + { + if (interaction.PrimaryButtonText is { Length: > 0 } primaryText) + { + return primaryText; + } + if (intent == MessageIntentDto.Error) + { + return Loc[nameof(Resources.Dialogs.InteractionButtonClose)]; + } + + return Loc[nameof(Resources.Dialogs.InteractionButtonOk)]; + } + + private string ResolvedSecondaryButtonText(WatchInteractionsResponseUpdate interaction) + { + if (!interaction.ShowSecondaryButton) + { + return string.Empty; + } + + return interaction.SecondaryButtonText is { Length: > 0 } secondaryText + ? secondaryText + : Loc[nameof(Resources.Dialogs.InteractionButtonCancel)]; + } + + public async Task ShowMessageBoxAsync(IDialogService dialogService, MessageBoxContent content, DialogParameters parameters) + { + var dialogParameters = new DialogParameters + { + DialogType = DialogType.MessageBox, + Alignment = HorizontalAlignment.Center, + Title = content.Title, + ShowDismiss = false, + PrimaryAction = parameters.PrimaryAction, + SecondaryAction = parameters.SecondaryAction, + Width = parameters.Width, + Height = parameters.Height, + AriaLabel = (content.Title ?? ""), + OnDialogResult = parameters.OnDialogResult + }; + return await dialogService.ShowDialogAsync(typeof(MessageBox), content, dialogParameters); + } + + private void NotifyInteractionAvailable() + { + // Let current waiters know that an interaction is available. + _interactionAvailableTcs.TrySetResult(); + + // Reset the task completion source for future waiters. + _interactionAvailableTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + + await TaskHelpers.WaitIgnoreCancelAsync(_interactionsDisplayTask); + await TaskHelpers.WaitIgnoreCancelAsync(_watchInteractionsTask); + } + + private class KeyedInteractionCollection : KeyedCollection + { + protected override int GetKeyForItem(WatchInteractionsResponseUpdate item) + { + return item.InteractionId; + } + } +} diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index 06be14b3bd4..439f739c3fe 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -1,6 +1,8 @@ @using Aspire.Dashboard.Components.CustomIcons +@using Aspire.Dashboard.Components.Interactions @using Aspire.Dashboard.Model @using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils @inherits LayoutComponentBase
@@ -66,7 +68,7 @@ }
- +
@@ -76,6 +78,7 @@ +
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] @Loc[nameof(Layout.MainLayoutUnhandledErrorReload)] diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 728f52a19f8..8a8481c4818 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -28,7 +28,6 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable private const string SettingsDialogId = "SettingsDialog"; private const string HelpDialogId = "HelpDialog"; - private const string MessageBarSection = "MessagesTop"; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -126,7 +125,7 @@ await MessageService.ShowMessageBarAsync(options => Target = "_blank" }; options.Intent = MessageIntent.Warning; - options.Section = MessageBarSection; + options.Section = DashboardUIHelpers.MessageBarSection; options.AllowDismiss = true; options.OnClose = async m => { diff --git a/src/Aspire.Dashboard/Model/InteractionsInputsDialogViewModel.cs b/src/Aspire.Dashboard/Model/InteractionsInputsDialogViewModel.cs new file mode 100644 index 00000000000..23cd8e19ba6 --- /dev/null +++ b/src/Aspire.Dashboard/Model/InteractionsInputsDialogViewModel.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.ResourceService.Proto.V1; + +namespace Aspire.Dashboard.Model; + +public sealed class InteractionsInputsDialogViewModel +{ + public required WatchInteractionsResponseUpdate Interaction { get; init; } + public required List Inputs { get; init; } +} diff --git a/src/Aspire.Dashboard/ResourceService/DashboardClient.cs b/src/Aspire.Dashboard/ResourceService/DashboardClient.cs index 207f5277718..1ec32581190 100644 --- a/src/Aspire.Dashboard/ResourceService/DashboardClient.cs +++ b/src/Aspire.Dashboard/ResourceService/DashboardClient.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Net.Security; using System.Runtime.CompilerServices; @@ -40,10 +41,12 @@ internal sealed class DashboardClient : IDashboardClient private const string ApiKeyHeaderName = "x-resource-service-api-key"; private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); + private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; private readonly TaskCompletionSource _whenConnectedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _initialDataReceivedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Channel _incomingInteractionChannel = Channel.CreateUnbounded(); private readonly object _lock = new(); private readonly ILoggerFactory _loggerFactory; @@ -51,7 +54,8 @@ internal sealed class DashboardClient : IDashboardClient private readonly DashboardOptions _dashboardOptions; private readonly ILogger _logger; - private ImmutableHashSet>> _outgoingChannels = []; + private ImmutableHashSet>> _outgoingResourceChannels = []; + private ImmutableHashSet> _outgoingInteractionChannels = []; private string? _applicationName; private const int StateDisabled = -1; @@ -210,7 +214,7 @@ internal sealed class KeyStoreProperties } // For testing purposes - internal int OutgoingResourceSubscriberCount => _outgoingChannels.Count; + internal int OutgoingResourceSubscriberCount => _outgoingResourceChannels.Count; public bool IsEnabled => _state is not StateDisabled; @@ -229,175 +233,253 @@ private void EnsureInitialized() return; } - _connection = Task.Run(() => ConnectAndWatchResourcesAsync(_clientCancellationToken), _clientCancellationToken); + _connection = Task.Run(() => ConnectAndWatchAsync(_clientCancellationToken), _clientCancellationToken); + } + + async Task ConnectAndWatchAsync(CancellationToken cancellationToken) + { + try + { + await ConnectAsync().ConfigureAwait(false); - return; + await Task.WhenAll( + Task.Run(() => WatchWithRecoveryAsync(cancellationToken, WatchResourcesAsync), cancellationToken), + Task.Run(() => WatchWithRecoveryAsync(cancellationToken, WatchInteractionsAsync), cancellationToken)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Ignore. This is likely caused by the dashboard client being disposed. We don't want to log. + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading data from the resource service."); + throw; + } - async Task ConnectAndWatchResourcesAsync(CancellationToken cancellationToken) + async Task ConnectAsync() { try { - await ConnectAsync().ConfigureAwait(false); + var response = await _client!.GetApplicationInformationAsync(new(), headers: _headers, cancellationToken: cancellationToken); - await WatchResourcesWithRecoveryAsync().ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Ignore. This is likely caused by the dashboard client being disposed. We don't want to log. + _applicationName = response.ApplicationName; + + _whenConnectedTcs.TrySetResult(); } catch (Exception ex) { - _logger.LogError(ex, "Error loading data from the resource service."); - throw; + _whenConnectedTcs.TrySetException(ex); } + } + } - async Task ConnectAsync() + private class RetryContext + { + public int ErrorCount { get; set; } + } + + private async Task WatchWithRecoveryAsync(CancellationToken cancellationToken, Func action) + { + // Track the number of errors we've seen since the last successfully received message. + // As this number climbs, we extend the amount of time between reconnection attempts, in + // order to avoid flooding the server with requests. This value is reset to zero whenever + // a message is successfully received. + var retryContext = new RetryContext(); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (retryContext.ErrorCount > 0) { - try - { - var response = await _client!.GetApplicationInformationAsync(new(), headers: _headers, cancellationToken: cancellationToken); + // The most recent attempt failed. There may be more than one failure. + // We wait for a period of time determined by the number of errors, + // where the time grows exponentially, until a threshold. + var delay = ExponentialBackOff(retryContext.ErrorCount, maxSeconds: 15); - _applicationName = response.ApplicationName; + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } - _whenConnectedTcs.TrySetResult(); - } - catch (Exception ex) - { - _whenConnectedTcs.TrySetException(ex); - } + try + { + await action(retryContext, cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + // There's a race condition between reconnect attempts and client disposal. + // This has been observed in unit tests where the client is created and disposed + // very quickly. This check should probably be in the gRPC library instead. } + catch (RpcException ex) + { + retryContext.ErrorCount++; + + _logger.LogError(ex, "Error #{ErrorCount} watching resources.", retryContext.ErrorCount); + } + } + + static TimeSpan ExponentialBackOff(int errorCount, double maxSeconds) + { + return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, errorCount - 1), maxSeconds)); + } + } + + private async Task WatchResourcesAsync(RetryContext retryContext, CancellationToken cancellationToken) + { + var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = retryContext.ErrorCount != 0 }, headers: _headers, cancellationToken: cancellationToken); + + await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + List? changes = null; - async Task WatchResourcesWithRecoveryAsync() + lock (_lock) { - // Track the number of errors we've seen since the last successfully received message. - // As this number climbs, we extend the amount of time between reconnection attempts, in - // order to avoid flooding the server with requests. This value is reset to zero whenever - // a message is successfully received. - var errorCount = 0; + // We received a message, which means we are connected. Clear the error count. + retryContext.ErrorCount = 0; - while (true) + if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) { - cancellationToken.ThrowIfCancellationRequested(); + // Populate our map using the initial data. + _resourceByName.Clear(); - if (errorCount > 0) - { - // The most recent attempt failed. There may be more than one failure. - // We wait for a period of time determined by the number of errors, - // where the time grows exponentially, until a threshold. - var delay = ExponentialBackOff(errorCount, maxSeconds: 15); - - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - } + // TODO send a "clear" event via outgoing channels, in case consumers have extra items to be removed - try + foreach (var resource in response.InitialData.Resources) { - await WatchResourcesAsync().ConfigureAwait(false); - } - catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) - { - // There's a race condition between reconnect attempts and client disposal. - // This has been observed in unit tests where the client is created and disposed - // very quickly. This check should probably be in the gRPC library instead. - } - catch (RpcException ex) - { - errorCount++; + // Add to map. + var viewModel = resource.ToViewModel(_knownPropertyLookup, _logger); + _resourceByName[resource.Name] = viewModel; - _logger.LogError(ex, "Error #{ErrorCount} watching resources.", errorCount); + // Send this update to any subscribers too. + changes ??= []; + changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); } - } - static TimeSpan ExponentialBackOff(int errorCount, double maxSeconds) - { - return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, errorCount - 1), maxSeconds)); + _initialDataReceivedTcs.TrySetResult(); } - - async Task WatchResourcesAsync() + else if (response.KindCase == WatchResourcesUpdate.KindOneofCase.Changes) { - var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = errorCount != 0 }, headers: _headers, cancellationToken: cancellationToken); - - await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + // Apply changes to the model. + foreach (var change in response.Changes.Value) { - List? changes = null; + changes ??= []; - lock (_lock) + if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) { - // We received a message, which means we are connected. Clear the error count. - errorCount = 0; - - if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) - { - // Populate our map using the initial data. - _resourceByName.Clear(); - - // TODO send a "clear" event via outgoing channels, in case consumers have extra items to be removed - - foreach (var resource in response.InitialData.Resources) - { - // Add to map. - var viewModel = resource.ToViewModel(_knownPropertyLookup, _logger); - _resourceByName[resource.Name] = viewModel; - - // Send this update to any subscribers too. - changes ??= []; - changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); - } - - _initialDataReceivedTcs.TrySetResult(); - } - else if (response.KindCase == WatchResourcesUpdate.KindOneofCase.Changes) + // Upsert (i.e. add or replace) + var viewModel = change.Upsert.ToViewModel(_knownPropertyLookup, _logger); + _resourceByName[change.Upsert.Name] = viewModel; + changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); + } + else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete) + { + // Remove + if (_resourceByName.Remove(change.Delete.ResourceName, out var removed)) { - // Apply changes to the model. - foreach (var change in response.Changes.Value) - { - changes ??= []; - - if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) - { - // Upsert (i.e. add or replace) - var viewModel = change.Upsert.ToViewModel(_knownPropertyLookup, _logger); - _resourceByName[change.Upsert.Name] = viewModel; - changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); - } - else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete) - { - // Remove - if (_resourceByName.Remove(change.Delete.ResourceName, out var removed)) - { - changes.Add(new(ResourceViewModelChangeType.Delete, removed)); - } - else - { - Debug.Fail("Attempt to remove an unknown resource view model."); - } - } - else - { - throw new FormatException($"Unexpected {nameof(WatchResourcesChange)} kind: {change.KindCase}"); - } - } + changes.Add(new(ResourceViewModelChangeType.Delete, removed)); } else { - throw new FormatException($"Unexpected {nameof(WatchResourcesUpdate)} kind: {response.KindCase}"); + Debug.Fail("Attempt to remove an unknown resource view model."); } } + else + { + throw new FormatException($"Unexpected {nameof(WatchResourcesChange)} kind: {change.KindCase}"); + } + } + } + else + { + throw new FormatException($"Unexpected {nameof(WatchResourcesUpdate)} kind: {response.KindCase}"); + } + } - if (changes is not null) + if (changes is not null) + { + foreach (var channel in _outgoingResourceChannels) + { + // Channel is unbound so TryWrite always succeeds. + channel.Writer.TryWrite(changes); + } + } + } + } + + private async Task WatchInteractionsAsync(RetryContext retryContext, CancellationToken cancellationToken) + { + // Create the watch interactions call. This is a bidirectional streaming call. + // Responses are streamed out to all watchers. Requests are sent from the incoming interaction channel. + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var call = _client!.WatchInteractions(headers: _headers, cancellationToken: cts.Token); + + // Send + _ = Task.Run(async () => + { + try + { + await foreach (var update in _incomingInteractionChannel.Reader.ReadAllAsync(cts.Token).ConfigureAwait(false)) + { + await call.RequestStream.WriteAsync(update).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Error writing to interaction request stream."); + } + finally + { + // Cancel the call if we can't write to it. + // Most likely reading from the response stream has already failed but force cancellation and the interaction call is retry just in case. + cts.Cancel(); + } + }, cts.Token); + + // Receive + try + { + await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cts.Token).ConfigureAwait(false)) + { + // We received a message, which means we are connected. Clear the error count. + retryContext.ErrorCount = 0; + + lock (_lock) + { + if (response.Complete != null) + { + // Interaction finished. Remove from pending collection. + _pendingInteractionCollection.Remove(response.InteractionId); + } + else + { + if (_pendingInteractionCollection.Contains(response.InteractionId)) { - foreach (var channel in _outgoingChannels) - { - // Channel is unbound so TryWrite always succeeds. - channel.Writer.TryWrite(changes); - } + _pendingInteractionCollection.Remove(response.InteractionId); } + _pendingInteractionCollection.Add(response); } } + + foreach (var channel in _outgoingInteractionChannels) + { + // Channel is unbound so TryWrite always succeeds. + channel.Writer.TryWrite(response); + } } } + finally + { + // Ensure the write task is cancelled if we exit the loop. + cts.Cancel(); + } } - Task IDashboardClient.WhenConnected + public async Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) + { + await _incomingInteractionChannel.Writer.WriteAsync(request, cancellationToken).ConfigureAwait(false); + } + + public Task WhenConnected { get { @@ -409,14 +491,14 @@ Task IDashboardClient.WhenConnected } } - string IDashboardClient.ApplicationName + public string ApplicationName { get => _applicationName ?? _dashboardOptions.ApplicationName ?? "Aspire"; } - async Task IDashboardClient.SubscribeResourcesAsync(CancellationToken cancellationToken) + public async Task SubscribeResourcesAsync(CancellationToken cancellationToken) { EnsureInitialized(); @@ -433,7 +515,7 @@ async Task IDashboardClient.SubscribeResourcesAsy lock (_lock) { - ImmutableInterlocked.Update(ref _outgoingChannels, static (set, channel) => set.Add(channel), channel); + ImmutableInterlocked.Update(ref _outgoingResourceChannels, static (set, channel) => set.Add(channel), channel); return new ResourceViewModelSubscription( InitialState: _resourceByName.Values.ToImmutableArray(), @@ -459,15 +541,57 @@ async IAsyncEnumerable> StreamUpdatesAsyn finally { cts.Dispose(); - ImmutableInterlocked.Update(ref _outgoingChannels, static (set, channel) => set.Remove(channel), channel); + ImmutableInterlocked.Update(ref _outgoingResourceChannels, static (set, channel) => set.Remove(channel), channel); + } + } + } + + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) + { + EnsureInitialized(); + + var cts = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); + + // There are two types of channel in this class. This is not a gRPC channel. + // It's a producer-consumer queue channel, used to push updates to subscribers + // without blocking the producer here. + var channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { AllowSynchronousContinuations = false, SingleReader = true, SingleWriter = true }); + + lock (_lock) + { + ImmutableInterlocked.Update(ref _outgoingInteractionChannels, static (set, channel) => set.Add(channel), channel); + + return StreamUpdatesAsync(_pendingInteractionCollection.ToList(), cts.Token); + } + + async IAsyncEnumerable StreamUpdatesAsync(List pendingInteractions, [EnumeratorCancellation] CancellationToken enumeratorCancellationToken = default) + { + try + { + foreach (var item in pendingInteractions) + { + yield return item; + } + + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken: enumeratorCancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + finally + { + cts.Dispose(); + ImmutableInterlocked.Update(ref _outgoingInteractionChannels, static (set, channel) => set.Remove(channel), channel); } } } - async IAsyncEnumerable> IDashboardClient.SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { EnsureInitialized(); + // It's ok to dispose CTS with using because this method exits after it is finished being used. using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); var call = _client!.WatchResourceConsoleLogs( @@ -511,7 +635,7 @@ async IAsyncEnumerable> IDashboardClient.Subscrib await readTask.ConfigureAwait(false); } - async IAsyncEnumerable> IDashboardClient.GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) + public async IAsyncEnumerable> GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { EnsureInitialized(); @@ -578,7 +702,7 @@ public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _state, StateDisposed) is not StateDisposed) { - _outgoingChannels = []; + _outgoingResourceChannels = []; await _cts.CancelAsync().ConfigureAwait(false); @@ -607,4 +731,9 @@ internal void SetInitialDataReceived(IList? initialData = null) _initialDataReceivedTcs.TrySetResult(); } + + private class InteractionCollection : KeyedCollection + { + protected override int GetKeyForItem(WatchInteractionsResponseUpdate item) => item.InteractionId; + } } diff --git a/src/Aspire.Dashboard/ResourceService/IDashboardClient.cs b/src/Aspire.Dashboard/ResourceService/IDashboardClient.cs index 4e0afe82b86..4fe11b4d08a 100644 --- a/src/Aspire.Dashboard/ResourceService/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ResourceService/IDashboardClient.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Aspire.ResourceService.Proto.V1; namespace Aspire.Dashboard.Model; @@ -39,6 +40,10 @@ public interface IDashboardClient : IAsyncDisposable /// Task SubscribeResourcesAsync(CancellationToken cancellationToken); + IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken); + + Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken); + /// /// Gets a stream of console log messages for the specified resource. /// Includes messages logged both before and after this method call. diff --git a/src/Aspire.Dashboard/ResourceService/Partials.cs b/src/Aspire.Dashboard/ResourceService/Partials.cs index 54b3797b3b8..cf40d14ad85 100644 --- a/src/Aspire.Dashboard/ResourceService/Partials.cs +++ b/src/Aspire.Dashboard/ResourceService/Partials.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Resources; using Aspire.Hosting; using Google.Protobuf.Collections; +using System.Globalization; namespace Aspire.ResourceService.Proto.V1; @@ -181,3 +182,20 @@ public ResourceCommandResponseViewModel ToViewModel() }; } } + +partial class InteractionInput +{ + // Used when binding to FluentCheckbox in the dashboard. + public bool IsChecked + { + get => bool.TryParse(Value, out var result) && result; + set => Value = value ? "true" : "false"; + } + + // Used when binding to FluentNumberField in the dashboard. + public int? NumberValue + { + get => int.TryParse(Value, CultureInfo.InvariantCulture, out var result) ? result : null; + set => Value = value?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + } +} diff --git a/src/Aspire.Dashboard/Resources/ControlsStrings.resx b/src/Aspire.Dashboard/Resources/ControlsStrings.resx index 7ec48cb6cc2..bb2b4f439e4 100644 --- a/src/Aspire.Dashboard/Resources/ControlsStrings.resx +++ b/src/Aspire.Dashboard/Resources/ControlsStrings.resx @@ -1,17 +1,17 @@ - @@ -123,7 +123,6 @@ Filters - Graph @@ -240,7 +239,6 @@ Time - Only show value updates @@ -328,7 +326,6 @@ Resource - Context @@ -395,7 +392,6 @@ Set column widths - (Empty) @@ -483,4 +479,4 @@ Hide hidden resources - + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index 86281136d20..00f991f9d0d 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1,6 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -11,32 +12,46 @@ namespace Aspire.Dashboard.Resources { using System; - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] - [System.Diagnostics.DebuggerNonUserCodeAttribute()] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Dialogs { - private static System.Resources.ResourceManager resourceMan; + private static global::System.Resources.ResourceManager resourceMan; - private static System.Globalization.CultureInfo resourceCulture; + private static global::System.Globalization.CultureInfo resourceCulture; - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Dialogs() { } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - public static System.Resources.ResourceManager ResourceManager { + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { get { - if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Dashboard.Resources.Dialogs", typeof(Dialogs).Assembly); + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Dashboard.Resources.Dialogs", typeof(Dialogs).Assembly); resourceMan = temp; } return resourceMan; } } - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - public static System.Globalization.CultureInfo Culture { + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -45,353 +60,552 @@ public static System.Globalization.CultureInfo Culture { } } - public static string FilterDialogFieldPlaceholder { + /// + /// Looks up a localized string similar to Close. + /// + public static string DialogCloseButtonText { get { - return ResourceManager.GetString("FilterDialogFieldPlaceholder", resourceCulture); + return ResourceManager.GetString("DialogCloseButtonText", resourceCulture); } } - public static string FilterDialogTextValuePlaceholder { + /// + /// Looks up a localized string similar to Details. + /// + public static string ExemplarsDialogDetailsColumnHeader { get { - return ResourceManager.GetString("FilterDialogTextValuePlaceholder", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogDetailsColumnHeader", resourceCulture); } } - public static string FilterDialogCancelButtonText { + /// + /// Looks up a localized string similar to Timestamp. + /// + public static string ExemplarsDialogTimestampColumnHeader { get { - return ResourceManager.GetString("FilterDialogCancelButtonText", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogTimestampColumnHeader", resourceCulture); } } - public static string FilterDialogApplyFilterButtonText { + /// + /// Looks up a localized string similar to Exemplars. + /// + public static string ExemplarsDialogTitle { get { - return ResourceManager.GetString("FilterDialogApplyFilterButtonText", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogTitle", resourceCulture); } } - - public static string FilterDialogDisableFilterButtonText { + /// + /// Looks up a localized string similar to Trace. + /// + public static string ExemplarsDialogTrace { get { - return ResourceManager.GetString("FilterDialogDisableFilterButtonText", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogTrace", resourceCulture); } } - public static string FilterDialogRemoveFilterButtonText { + /// + /// Looks up a localized string similar to Trace. + /// + public static string ExemplarsDialogTraceColumnHeader { get { - return ResourceManager.GetString("FilterDialogRemoveFilterButtonText", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogTraceColumnHeader", resourceCulture); } } - public static string SettingsDialogSystemTheme { + /// + /// Looks up a localized string similar to Value. + /// + public static string ExemplarsDialogValueColumnHeader { get { - return ResourceManager.GetString("SettingsDialogSystemTheme", resourceCulture); + return ResourceManager.GetString("ExemplarsDialogValueColumnHeader", resourceCulture); } } - public static string SettingsDialogLightTheme { + /// + /// Looks up a localized string similar to A value is required.. + /// + public static string FieldRequired { get { - return ResourceManager.GetString("SettingsDialogLightTheme", resourceCulture); + return ResourceManager.GetString("FieldRequired", resourceCulture); } } - public static string SettingsDialogDarkTheme { + /// + /// Looks up a localized string similar to A maximum length of {1} characters is allowed.. + /// + public static string FieldTooLong { get { - return ResourceManager.GetString("SettingsDialogDarkTheme", resourceCulture); + return ResourceManager.GetString("FieldTooLong", resourceCulture); } } - public static string SettingsDialogTheme { + /// + /// Looks up a localized string similar to Apply filter. + /// + public static string FilterDialogApplyFilterButtonText { get { - return ResourceManager.GetString("SettingsDialogTheme", resourceCulture); + return ResourceManager.GetString("FilterDialogApplyFilterButtonText", resourceCulture); } } - public static string SettingsDialogVersion { + /// + /// Looks up a localized string similar to Cancel. + /// + public static string FilterDialogCancelButtonText { get { - return ResourceManager.GetString("SettingsDialogVersion", resourceCulture); + return ResourceManager.GetString("FilterDialogCancelButtonText", resourceCulture); } } - public static string SettingsDialogLanguage { + /// + /// Looks up a localized string similar to Condition. + /// + public static string FilterDialogConditionInputLabel { get { - return ResourceManager.GetString("SettingsDialogLanguage", resourceCulture); + return ResourceManager.GetString("FilterDialogConditionInputLabel", resourceCulture); } } - public static string SettingsDialogLanguagePageReloads { + /// + /// Looks up a localized string similar to Disable all. + /// + public static string FilterDialogDisableAll { get { - return ResourceManager.GetString("SettingsDialogLanguagePageReloads", resourceCulture); + return ResourceManager.GetString("FilterDialogDisableAll", resourceCulture); } } - public static string FilterDialogParameterInputLabel { + /// + /// Looks up a localized string similar to Disable filter. + /// + public static string FilterDialogDisableFilterButtonText { get { - return ResourceManager.GetString("FilterDialogParameterInputLabel", resourceCulture); + return ResourceManager.GetString("FilterDialogDisableFilterButtonText", resourceCulture); } } - public static string FilterDialogConditionInputLabel { + /// + /// Looks up a localized string similar to Enable all. + /// + public static string FilterDialogEnableAll { get { - return ResourceManager.GetString("FilterDialogConditionInputLabel", resourceCulture); + return ResourceManager.GetString("FilterDialogEnableAll", resourceCulture); } } - public static string HelpDialogGetHelpLinkText { + /// + /// Looks up a localized string similar to Field. + /// + public static string FilterDialogFieldPlaceholder { get { - return ResourceManager.GetString("HelpDialogGetHelpLinkText", resourceCulture); + return ResourceManager.GetString("FilterDialogFieldPlaceholder", resourceCulture); } } - public static string HelpDialogCategoryPanels { + /// + /// Looks up a localized string similar to Parameter. + /// + public static string FilterDialogParameterInputLabel { get { - return ResourceManager.GetString("HelpDialogCategoryPanels", resourceCulture); + return ResourceManager.GetString("FilterDialogParameterInputLabel", resourceCulture); } } - public static string HelpDialogCategoryPageNavigation { + /// + /// Looks up a localized string similar to Remove filter. + /// + public static string FilterDialogRemoveFilterButtonText { get { - return ResourceManager.GetString("HelpDialogCategoryPageNavigation", resourceCulture); + return ResourceManager.GetString("FilterDialogRemoveFilterButtonText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Value. + /// + public static string FilterDialogTextValuePlaceholder { + get { + return ResourceManager.GetString("FilterDialogTextValuePlaceholder", resourceCulture); } } + /// + /// Looks up a localized string similar to Site-wide navigation. + /// public static string HelpDialogCategoryNavigation { get { return ResourceManager.GetString("HelpDialogCategoryNavigation", resourceCulture); } } - public static string HelpDialogIncreasePanelSize { + /// + /// Looks up a localized string similar to Page navigation. + /// + public static string HelpDialogCategoryPageNavigation { get { - return ResourceManager.GetString("HelpDialogIncreasePanelSize", resourceCulture); + return ResourceManager.GetString("HelpDialogCategoryPageNavigation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Panels. + /// + public static string HelpDialogCategoryPanels { + get { + return ResourceManager.GetString("HelpDialogCategoryPanels", resourceCulture); } } + /// + /// Looks up a localized string similar to Decrease panel size. + /// public static string HelpDialogDecreasePanelSize { get { return ResourceManager.GetString("HelpDialogDecreasePanelSize", resourceCulture); } } - public static string HelpDialogResetPanelSize { + /// + /// Looks up a localized string similar to Go to Microsoft Learn documentation. + /// + public static string HelpDialogGetHelpLinkText { get { - return ResourceManager.GetString("HelpDialogResetPanelSize", resourceCulture); + return ResourceManager.GetString("HelpDialogGetHelpLinkText", resourceCulture); } } - public static string HelpDialogTogglePanelOrientation { + /// + /// Looks up a localized string similar to Go to Console Logs. + /// + public static string HelpDialogGoToConsoleLogs { get { - return ResourceManager.GetString("HelpDialogTogglePanelOrientation", resourceCulture); + return ResourceManager.GetString("HelpDialogGoToConsoleLogs", resourceCulture); } } - public static string HelpDialogTogglePanelOpen { + /// + /// Looks up a localized string similar to Go to Help. + /// + public static string HelpDialogGoToHelp { get { - return ResourceManager.GetString("HelpDialogTogglePanelOpen", resourceCulture); + return ResourceManager.GetString("HelpDialogGoToHelp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Go to Metrics. + /// + public static string HelpDialogGoToMetrics { + get { + return ResourceManager.GetString("HelpDialogGoToMetrics", resourceCulture); } } + /// + /// Looks up a localized string similar to Go to Resources. + /// public static string HelpDialogGoToResources { get { return ResourceManager.GetString("HelpDialogGoToResources", resourceCulture); } } - public static string HelpDialogGoToConsoleLogs { + /// + /// Looks up a localized string similar to Go to Settings. + /// + public static string HelpDialogGoToSettings { get { - return ResourceManager.GetString("HelpDialogGoToConsoleLogs", resourceCulture); + return ResourceManager.GetString("HelpDialogGoToSettings", resourceCulture); } } + /// + /// Looks up a localized string similar to Go to Structured Logs. + /// public static string HelpDialogGoToStructuredLogs { get { return ResourceManager.GetString("HelpDialogGoToStructuredLogs", resourceCulture); } } + /// + /// Looks up a localized string similar to Go to Traces. + /// public static string HelpDialogGoToTraces { get { return ResourceManager.GetString("HelpDialogGoToTraces", resourceCulture); } } - public static string HelpDialogGoToMetrics { + /// + /// Looks up a localized string similar to Increase panel size. + /// + public static string HelpDialogIncreasePanelSize { get { - return ResourceManager.GetString("HelpDialogGoToMetrics", resourceCulture); + return ResourceManager.GetString("HelpDialogIncreasePanelSize", resourceCulture); } } - public static string HelpDialogGoToHelp { + /// + /// Looks up a localized string similar to Keyboard Shortcuts. + /// + public static string HelpDialogKeyboardShortcutsTitle { get { - return ResourceManager.GetString("HelpDialogGoToHelp", resourceCulture); + return ResourceManager.GetString("HelpDialogKeyboardShortcutsTitle", resourceCulture); } } - public static string HelpDialogGoToSettings { + /// + /// Looks up a localized string similar to Reset panel sizes. + /// + public static string HelpDialogResetPanelSize { get { - return ResourceManager.GetString("HelpDialogGoToSettings", resourceCulture); + return ResourceManager.GetString("HelpDialogResetPanelSize", resourceCulture); } } - public static string HelpDialogKeyboardShortcutsTitle { + /// + /// Looks up a localized string similar to Close panel. + /// + public static string HelpDialogTogglePanelOpen { get { - return ResourceManager.GetString("HelpDialogKeyboardShortcutsTitle", resourceCulture); + return ResourceManager.GetString("HelpDialogTogglePanelOpen", resourceCulture); } } - public static string DialogCloseButtonText { + /// + /// Looks up a localized string similar to Toggle panel orientation. + /// + public static string HelpDialogTogglePanelOrientation { get { - return ResourceManager.GetString("DialogCloseButtonText", resourceCulture); + return ResourceManager.GetString("HelpDialogTogglePanelOrientation", resourceCulture); } } - public static string ExemplarsDialogTitle { + /// + /// Looks up a localized string similar to Cancel. + /// + public static string InteractionButtonCancel { get { - return ResourceManager.GetString("ExemplarsDialogTitle", resourceCulture); + return ResourceManager.GetString("InteractionButtonCancel", resourceCulture); } } - public static string ExemplarsDialogTraceColumnHeader { + /// + /// Looks up a localized string similar to Close. + /// + public static string InteractionButtonClose { get { - return ResourceManager.GetString("ExemplarsDialogTraceColumnHeader", resourceCulture); + return ResourceManager.GetString("InteractionButtonClose", resourceCulture); } } - public static string ExemplarsDialogTimestampColumnHeader { + /// + /// Looks up a localized string similar to OK. + /// + public static string InteractionButtonOk { get { - return ResourceManager.GetString("ExemplarsDialogTimestampColumnHeader", resourceCulture); + return ResourceManager.GetString("InteractionButtonOk", resourceCulture); } } - public static string ExemplarsDialogValueColumnHeader { + /// + /// Looks up a localized string similar to Open in text visualizer. + /// + public static string OpenInTextVisualizer { get { - return ResourceManager.GetString("ExemplarsDialogValueColumnHeader", resourceCulture); + return ResourceManager.GetString("OpenInTextVisualizer", resourceCulture); } } - public static string ExemplarsDialogDetailsColumnHeader { + /// + /// Looks up a localized string similar to Cancel. + /// + public static string OpenTraceDialogCancelButtonText { get { - return ResourceManager.GetString("ExemplarsDialogDetailsColumnHeader", resourceCulture); + return ResourceManager.GetString("OpenTraceDialogCancelButtonText", resourceCulture); } } - public static string ExemplarsDialogTrace { + /// + /// Looks up a localized string similar to Waiting for trace {0} to load.... + /// + public static string OpenTraceDialogMessage { get { - return ResourceManager.GetString("ExemplarsDialogTrace", resourceCulture); + return ResourceManager.GetString("OpenTraceDialogMessage", resourceCulture); } } - public static string OpenTraceDialogMessage { + /// + /// Looks up a localized string similar to Dark. + /// + public static string SettingsDialogDarkTheme { get { - return ResourceManager.GetString("OpenTraceDialogMessage", resourceCulture); + return ResourceManager.GetString("SettingsDialogDarkTheme", resourceCulture); } } - public static string OpenTraceDialogCancelButtonText { + /// + /// Looks up a localized string similar to Resource logs and telemetry. + /// + public static string SettingsDialogDashboardLogsAndTelemetry { get { - return ResourceManager.GetString("OpenTraceDialogCancelButtonText", resourceCulture); + return ResourceManager.GetString("SettingsDialogDashboardLogsAndTelemetry", resourceCulture); } } - public static string TextVisualizerDialogPlaintextFormat { + /// + /// Looks up a localized string similar to Language. + /// + public static string SettingsDialogLanguage { get { - return ResourceManager.GetString("TextVisualizerDialogPlaintextFormat", resourceCulture); + return ResourceManager.GetString("SettingsDialogLanguage", resourceCulture); } } - public static string TextVisualizerDialogJsonFormat { + /// + /// Looks up a localized string similar to The page will reload on language change.. + /// + public static string SettingsDialogLanguagePageReloads { get { - return ResourceManager.GetString("TextVisualizerDialogJsonFormat", resourceCulture); + return ResourceManager.GetString("SettingsDialogLanguagePageReloads", resourceCulture); } } - public static string TextVisualizerDialogXmlFormat { + /// + /// Looks up a localized string similar to Light. + /// + public static string SettingsDialogLightTheme { get { - return ResourceManager.GetString("TextVisualizerDialogXmlFormat", resourceCulture); + return ResourceManager.GetString("SettingsDialogLightTheme", resourceCulture); } } - public static string TextVisualizerSelectFormatType { + /// + /// Looks up a localized string similar to System. + /// + public static string SettingsDialogSystemTheme { get { - return ResourceManager.GetString("TextVisualizerSelectFormatType", resourceCulture); + return ResourceManager.GetString("SettingsDialogSystemTheme", resourceCulture); } } - public static string OpenInTextVisualizer { + /// + /// Looks up a localized string similar to Dashboard telemetry is enabled. Aspire will collect usage data and send it to Microsoft.. + /// + public static string SettingsDialogTelemetryEnabledInfo { get { - return ResourceManager.GetString("OpenInTextVisualizer", resourceCulture); + return ResourceManager.GetString("SettingsDialogTelemetryEnabledInfo", resourceCulture); } } - public static string FieldRequired { + /// + /// Looks up a localized string similar to Why?. + /// + public static string SettingsDialogTelemetryInfoLinkText { get { - return ResourceManager.GetString("FieldRequired", resourceCulture); + return ResourceManager.GetString("SettingsDialogTelemetryInfoLinkText", resourceCulture); } } - public static string FieldTooLong { + /// + /// Looks up a localized string similar to Go to usage telemetry documentation. + /// + public static string SettingsDialogTelemetryInfoLinkTooltip { get { - return ResourceManager.GetString("FieldTooLong", resourceCulture); + return ResourceManager.GetString("SettingsDialogTelemetryInfoLinkTooltip", resourceCulture); } } - public static string SettingsDialogDashboardLogsAndTelemetry { + /// + /// Looks up a localized string similar to Theme. + /// + public static string SettingsDialogTheme { get { - return ResourceManager.GetString("SettingsDialogDashboardLogsAndTelemetry", resourceCulture); + return ResourceManager.GetString("SettingsDialogTheme", resourceCulture); } } - public static string SettingsRemoveAllButtonText { + /// + /// Looks up a localized string similar to Version: {0}. + /// + public static string SettingsDialogVersion { get { - return ResourceManager.GetString("SettingsRemoveAllButtonText", resourceCulture); + return ResourceManager.GetString("SettingsDialogVersion", resourceCulture); } } - public static string TextVisualizerSecretWarningTitle { + /// + /// Looks up a localized string similar to Remove all. + /// + public static string SettingsRemoveAllButtonText { get { - return ResourceManager.GetString("TextVisualizerSecretWarningTitle", resourceCulture); + return ResourceManager.GetString("SettingsRemoveAllButtonText", resourceCulture); } } - public static string TextVisualizerSecretWarningDescription { + /// + /// Looks up a localized string similar to Format JSON. + /// + public static string TextVisualizerDialogJsonFormat { get { - return ResourceManager.GetString("TextVisualizerSecretWarningDescription", resourceCulture); + return ResourceManager.GetString("TextVisualizerDialogJsonFormat", resourceCulture); } } - public static string TextVisualizerSecretWarningAcknowledge { + /// + /// Looks up a localized string similar to Unformatted. + /// + public static string TextVisualizerDialogPlaintextFormat { get { - return ResourceManager.GetString("TextVisualizerSecretWarningAcknowledge", resourceCulture); + return ResourceManager.GetString("TextVisualizerDialogPlaintextFormat", resourceCulture); } } - public static string FilterDialogDisableAll { + /// + /// Looks up a localized string similar to Format XML. + /// + public static string TextVisualizerDialogXmlFormat { get { - return ResourceManager.GetString("FilterDialogDisableAll", resourceCulture); + return ResourceManager.GetString("TextVisualizerDialogXmlFormat", resourceCulture); } } - public static string FilterDialogEnableAll { + /// + /// Looks up a localized string similar to Show value. + /// + public static string TextVisualizerSecretWarningAcknowledge { get { - return ResourceManager.GetString("FilterDialogEnableAll", resourceCulture); + return ResourceManager.GetString("TextVisualizerSecretWarningAcknowledge", resourceCulture); } } - - public static string SettingsDialogTelemetryEnabledInfo { + /// + /// Looks up a localized string similar to Confirm you want to show the value. In the future, opening the text visualizer will automatically display all values. + /// + public static string TextVisualizerSecretWarningDescription { get { - return ResourceManager.GetString("SettingsDialogTelemetryEnabledInfo", resourceCulture); + return ResourceManager.GetString("TextVisualizerSecretWarningDescription", resourceCulture); } } - public static string SettingsDialogTelemetryInfoLinkText { + /// + /// Looks up a localized string similar to Sensitive value hidden. + /// + public static string TextVisualizerSecretWarningTitle { get { - return ResourceManager.GetString("SettingsDialogTelemetryInfoLinkText", resourceCulture); + return ResourceManager.GetString("TextVisualizerSecretWarningTitle", resourceCulture); } } - public static string SettingsDialogTelemetryInfoLinkTooltip { + /// + /// Looks up a localized string similar to Select format. + /// + public static string TextVisualizerSelectFormatType { get { - return ResourceManager.GetString("SettingsDialogTelemetryInfoLinkTooltip", resourceCulture); + return ResourceManager.GetString("TextVisualizerSelectFormatType", resourceCulture); } } } diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 1356e720b3d..ed4c745ce25 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -1,17 +1,17 @@ - @@ -295,4 +295,13 @@ Go to usage telemetry documentation - + + OK + + + Cancel + + + Close + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 314e84aaf9b..5aba91cd777 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -182,6 +182,21 @@ Přepnout orientaci panelu + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Otevřít ve vizualizéru textu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 10e76e065ce..558bed93b40 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -182,6 +182,21 @@ Bereichsausrichtung umschalten + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer In Textschnellansicht öffnen diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 2f2d53b0010..d061e2fe69b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -182,6 +182,21 @@ Alternar orientación del panel + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Abrir en visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index e5d23b1670b..19611e18478 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -182,6 +182,21 @@ Activer/désactiver l’orientation du panneau + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Ouvrir dans le visualiseur de texte diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index 0c1e996944e..530a9c8b82d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -182,6 +182,21 @@ Attiva/Disattiva orientamento del pannello + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Apri nel visualizzatore di testo diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index fe0686fbcd2..69176585151 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -182,6 +182,21 @@ パネルの向きを切り替える + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer テキスト ビジュアライザーで開く diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index 8473868a38e..1ecf070685b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -182,6 +182,21 @@ 패널 방향 전환 + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer 텍스트 시각화 도우미에서 열기 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 27959f9b672..00f5ef98318 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -182,6 +182,21 @@ Przełącz orientację panelu + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Otwórz w wizualizatorze tekstu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index d44ff1e8252..292af6eb7d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -182,6 +182,21 @@ Alternar orientação do painel + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Abrir no visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index e1dac16d299..5e4e1d90178 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -182,6 +182,21 @@ Переключить ориентацию панели + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Открыть в визуализаторе текста diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 1012a17b385..c11cca8400e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -182,6 +182,21 @@ Panelin yönünü değiştir + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer Metin görselleştiricide aç diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index f13e1bb7a62..d7f72ca2eba 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -182,6 +182,21 @@ 切换面板方向 + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer 在文本可视化工具中打开 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index f6e6640e5f7..46a4d4ed659 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -182,6 +182,21 @@ 切換面板方向 + + Cancel + Cancel + + + + Close + Close + + + + OK + OK + + Open in text visualizer 在文字視覺化工具中開啟 diff --git a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs index db41d8b57a1..bcd533ce063 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs @@ -12,6 +12,8 @@ namespace Aspire.Dashboard.Utils; internal static class DashboardUIHelpers { + public const string MessageBarSection = "MessagesTop"; + // The initial data fetch for a FluentDataGrid doesn't include a count of items to return. // The data grid doesn't specify a count because it doesn't know how many items fit in the UI. // Once it knows the height of items and the height of the grid then it specifies the desired item count diff --git a/src/Aspire.Dashboard/wwwroot/css/app.css b/src/Aspire.Dashboard/wwwroot/css/app.css index e75457dffcb..17ca3170135 100644 --- a/src/Aspire.Dashboard/wwwroot/css/app.css +++ b/src/Aspire.Dashboard/wwwroot/css/app.css @@ -64,6 +64,10 @@ fluent-toolbar::part(end) { --reconnection-ui-bg: rgb(255, 255, 255); --error-counter-badge-foreground-color: var(--neutral-fill-rest); --kbd-background-color: var(--neutral-layer-4); + --messagebar-success-background-color: #f1faf1; + --messagebar-success-border-color: #9fd89f; + --messagebar-error-background-color: #FDF3F4; + --messagebar-error-border-color: #f1bbbc; --messagebar-warning-background-color: #FDF6F3; --messagebar-warning-border-color: #f4bfab; --messagebar-info-background-color: #f5f5f5; @@ -99,6 +103,10 @@ fluent-toolbar::part(end) { --reconnection-ui-bg: #D6D6D6; --error-counter-badge-foreground-color: #ffffff; --kbd-background-color: var(--fill-color); + --messagebar-success-background-color: #052505; + --messagebar-success-border-color: #107C10; + --messagebar-error-background-color: #3F1011; + --messagebar-error-border-color: #D13438; --messagebar-warning-background-color: #411200; --messagebar-warning-border-color: #DA3B01; --messagebar-info-background-color: #141414; @@ -225,6 +233,16 @@ h1 { padding-bottom: 0 !important; } +.fluent-messagebar.intent-success { + background-color: var(--messagebar-success-background-color) !important; + border: 1px solid var(--messagebar-success-border-color) !important; +} + +.fluent-messagebar.intent-error { + background-color: var(--messagebar-error-background-color) !important; + border: 1px solid var(--messagebar-error-border-color) !important; +} + .fluent-messagebar.intent-warning { background-color: var(--messagebar-warning-background-color) !important; border: 1px solid var(--messagebar-warning-border-color) !important; diff --git a/src/Aspire.Hosting/ApplicationModel/InteractionService.cs b/src/Aspire.Hosting/ApplicationModel/InteractionService.cs new file mode 100644 index 00000000000..0b3d1c6713d --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/InteractionService.cs @@ -0,0 +1,584 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +/// +/// A service to interact with the current development environment. +/// +[Experimental(DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class InteractionService +{ + internal const string DiagnosticId = "ASPIREINTERACTION001"; + + private Action? OnInteractionUpdated { get; set; } + private readonly object _onInteractionUpdatedLock = new(); + private readonly InteractionCollection _interactionCollection = new(); + private readonly ILogger _logger; + + internal InteractionService(ILogger logger) + { + _logger = logger; + } + + /// + /// Prompts the user for confirmation with a dialog. + /// + /// The title of the dialog. + /// The message to display in the dialog. + /// Optional configuration for the message box interaction. + /// A token to cancel the operation. + /// + /// An containing true if the user confirmed, false otherwise. + /// + public async Task> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= MessageBoxInteractionOptions.CreateDefault(); + options.Intent = MessageIntent.Confirmation; + options.ShowDismiss = false; + options.ShowSecondaryButton = true; + + return await PromptMessageBoxCoreAsync(title, message, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Prompts the user with a message box dialog. + /// + /// The title of the message box. + /// The message to display in the message box. + /// Optional configuration for the message box interaction. + /// A token to cancel the operation. + /// + /// An containing true if the user accepted, false otherwise. + /// + public async Task> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= MessageBoxInteractionOptions.CreateDefault(); + options.ShowSecondaryButton = false; + options.ShowDismiss = false; + + return await PromptMessageBoxCoreAsync(title, message, options, cancellationToken).ConfigureAwait(false); + } + + private async Task> PromptMessageBoxCoreAsync(string title, string message, MessageBoxInteractionOptions options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + options ??= MessageBoxInteractionOptions.CreateDefault(); + options.ShowDismiss = false; + + var newState = new Interaction(title, message, options, new Interaction.MessageBoxInteractionInfo(intent: options.Intent ?? MessageIntent.None), cancellationToken); + AddInteractionUpdate(newState); + + using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState); + + var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); + return completion.Canceled + ? InteractionResultFactory.Cancel() + : InteractionResultFactory.Ok((bool)completion.State!); + } + + /// + /// Prompts the user for a single text input. + /// + /// The title of the input dialog. + /// The message to display in the dialog. + /// The label for the input field. + /// The placeholder text for the input field. + /// Optional configuration for the input dialog interaction. + /// A token to cancel the operation. + /// + /// An containing the user's input. + /// + public async Task> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + return await PromptInputAsync(title, message, new InteractionInput { InputType = InputType.Text, Label = inputLabel, Required = true, Placeholder = placeHolder }, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Prompts the user for a single input using a specified . + /// + /// The title of the input dialog. + /// The message to display in the dialog. + /// The input configuration. + /// Optional configuration for the input dialog interaction. + /// A token to cancel the operation. + /// + /// An containing the user's input. + /// + public async Task> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + var result = await PromptInputsAsync(title, message, [input], options, cancellationToken).ConfigureAwait(false); + if (result.Canceled) + { + return InteractionResultFactory.Cancel(); + } + + return InteractionResultFactory.Ok(result.Data![0]); + } + + /// + /// Prompts the user for multiple inputs. + /// + /// The title of the input dialog. + /// The message to display in the dialog. + /// A collection of to prompt for. + /// Optional configuration for the input dialog interaction. + /// A token to cancel the operation. + /// + /// An containing the user's inputs. + /// + public async Task>> PromptInputsAsync(string title, string? message, IEnumerable inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var inputList = inputs.ToList(); + options ??= InputsDialogInteractionOptions.Default; + + var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputList), cancellationToken); + AddInteractionUpdate(newState); + + using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState); + + var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); + return completion.Canceled + ? InteractionResultFactory.Cancel>() + : InteractionResultFactory.Ok((IReadOnlyList)completion.State!); + } + + /// + /// Prompts the user with a message bar notification. + /// + /// The title of the message bar. + /// The message to display in the message bar. + /// Optional configuration for the message bar interaction. + /// A token to cancel the operation. + /// + /// An containing true if the user accepted, false otherwise. + /// + public async Task> PromptMessageBarAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + options ??= MessageBoxInteractionOptions.CreateDefault(); + + var newState = new Interaction(title, message, options, new Interaction.MessageBarInteractionInfo(intent: options.Intent ?? MessageIntent.None), cancellationToken); + AddInteractionUpdate(newState); + + using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState); + + var completion = await newState.CompletionTcs.Task.ConfigureAwait(false); + return completion.Canceled + ? InteractionResultFactory.Cancel() + : InteractionResultFactory.Ok((bool)completion.State!); + } + + // For testing. + internal List GetCurrentInteractions() + { + lock (_onInteractionUpdatedLock) + { + return _interactionCollection.ToList(); + } + } + + private void OnInteractionCancellation(object? newState) + { + var interactionState = (Interaction)newState!; + + interactionState.State = Interaction.InteractionState.Complete; + interactionState.CompletionTcs.TrySetResult(new InteractionCompletionState { Canceled = true }); + AddInteractionUpdate(interactionState); + } + + private void AddInteractionUpdate(Interaction interactionUpdate) + { + lock (_onInteractionUpdatedLock) + { + var updateEvent = false; + + if (interactionUpdate.State == Interaction.InteractionState.Complete) + { + Debug.Assert( + interactionUpdate.CompletionTcs.Task.IsCompleted, + "TaskCompletionSource should be completed when interaction is done."); + + // Only update event if interaction was previously registered and not already removed. + updateEvent = _interactionCollection.Remove(interactionUpdate.InteractionId); + } + else + { + if (_interactionCollection.Contains(interactionUpdate.InteractionId)) + { + // Should never happen, but throw descriptive exception if it does. + throw new InvalidOperationException($"An interaction with ID {interactionUpdate.InteractionId} already exists. Interaction IDs must be unique."); + } + + _interactionCollection.Add(interactionUpdate); + updateEvent = true; + } + + if (updateEvent) + { + OnInteractionUpdated?.Invoke(interactionUpdate); + } + } + } + + internal void CompleteInteraction(int interactionId, Func createResult) + { + lock (_onInteractionUpdatedLock) + { + if (_interactionCollection.TryGetValue(interactionId, out var interactionState)) + { + var result = createResult(interactionState); + + interactionState.CompletionTcs.TrySetResult(result); + interactionState.State = Interaction.InteractionState.Complete; + _interactionCollection.Remove(interactionId); + OnInteractionUpdated?.Invoke(interactionState); + } + else + { + _logger.LogDebug("No interaction found with ID {InteractionId}.", interactionId); + } + } + } + + internal async IAsyncEnumerable SubscribeInteractionUpdates([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var channel = Channel.CreateUnbounded(); + + void WriteToChannel(Interaction resourceEvent) => + channel.Writer.TryWrite(resourceEvent); + + List pendingInteractions; + + lock (_onInteractionUpdatedLock) + { + OnInteractionUpdated += WriteToChannel; + + pendingInteractions = _interactionCollection.ToList(); + } + + foreach (var interaction in pendingInteractions) + { + yield return interaction; + } + + try + { + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + finally + { + lock (_onInteractionUpdatedLock) + { + OnInteractionUpdated -= WriteToChannel; + } + + channel.Writer.TryComplete(); + } + } +} + +internal class InteractionCollection : KeyedCollection +{ + protected override int GetKeyForItem(Interaction item) => item.InteractionId; +} + +/// +/// +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class InteractionResult +{ + /// + /// + /// + public T? Data { get; } + + /// + /// + /// + public bool Canceled { get; } + + internal InteractionResult(T? data, bool canceled) + { + Data = data; + Canceled = canceled; + } +} + +internal static class InteractionResultFactory +{ + internal static InteractionResult Ok(T result) + { + return new InteractionResult(result, canceled: false); + } + + internal static InteractionResult Cancel(T? data = default) + { + return new InteractionResult(data ?? default, canceled: true); + } +} + +/// +/// Represents an input for an interaction. +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class InteractionInput +{ + private string? _value; + + /// + /// Gets or sets the label for the input. + /// + public required string Label { get; init; } + + /// + /// Gets or sets the type of the input. + /// + public required InputType InputType { get; init; } + + /// + /// Gets or sets a value indicating whether the input is required. + /// + public bool Required { get; init; } + + /// + /// Gets or sets the options for the input. Only used by inputs. + /// + public IReadOnlyList>? Options { get; init; } + + /// + /// Gets or sets the value of the input. + /// + public string? Value { get => _value; init => _value = value; } + + /// + /// Gets or sets the placeholder text for the input. + /// + public string? Placeholder { get; set; } + + internal void SetValue(string value) => _value = value; +} + +/// +/// Specifies the type of input for an . +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum InputType +{ + /// + /// A single-line text input. + /// + Text, + /// + /// A password input. + /// + Password, + /// + /// A select input. + /// + Select, + /// + /// A checkbox input. + /// + Checkbox, + /// + /// A numeric input. + /// + Number +} + +/// +/// Options for configuring an inputs dialog interaction. +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class InputsDialogInteractionOptions : InteractionOptions +{ + internal static new InputsDialogInteractionOptions Default { get; } = new(); +} + +/// +/// Options for configuring a message box interaction. +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class MessageBoxInteractionOptions : InteractionOptions +{ + internal static MessageBoxInteractionOptions CreateDefault() => new(); + + /// + /// Gets or sets the intent of the message box. + /// + public MessageIntent? Intent { get; set; } +} + +/// +/// Options for configuring a message bar interaction. +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class MessageBarInteractionOptions : InteractionOptions +{ + internal static MessageBarInteractionOptions CreateDefault() => new(); + + /// + /// Gets or sets the intent of the message bar. + /// + public MessageIntent? Intent { get; set; } +} + +/// +/// Specifies the intent or purpose of a message in an interaction. +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum MessageIntent +{ + /// + /// No specific intent. + /// + None = 0, + /// + /// Indicates a successful operation. + /// + Success = 1, + /// + /// Indicates a warning. + /// + Warning = 2, + /// + /// Indicates an error. + /// + Error = 3, + /// + /// Provides informational content. + /// + Information = 4, + /// + /// Requests confirmation from the user. + /// + Confirmation = 5 +} + +/// +/// Optional configuration for interactions added with . +/// +[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class InteractionOptions +{ + internal static InteractionOptions Default { get; } = new(); + + /// + /// Optional primary button text to override the default text. + /// + public string? PrimaryButtonText { get; set; } + + /// + /// Optional secondary button text to override the default text. + /// + public string? SecondaryButtonText { get; set; } + + /// + /// Gets or sets a value indicating whether show the secondary button. Defaults to true. + /// + public bool ShowSecondaryButton { get; set; } = true; + + /// + /// Gets or sets a value indicating whether show the dismiss button in the header. + /// + public bool ShowDismiss { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to escape HTML in the message content. Defaults to true. + /// + public bool EscapeMessageHtml { get; set; } = true; +} + +[DebuggerDisplay("State = {State}, Canceled = {Canceled}")] +internal sealed class InteractionCompletionState +{ + public bool Canceled { get; init; } + public object? State { get; init; } +} + +[DebuggerDisplay("InteractionId = {InteractionId}, State = {State}, Title = {Title}")] +internal class Interaction +{ + private static int s_nextInteractionId = 1; + + public int InteractionId { get; } + public InteractionState State { get; set; } + public TaskCompletionSource CompletionTcs { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + public InteractionInfoBase InteractionInfo { get; } + public CancellationToken CancellationToken { get; } + + public string Title { get; } + public string? Message { get; } + public InteractionOptions Options { get; } + + public Interaction(string title, string? message, InteractionOptions options, InteractionInfoBase interactionInfo, CancellationToken cancellationToken) + { + InteractionId = Interlocked.Increment(ref s_nextInteractionId); + Title = title; + Message = options.EscapeMessageHtml ? WebUtility.HtmlEncode(message) : message; + Options = options; + InteractionInfo = interactionInfo; + CancellationToken = cancellationToken; + } + + internal enum InteractionState + { + InProgress, + Complete + } + + internal abstract class InteractionInfoBase + { + } + + internal sealed class MessageBoxInteractionInfo : InteractionInfoBase + { + public MessageBoxInteractionInfo(MessageIntent intent) + { + Intent = intent; + } + + public MessageIntent Intent { get; } + } + + internal sealed class MessageBarInteractionInfo : InteractionInfoBase + { + public MessageBarInteractionInfo(MessageIntent intent) + { + Intent = intent; + } + + public MessageIntent Intent { get; } + } + + internal sealed class InputsInteractionInfo : InteractionInfoBase + { + public InputsInteractionInfo(List inputs) + { + Inputs = inputs; + } + + public List Inputs { get; } + } +} + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 4108b55831c..e796eae39f8 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using static Aspire.Hosting.ApplicationModel.Interaction; namespace Aspire.Hosting.Dashboard; @@ -51,6 +52,162 @@ static string ComputeApplicationName(string applicationName) } } + public override async Task WatchInteractions(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context) + { + await ExecuteAsync( + WatchInteractionsInternal, + context).ConfigureAwait(false); + + async Task WatchInteractionsInternal(CancellationToken cancellationToken) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var updates = serviceData.SubscribeInteractionUpdates(); + + // Send + _ = Task.Run(async () => + { + try + { + await foreach (var interaction in updates.WithCancellation(cts.Token).ConfigureAwait(false)) + { + var change = new WatchInteractionsResponseUpdate(); + change.InteractionId = interaction.InteractionId; + change.Title = interaction.Title; + if (interaction.Message != null) + { + change.Message = interaction.Message; + } + if (interaction.Options.PrimaryButtonText != null) + { + change.PrimaryButtonText = interaction.Options.PrimaryButtonText; + } + if (interaction.Options.SecondaryButtonText != null) + { + change.SecondaryButtonText = interaction.Options.SecondaryButtonText; + } + change.ShowDismiss = interaction.Options.ShowDismiss; + change.ShowSecondaryButton = interaction.Options.ShowSecondaryButton; + + if (interaction.State == InteractionState.Complete) + { + change.Complete = new InteractionComplete(); + } + else if (interaction.InteractionInfo is MessageBoxInteractionInfo messageBox) + { + change.MessageBox = new InteractionMessageBox(); + change.MessageBox.Intent = MapMessageIntent(messageBox.Intent); + } + else if (interaction.InteractionInfo is MessageBarInteractionInfo messageBar) + { + change.MessageBar = new InteractionMessageBar(); + change.MessageBar.Intent = MapMessageIntent(messageBar.Intent); + } + else if (interaction.InteractionInfo is InputsInteractionInfo inputs) + { + change.InputsDialog = new InteractionInputsDialog(); + + var inputInstances = inputs.Inputs.Select(input => + { + var dto = new InteractionInput + { + InputType = MapInputType(input.InputType), + Required = input.Required + }; + if (input.Label != null) + { + dto.Label = input.Label; + } + if (input.Placeholder != null) + { + dto.Placeholder = input.Placeholder; + } + if (input.Value != null) + { + dto.Value = input.Value; + } + if (input.Options != null) + { + dto.Options.Add(input.Options.ToDictionary()); + } + return dto; + }).ToList(); + change.InputsDialog.InputItems.AddRange(inputInstances); + } + + await responseStream.WriteAsync(change, cts.Token).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error while watching interactions."); + } + finally + { + cts.Cancel(); + } + }, cts.Token); + + // Receive + try + { + await foreach (var request in requestStream.ReadAllAsync(cts.Token).ConfigureAwait(false)) + { + await serviceData.SendInteractionRequestAsync(request).ConfigureAwait(false); + } + } + finally + { + // Ensure the write task is cancelled if we exit the loop. + cts.Cancel(); + } + } + } + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private static MessageIntent MapMessageIntent(ApplicationModel.MessageIntent? intent) + { + if (intent is null) + { + return MessageIntent.None; + } + + switch (intent.Value) + { + case ApplicationModel.MessageIntent.Success: + return MessageIntent.Success; + case ApplicationModel.MessageIntent.Warning: + return MessageIntent.Warning; + case ApplicationModel.MessageIntent.Error: + return MessageIntent.Error; + case ApplicationModel.MessageIntent.Information: + return MessageIntent.Information; + case ApplicationModel.MessageIntent.Confirmation: + return MessageIntent.Confirmation; + default: + return MessageIntent.None; + } + } + + private static InputType MapInputType(ApplicationModel.InputType inputType) + { + switch (inputType) + { + case ApplicationModel.InputType.Text: + return InputType.Text; + case ApplicationModel.InputType.Password: + return InputType.Password; + case ApplicationModel.InputType.Select: + return InputType.Select; + case ApplicationModel.InputType.Checkbox: + return InputType.Checkbox; + case ApplicationModel.InputType.Number: + return InputType.Number; + default: + throw new InvalidOperationException($"Unexpected input type: {inputType}"); + } + } +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + public override async Task WatchResources( WatchResourcesRequest request, IServerStreamWriter responseStream, @@ -196,7 +353,7 @@ private async Task ExecuteAsync(Func execute, ServerCal } catch (Exception ex) { - logger.LogError(ex, $"Error executing service method '{serverCallContext.Method}'."); + logger.LogError(ex, "Error executing service method '{Method}'.", serverCallContext.Method); throw; } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index e78850200e6..78059e5012d 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -3,10 +3,13 @@ using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; +using Aspire.ResourceService.Proto.V1; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Dashboard; +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + /// /// Models the state for , as that service is constructed /// for each gRPC request. This long-lived object holds state across requests. @@ -16,17 +19,20 @@ internal sealed class DashboardServiceData : IDisposable private readonly CancellationTokenSource _cts = new(); private readonly ResourcePublisher _resourcePublisher; private readonly ResourceCommandService _resourceCommandService; + private readonly InteractionService _interactionService; private readonly ResourceLoggerService _resourceLoggerService; public DashboardServiceData( ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, ILogger logger, - ResourceCommandService resourceCommandService) + ResourceCommandService resourceCommandService, + InteractionService interactionService) { _resourceLoggerService = resourceLoggerService; _resourcePublisher = new ResourcePublisher(_cts.Token); _resourceCommandService = resourceCommandService; + _interactionService = interactionService; var cancellationToken = _cts.Token; Task.Run(async () => @@ -100,6 +106,11 @@ public void Dispose() } } + internal IAsyncEnumerable SubscribeInteractionUpdates() + { + return _interactionService.SubscribeInteractionUpdates(); + } + internal ResourceSnapshotSubscription SubscribeResources() { return _resourcePublisher.Subscribe(); @@ -138,6 +149,42 @@ async IAsyncEnumerable> Enumerate([EnumeratorCancellation } } } + + internal Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request) + { + _interactionService.CompleteInteraction(request.InteractionId, interaction => + { + switch (request.KindCase) + { + case WatchInteractionsRequestUpdate.KindOneofCase.MessageBox: + return new InteractionCompletionState { State = request.MessageBox.Result }; + case WatchInteractionsRequestUpdate.KindOneofCase.MessageBar: + return new InteractionCompletionState { State = request.MessageBar.Result }; + case WatchInteractionsRequestUpdate.KindOneofCase.InputsDialog: + var inputsInfo = (Interaction.InputsInteractionInfo)interaction.InteractionInfo; + for (var i = 0; i < inputsInfo.Inputs.Count; i++) + { + var modelInput = inputsInfo.Inputs[i]; + var requestInput = request.InputsDialog.InputItems[i]; + + var incomingValue = requestInput.Value; + + // Ensure checkbox value is either true or false. + if (requestInput.InputType == ResourceService.Proto.V1.InputType.Checkbox) + { + incomingValue = (bool.TryParse(incomingValue, out var b) && b) ? "true" : "false"; + } + + modelInput.SetValue(incomingValue); + } + return new InteractionCompletionState { State = inputsInfo.Inputs }; + default: + return new InteractionCompletionState { Canceled = true }; + } + }); + + return Task.CompletedTask; + } } internal enum ExecuteCommandResultType @@ -146,3 +193,5 @@ internal enum ExecuteCommandResultType Failure, Canceled } + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs index 4072da3ef96..22f95853ff9 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceHost.cs @@ -18,6 +18,8 @@ namespace Aspire.Hosting.Dashboard; +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + /// /// Hosts a gRPC service via (aka the "Resource Service") that a dashboard can connect to. /// Configures DI and networking options for the service. @@ -54,7 +56,8 @@ public DashboardServiceHost( IConfigureOptions loggerOptions, ResourceNotificationService resourceNotificationService, ResourceLoggerService resourceLoggerService, - ResourceCommandService resourceCommandService) + ResourceCommandService resourceCommandService, + InteractionService interactionService) { _logger = loggerFactory.CreateLogger(); @@ -112,6 +115,7 @@ public DashboardServiceHost( builder.Services.AddSingleton(); builder.Services.AddSingleton(resourceNotificationService); builder.Services.AddSingleton(resourceLoggerService); + builder.Services.AddSingleton(interactionService); builder.WebHost.ConfigureKestrel(ConfigureKestrel); @@ -222,3 +226,5 @@ async Task IHostedService.StopAsync(CancellationToken cancellationToken) } } } + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto index f8ce9049708..a12ff870e42 100644 --- a/src/Aspire.Hosting/Dashboard/proto/resource_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/resource_service.proto @@ -314,6 +314,71 @@ message WatchResourceConsoleLogsUpdate { repeated ConsoleLogLine log_lines = 1; } +message WatchInteractionsRequestUpdate { + int32 interaction_id = 1; + + oneof kind { + InteractionComplete complete = 2; + InteractionMessageBox message_box = 3; + InteractionInputsDialog inputs_dialog = 4; + InteractionMessageBar message_bar = 5; + } +} +message WatchInteractionsResponseUpdate { + int32 interaction_id = 1; + + string title = 2; + string message = 3; + string primary_button_text = 4; + string secondary_button_text = 5; + bool show_secondary_button = 6; + bool show_dismiss = 7; + + oneof kind { + InteractionComplete complete = 16; + InteractionMessageBox message_box = 17; + InteractionInputsDialog inputs_dialog = 18; + InteractionMessageBar message_bar = 19; + } +} +message InteractionComplete { +} +message InteractionMessageBox { + MessageIntent intent = 1; + optional bool result = 2; +} +message InteractionMessageBar { + MessageIntent intent = 1; + optional bool result = 2; +} +message InteractionInputsDialog { + repeated InteractionInput input_items = 1; +} +message InteractionInput { + string label = 1; + string placeholder = 2; + InputType input_type = 3; + bool required = 4; + map options = 5; + string value = 6; +} +enum MessageIntent { + MESSAGE_INTENT_NONE = 0; + MESSAGE_INTENT_SUCCESS = 1; + MESSAGE_INTENT_WARNING = 2; + MESSAGE_INTENT_ERROR = 3; + MESSAGE_INTENT_INFORMATION = 4; + MESSAGE_INTENT_CONFIRMATION = 5; +} +enum InputType { + INPUT_TYPE_UNSPECIFIED = 0; + INPUT_TYPE_TEXT = 1; + INPUT_TYPE_PASSWORD = 2; + INPUT_TYPE_SELECT = 3; + INPUT_TYPE_CHECKBOX = 4; + INPUT_TYPE_NUMBER = 5; +} + //////////////////////////////////////////// service DashboardService { @@ -321,4 +386,5 @@ service DashboardService { rpc WatchResources(WatchResourcesRequest) returns (stream WatchResourcesUpdate); rpc WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest) returns (stream WatchResourceConsoleLogsUpdate); rpc ExecuteResourceCommand(ResourceCommandRequest) returns (ResourceCommandResponse); + rpc WatchInteractions(stream WatchInteractionsRequestUpdate) returns (stream WatchInteractionsResponseUpdate); } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index da8b754d049..ecdaaf4f915 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -233,6 +233,9 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(s => new ResourceCommandService(s.GetRequiredService(), s.GetRequiredService(), s)); +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + _innerBuilder.Services.AddSingleton(s => new InteractionService(s.GetRequiredService>())); +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. _innerBuilder.Services.AddSingleton(Eventing); _innerBuilder.Services.AddHealthChecks(); _innerBuilder.Services.Configure(o => diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs index dfd59eb0458..e172945b6ce 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Model; +using Aspire.ResourceService.Proto.V1; using Bunit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -78,7 +79,9 @@ private sealed class MockDashboardClient : IDashboardClient public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs index d83b8f0e342..06333398600 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Dashboard.Model; +using Aspire.ResourceService.Proto.V1; namespace Aspire.Dashboard.Components.Tests.Shared; @@ -87,4 +88,14 @@ async static IAsyncEnumerable> BuildSubsc } } } + + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index 8ae0927a056..965e9eab569 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; +using Aspire.ResourceService.Proto.V1; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; @@ -49,4 +50,14 @@ private static async IAsyncEnumerable> Te await Task.CompletedTask; yield return []; } + + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index e5264dd8ba9..47812c02b85 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Dashboard.Model; +using Aspire.ResourceService.Proto.V1; using Aspire.Tests.Shared.DashboardModel; using Microsoft.AspNetCore.InternalTesting; using Xunit; @@ -226,7 +227,9 @@ private sealed class MockDashboardClient(Task sub public ValueTask DisposeAsync() => ValueTask.CompletedTask; public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => subscribeResult; } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 5d24325fd97..7baea936a35 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -22,6 +22,8 @@ namespace Aspire.Hosting.Tests.Dashboard; +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + public class DashboardServiceTests(ITestOutputHelper testOutputHelper) { [Fact] @@ -234,7 +236,8 @@ private static DashboardServiceData CreateDashboardServiceData( resourceNotificationService, resourceLoggerService, loggerFactory.CreateLogger(), - new ResourceCommandService(resourceNotificationService, resourceLoggerService, new ServiceCollection().BuildServiceProvider())); + new ResourceCommandService(resourceNotificationService, resourceLoggerService, new ServiceCollection().BuildServiceProvider()), + new InteractionService(NullLogger.Instance)); } private sealed class TestHostEnvironment : IHostEnvironment @@ -263,3 +266,5 @@ private static async Task CancelTokenAndAwaitTask(CancellationTokenSource cts, T } } } + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs new file mode 100644 index 00000000000..eb5791f80ce --- /dev/null +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Channels; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Hosting.Tests; + +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +public class InteractionServiceTests +{ + [Fact] + public async Task PromptConfirmationAsync_CompleteResult_ReturnResult() + { + // Arrange + var interactionService = CreateInteractionService(); + + // Act 1 + var resultTask = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation"); + + // Assert 1 + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + Assert.Equal(Interaction.InteractionState.InProgress, interaction.State); + + // Act 2 + interactionService.CompleteInteraction(interaction.InteractionId, _ => new InteractionCompletionState { State = true }); + + var result = await resultTask.DefaultTimeout(); + Assert.True(result.Data!); + + // Assert 2 + Assert.Empty(interactionService.GetCurrentInteractions()); + } + + [Fact] + public async Task PromptConfirmationAsync_Cancellation_ReturnResult() + { + // Arrange + var interactionService = CreateInteractionService(); + + // Act 1 + var cts = new CancellationTokenSource(); + var resultTask = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation", cancellationToken: cts.Token); + + // Assert 1 + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + Assert.Equal(Interaction.InteractionState.InProgress, interaction.State); + + // Act 2 + cts.Cancel(); + + var result = await resultTask.DefaultTimeout(); + Assert.True(result.Canceled); + + // Assert 2 + Assert.Empty(interactionService.GetCurrentInteractions()); + } + + [Fact] + public async Task PromptConfirmationAsync_MultipleCompleteResult_ReturnResult() + { + // Arrange + var interactionService = CreateInteractionService(); + + // Act 1 + var resultTask1 = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation"); + var resultTask2 = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation"); + + // Assert 1 + int? id1 = null; + int? id2 = null; + Assert.Collection(interactionService.GetCurrentInteractions(), + interaction => + { + id1 = interaction.InteractionId; + }, + interaction => + { + id2 = interaction.InteractionId; + }); + Assert.True(id1.HasValue && id2.HasValue && id1 < id2); + + // Act & Assert 2 + interactionService.CompleteInteraction(id1.Value, _ => new InteractionCompletionState { State = true }); + Assert.True((bool)(await resultTask1.DefaultTimeout()).Data!); + Assert.Equal(id2.Value, Assert.Single(interactionService.GetCurrentInteractions()).InteractionId); + + interactionService.CompleteInteraction(id2.Value, _ => new InteractionCompletionState { State = false }); + Assert.False((bool)(await resultTask2.DefaultTimeout()).Data!); + Assert.Empty(interactionService.GetCurrentInteractions()); + } + + [Fact] + public async Task SubscribeInteractionUpdates_MultipleCompleteResult_ReturnResult() + { + // Arrange + var interactionService = CreateInteractionService(); + var subscription = interactionService.SubscribeInteractionUpdates(); + var updates = Channel.CreateUnbounded(); + var readTask = Task.Run(async () => + { + await foreach (var interaction in subscription.WithCancellation(CancellationToken.None)) + { + await updates.Writer.WriteAsync(interaction); + } + }); + + // Act 1 + var resultTask1 = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation"); + var interaction1 = Assert.Single(interactionService.GetCurrentInteractions()); + Assert.Equal(interaction1.InteractionId, (await updates.Reader.ReadAsync().DefaultTimeout()).InteractionId); + + var resultTask2 = interactionService.PromptConfirmationAsync("Are you sure?", "Confirmation"); + Assert.Equal(2, interactionService.GetCurrentInteractions().Count); + var interaction2 = interactionService.GetCurrentInteractions()[1]; + Assert.Equal(interaction2.InteractionId, (await updates.Reader.ReadAsync().DefaultTimeout()).InteractionId); + + // Act & Assert 2 + var result1 = new InteractionCompletionState { State = true }; + interactionService.CompleteInteraction(interaction1.InteractionId, _ => result1); + Assert.True((await resultTask1.DefaultTimeout()).Data); + Assert.Equal(interaction2.InteractionId, Assert.Single(interactionService.GetCurrentInteractions()).InteractionId); + var completedInteraction1 = await updates.Reader.ReadAsync().DefaultTimeout(); + Assert.True(completedInteraction1.CompletionTcs.Task.IsCompletedSuccessfully); + Assert.Equivalent(result1, await completedInteraction1.CompletionTcs.Task.DefaultTimeout()); + + var result2 = new InteractionCompletionState { State = false }; + interactionService.CompleteInteraction(interaction2.InteractionId, _ => result2); + Assert.False((await resultTask2.DefaultTimeout()).Data); + Assert.Empty(interactionService.GetCurrentInteractions()); + var completedInteraction2 = await updates.Reader.ReadAsync().DefaultTimeout(); + Assert.True(completedInteraction2.CompletionTcs.Task.IsCompletedSuccessfully); + Assert.Equivalent(result2, await completedInteraction2.CompletionTcs.Task.DefaultTimeout()); + } + + private static InteractionService CreateInteractionService() + { + return new InteractionService(NullLogger.Instance); + } +} + +#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.