diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index bb7eed9380a..30b0a407dec 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -62,6 +62,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Controls/ApplicationName.razor.cs b/src/Aspire.Dashboard/Components/Controls/ApplicationName.razor.cs index 0c7397fc662..f564e7fbb83 100644 --- a/src/Aspire.Dashboard/Components/Controls/ApplicationName.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ApplicationName.razor.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using Aspire.Dashboard.Model; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs index f4154584ae3..7134b4b44d9 100644 --- a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -19,18 +19,21 @@ namespace Aspire.Dashboard.Components.Interactions; public class InteractionsProvider : ComponentBase, IAsyncDisposable { - private record InteractionMessageBarReference(int InteractionId, Message Message); - private record InteractionDialogReference(int InteractionId, IDialogReference Dialog); + internal record InteractionMessageBarReference(int InteractionId, Message Message); + internal record InteractionDialogReference(int InteractionId, IDialogReference Dialog); private readonly CancellationTokenSource _cts = new(); private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); private readonly KeyedInteractionCollection _pendingInteractions = new(); private readonly KeyedMessageCollection _openMessageBars = new(); - private Task? _interactionsDisplayTask; + private Task? _dialogDisplayTask; private Task? _watchInteractionsTask; private TaskCompletionSource _interactionAvailableTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - private InteractionDialogReference? _interactionDialogReference; + + // Internal for testing. + internal bool? _enabled; + internal InteractionDialogReference? _interactionDialogReference; [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -52,345 +55,385 @@ 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) { + Logger.LogDebug("InteractionProvider is disabled because the DashboardClient is not enabled."); + _enabled = false; return; } + else + { + _enabled = true; + } - _interactionsDisplayTask = Task.Run(async () => + _dialogDisplayTask = Task.Run(async () => { - var waitForInteractionAvailableTask = Task.CompletedTask; + try + { + await InteractionsDisplayAsync().ConfigureAwait(false); + } + catch (Exception ex) when (!_cts.IsCancellationRequested) + { + Logger.LogError(ex, "Unexpected error while displaying interaction dialogs."); + } + }); - while (!_cts.IsCancellationRequested) + _watchInteractionsTask = Task.Run(async () => + { + try + { + await WatchInteractionsAsync().ConfigureAwait(false); + } + catch (Exception ex) when (!_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); + Logger.LogError(ex, "Unexpected error while watching interactions."); + } + }); + } - IDialogReference? currentDialogReference = null; + private async Task InteractionsDisplayAsync() + { + var waitForInteractionAvailableTask = Task.CompletedTask; - await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); - try + 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) { - 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; - } + // 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); + waitForInteractionAvailableTask = Task.CompletedTask; + var item = ((IList)_pendingInteractions)[0]; + _pendingInteractions.RemoveAt(0); - Func> openDialog; + Func> openDialog; - if (item.MessageBox is { } messageBox) + if (item.MessageBox is { } messageBox) + { + var dialogParameters = CreateDialogParameters(item, messageBox.Intent); + dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => { - var dialogParameters = CreateDialogParameters(item, messageBox.Intent); - dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => + var request = new WatchInteractionsRequestUpdate { - var request = new WatchInteractionsRequestUpdate - { - InteractionId = item.InteractionId - }; + InteractionId = item.InteractionId + }; - if (dialogResult.Cancelled) + if (dialogResult.Cancelled) + { + // There will be data in the dialog result on cancel if the secondary button is clicked. + if (dialogResult.Data != null) { - // 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(); - } + messageBox.Result = false; + request.MessageBox = messageBox; } else { - messageBox.Result = true; - request.MessageBox = messageBox; + request.Complete = new InteractionComplete(); } - - await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); - }); - - var content = new MessageBoxContent - { - Title = item.Title, - MarkupMessage = new MarkupString(item.Message), - }; - switch (messageBox.Intent) + } + else { - case MessageIntentDto.None: - content.Icon = null; - break; - 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; + messageBox.Result = true; + request.MessageBox = messageBox; } - openDialog = dialogService => ShowMessageBoxAsync(dialogService, content, dialogParameters); + 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.None: + content.Icon = null; + break; + 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; } - else if (item.InputsDialog is { } inputs) + + openDialog = dialogService => ShowMessageBoxAsync(dialogService, content, dialogParameters); + } + else if (item.InputsDialog is { } inputs) + { + var vm = new InteractionsInputsDialogViewModel { - var vm = new InteractionsInputsDialogViewModel + Interaction = item, + OnSubmitCallback = async savedInteraction => { - Interaction = item, - OnSubmitCallback = async savedInteraction => + var request = new WatchInteractionsRequestUpdate { - var request = new WatchInteractionsRequestUpdate - { - InteractionId = item.InteractionId, - InputsDialog = savedInteraction.InputsDialog - }; + InteractionId = savedInteraction.InteractionId, + InputsDialog = savedInteraction.InputsDialog + }; - await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); - } - }; + await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + } + }; - var dialogParameters = CreateDialogParameters(item, intent: null); - dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => + var dialogParameters = CreateDialogParameters(item, intent: null); + dialogParameters.OnDialogResult = EventCallback.Factory.Create(this, async dialogResult => + { + // Only send notification of completion if the dialog was cancelled. + // A non-cancelled dialog result means the user submitted the form and we already sent the request. + if (dialogResult.Cancelled) { - // Only send notification of completion if the dialog was cancelled. - // A non-cancelled dialog result means the user submitted the form and we already sent the request. - if (dialogResult.Cancelled) + var request = new WatchInteractionsRequestUpdate { - var request = new WatchInteractionsRequestUpdate - { - InteractionId = item.InteractionId, - Complete = new InteractionComplete() - }; - - await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); - } - }); - - openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); - } - else - { - Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase); - continue; - } + InteractionId = item.InteractionId, + Complete = new InteractionComplete() + }; - await InvokeAsync(async () => - { - currentDialogReference = await openDialog(DialogService); + await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + } }); - Debug.Assert(currentDialogReference != null, "Dialog should have been created in UI thread."); - _interactionDialogReference = new InteractionDialogReference(item.InteractionId, currentDialogReference); + openDialog = dialogService => dialogService.ShowDialogAsync(vm, dialogParameters); } - finally + else { - _semaphore.Release(); + Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase); + continue; } - try + await InvokeAsync(async () => + { + currentDialogReference = await openDialog(DialogService); + }); + + Debug.Assert(currentDialogReference != null, "Dialog should have been created in UI thread."); + _interactionDialogReference = new InteractionDialogReference(item.InteractionId, currentDialogReference); + } + finally + { + _semaphore.Release(); + } + + try + { + if (currentDialogReference != null) { - if (currentDialogReference != null) + await currentDialogReference.Result.WaitAsync(_cts.Token); + + await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); + try { - await currentDialogReference.Result.WaitAsync(_cts.Token); + if (_interactionDialogReference?.Dialog == currentDialogReference) + { + _interactionDialogReference = null; + } + } + finally + { + _semaphore.Release(); } - } - catch - { - // Ignore any exceptions that occur while waiting for the dialog to close. } } - }); + catch + { + // Ignore any exceptions that occur while waiting for the dialog to close. + } + } + } - _watchInteractionsTask = Task.Run(async () => + private async Task WatchInteractionsAsync() + { + var interactions = DashboardClient.SubscribeInteractionsAsync(_cts.Token); + await foreach (var item in interactions) { - var interactions = DashboardClient.SubscribeInteractionsAsync(_cts.Token); - await foreach (var item in interactions) + await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); + try { - await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false); - try + switch (item.KindCase) { - switch (item.KindCase) - { - case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox: - case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog: - if (_interactionDialogReference != null && - _interactionDialogReference.InteractionId == item.InteractionId) + case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox: + case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog: + if (_interactionDialogReference != null && + _interactionDialogReference.InteractionId == item.InteractionId) + { + // If the dialog is already open for this interaction, update it with the new data. + var c = (InteractionsInputsDialogViewModel)_interactionDialogReference.Dialog.Instance.Content; + await c.UpdateInteractionAsync(item); + } + else + { + // New or updated interaction. + if (_pendingInteractions.Contains(item.InteractionId)) { - // If the dialog is already open for this interaction, update it with the new data. - var c = (InteractionsInputsDialogViewModel)_interactionDialogReference.Dialog.Instance.Content; - await c.UpdateInteractionAsync(item); + // Update existing interaction at the same place in collection. + var existingItem = _pendingInteractions[item.InteractionId]; + var index = _pendingInteractions.IndexOf(existingItem); + _pendingInteractions.RemoveAt(index); + _pendingInteractions.Insert(index, item); // Reinsert at the same index to maintain order. } else { - // New or updated interaction. - if (_pendingInteractions.Contains(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; + if (!string.IsNullOrEmpty(messageBar.LinkText)) { - // Update existing interaction at the same place in collection. - var existingItem = _pendingInteractions[item.InteractionId]; - var index = _pendingInteractions.IndexOf(existingItem); - _pendingInteractions.RemoveAt(index); - _pendingInteractions.Insert(index, item); // Reinsert at the same index to maintain order. + options.Link = new() + { + Text = messageBar.LinkText, + Href = messageBar.LinkUrl + }; } - else + + var primaryButtonText = item.PrimaryButtonText; + var secondaryButtonText = item.ShowSecondaryButton ? item.SecondaryButtonText : null; + if (messageBar.Intent == MessageIntentDto.Confirmation) { - _pendingInteractions.Add(item); + primaryButtonText = ResolvedPrimaryButtonText(item, messageBar.Intent); + secondaryButtonText = ResolvedSecondaryButtonText(item); } - NotifyInteractionAvailable(); - } - break; - case WatchInteractionsResponseUpdate.KindOneofCase.MessageBar: - var messageBar = item.MessageBar; + bool? result = null; - Message? message = null; - await InvokeAsync(async () => - { - message = await MessageService.ShowMessageBarAsync(options => + if (!string.IsNullOrEmpty(primaryButtonText)) { - 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; - if (!string.IsNullOrEmpty(messageBar.LinkText)) + options.PrimaryAction = new ActionButton { - options.Link = new() + Text = primaryButtonText, + OnClick = m => { - Text = messageBar.LinkText, - Href = messageBar.LinkUrl - }; - } - - var primaryButtonText = item.PrimaryButtonText; - var secondaryButtonText = item.ShowSecondaryButton ? item.SecondaryButtonText : null; - if (messageBar.Intent == MessageIntentDto.Confirmation) - { - primaryButtonText = ResolvedPrimaryButtonText(item, messageBar.Intent); - secondaryButtonText = ResolvedSecondaryButtonText(item); - } - - bool? result = null; - - if (!string.IsNullOrEmpty(primaryButtonText)) + result = true; + m.Close(); + return Task.CompletedTask; + } + }; + } + if (item.ShowSecondaryButton && !string.IsNullOrEmpty(secondaryButtonText)) + { + options.SecondaryAction = new ActionButton { - options.PrimaryAction = new ActionButton + Text = secondaryButtonText, + OnClick = m => { - Text = primaryButtonText, - OnClick = m => - { - result = true; - m.Close(); - return Task.CompletedTask; - } - }; - } - if (item.ShowSecondaryButton && !string.IsNullOrEmpty(secondaryButtonText)) + result = false; + m.Close(); + return Task.CompletedTask; + } + }; + } + + options.OnClose = async m => + { + // Only send complete notification if in the open message bars list. + if (_openMessageBars.TryGetValue(item.InteractionId, out var openMessageBar)) { - options.SecondaryAction = new ActionButton + var request = new WatchInteractionsRequestUpdate { - Text = secondaryButtonText, - OnClick = m => - { - result = false; - m.Close(); - return Task.CompletedTask; - } + InteractionId = item.InteractionId }; - } - options.OnClose = async m => - { - // Only send complete notification if in the open message bars list. - if (_openMessageBars.TryGetValue(item.InteractionId, out var openMessageBar)) + if (result == 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(item.InteractionId); - - await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false); + request.Complete = new InteractionComplete(); } - }; - }); + else + { + messageBar.Result = result.Value; + request.MessageBar = messageBar; + } + + _openMessageBars.Remove(item.InteractionId); + + 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.InteractionId, message)); - break; - case WatchInteractionsResponseUpdate.KindOneofCase.Complete: - // Complete interaction. - _pendingInteractions.Remove(item.InteractionId); + Debug.Assert(message != null, "Message should have been created in UI thread."); + _openMessageBars.Add(new InteractionMessageBarReference(item.InteractionId, message)); + break; + case WatchInteractionsResponseUpdate.KindOneofCase.Complete: + // Complete interaction. + _pendingInteractions.Remove(item.InteractionId); - // Close the interaction's dialog if it is open. - if (_interactionDialogReference?.InteractionId == item.InteractionId) + // Close the interaction's dialog if it is open. + if (_interactionDialogReference?.InteractionId == item.InteractionId) + { + try { - 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; - } + await InvokeAsync(_interactionDialogReference.Dialog.CloseAsync); } - - if (_openMessageBars.TryGetValue(item.InteractionId, out var openMessageBar)) + catch (Exception ex) { - // The presence of the item in the collection is used to decide whether to report completion to the server. - // This item is already completed (we're reacting to a completion notification) so remove before close. - _openMessageBars.Remove(item.InteractionId); - - // InvokeAsync not necessary here. It's called internally. - openMessageBar.Message.Close(); + Logger.LogDebug(ex, "Unexpected error when closing interaction {InteractionId} dialog reference.", item.InteractionId); } - break; - default: - Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase); - break; - } - } - finally - { - _semaphore.Release(); + finally + { + _interactionDialogReference = null; + } + } + + if (_openMessageBars.TryGetValue(item.InteractionId, out var openMessageBar)) + { + // The presence of the item in the collection is used to decide whether to report completion to the server. + // This item is already completed (we're reacting to a completion notification) so remove before close. + _openMessageBars.Remove(item.InteractionId); + + // InvokeAsync not necessary here. It's 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) @@ -478,7 +521,7 @@ public async ValueTask DisposeAsync() { _cts.Cancel(); - await TaskHelpers.WaitIgnoreCancelAsync(_interactionsDisplayTask); + await TaskHelpers.WaitIgnoreCancelAsync(_dialogDisplayTask); await TaskHelpers.WaitIgnoreCancelAsync(_watchInteractionsTask); } diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor index a11f57a714e..b5b78e8f5ef 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopNavMenu.razor @@ -1,5 +1,4 @@ -@using Aspire.Dashboard.Model -@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Resources @using Aspire.Dashboard.Utils @inject IStringLocalizer Loc @inject IDashboardClient DashboardClient diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 8a8481c4818..4b18e713c67 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -199,7 +199,7 @@ public async Task LaunchSettingsAsync() { Title = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogTitle)], DismissTitle = DialogsLoc[nameof(Resources.Dialogs.DialogCloseButtonText)], - PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)].Value, + PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)].Value, SecondaryAction = null, TrapFocus = true, Modal = true, diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index a3b3013f989..80ff7c48ddb 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -3,12 +3,11 @@ using System.Text.RegularExpressions; using Aspire.Dashboard.Components.CustomIcons; -using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; -using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; using Microsoft.JSInterop; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; namespace Aspire.Dashboard.Components.Layout; diff --git a/src/Aspire.Dashboard/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index 6c29ba2f9ed..854de5bfc5f 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -17,6 +17,8 @@ @using Aspire.Dashboard.Components @using Aspire.Dashboard.Components.Controls @using Aspire.Dashboard.Components.Layout +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.ServiceClient @using Microsoft.Extensions.Localization @* Require authorization for all pages of the web app *@ diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 0ebe693af00..50ffb1de9aa 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -9,15 +9,17 @@ using System.Security.Cryptography.X509Certificates; using System.Threading.Channels; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; -using Aspire.Hosting; using Aspire.DashboardService.Proto.V1; +using Aspire.Hosting; using Grpc.Core; using Grpc.Net.Client; using Grpc.Net.Client.Configuration; using Microsoft.Extensions.Options; +using ResourceCommandResponseKind = Aspire.Dashboard.Model.ResourceCommandResponseKind; -namespace Aspire.Dashboard.Model; +namespace Aspire.Dashboard.ServiceClient; /// /// Implements gRPC client that communicates with a resource server, populating data for the dashboard. @@ -48,6 +50,8 @@ internal sealed class DashboardClient : IDashboardClient private readonly TaskCompletionSource _initialDataReceivedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Channel _incomingInteractionChannel = Channel.CreateUnbounded(); private readonly object _lock = new(); + private readonly TaskCompletionSource _resourceWatchCompleteTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _interactionWatchCompleteTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly ILoggerFactory _loggerFactory; private readonly IKnownPropertyLookup _knownPropertyLookup; @@ -65,7 +69,7 @@ internal sealed class DashboardClient : IDashboardClient private int _state = StateNone; private readonly GrpcChannel? _channel; - private readonly Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient? _client; + internal Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient? _client; private readonly Metadata _headers = []; private Task? _connection; @@ -216,6 +220,9 @@ internal sealed class KeyStoreProperties // For testing purposes internal int OutgoingResourceSubscriberCount => _outgoingResourceChannels.Count; internal int OutgoingInteractionSubscriberCount => _outgoingInteractionChannels.Count; + internal void SetDashboardServiceClient(Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient client) => _client = client; + internal Task ResourceWatchCompleteTask => _resourceWatchCompleteTcs.Task; + internal Task InteractionWatchCompleteTask => _interactionWatchCompleteTcs.Task; public bool IsEnabled => _state is not StateDisabled; @@ -244,8 +251,16 @@ async Task ConnectAndWatchAsync(CancellationToken cancellationToken) await ConnectAsync().ConfigureAwait(false); await Task.WhenAll( - Task.Run(() => WatchWithRecoveryAsync(cancellationToken, WatchResourcesAsync), cancellationToken), - Task.Run(() => WatchWithRecoveryAsync(cancellationToken, WatchInteractionsAsync), cancellationToken)).ConfigureAwait(false); + Task.Run(async () => + { + await WatchWithRecoveryAsync(cancellationToken, WatchResourcesAsync).ConfigureAwait(false); + _resourceWatchCompleteTcs.TrySetResult(); + }, cancellationToken), + Task.Run(async () => + { + await WatchWithRecoveryAsync(cancellationToken, WatchInteractionsAsync).ConfigureAwait(false); + _interactionWatchCompleteTcs.TrySetResult(); + }, cancellationToken)).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -279,7 +294,7 @@ private class RetryContext public int ErrorCount { get; set; } } - private async Task WatchWithRecoveryAsync(CancellationToken cancellationToken, Func action) + 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 @@ -303,7 +318,10 @@ private async Task WatchWithRecoveryAsync(CancellationToken cancellationToken, F try { - await action(retryContext, cancellationToken).ConfigureAwait(false); + if (await action(retryContext, cancellationToken).ConfigureAwait(false) == RetryResult.DoNotRetry) + { + return; + } } catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { @@ -325,7 +343,7 @@ static TimeSpan ExponentialBackOff(int errorCount, double maxSeconds) } } - private async Task WatchResourcesAsync(RetryContext retryContext, CancellationToken cancellationToken) + private async Task WatchResourcesAsync(RetryContext retryContext, CancellationToken cancellationToken) { var call = _client!.WatchResources(new WatchResourcesRequest { IsReconnect = retryContext.ErrorCount != 0 }, headers: _headers, cancellationToken: cancellationToken); @@ -405,15 +423,25 @@ private async Task WatchResourcesAsync(RetryContext retryContext, CancellationTo } } } + + return RetryResult.Retry; } - private async Task WatchInteractionsAsync(RetryContext retryContext, CancellationToken cancellationToken) + 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); + if (await IsUnimplemented(call).ConfigureAwait(false)) + { + // The server does not support this method. + _logger.LogWarning("Server does not support interactions."); + + return RetryResult.DoNotRetry; + } + // Send _ = Task.Run(async () => { @@ -473,6 +501,29 @@ private async Task WatchInteractionsAsync(RetryContext retryContext, Cancellatio // Ensure the write task is cancelled if we exit the loop. cts.Cancel(); } + + return RetryResult.Retry; + } + + private static async Task IsUnimplemented(AsyncDuplexStreamingCall call) + { + // Wait for the server to respond with initial headers. Require before calling GetStatus. + await call.ResponseHeadersAsync.ConfigureAwait(false); + + try + { + var status = call.GetStatus(); + if (status.StatusCode == StatusCode.Unimplemented) + { + return true; + } + } + catch (InvalidOperationException) + { + // Expected from GetStatus when the method is still in progress. + } + + return false; } public async Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) @@ -737,4 +788,10 @@ private class InteractionCollection : KeyedCollection item.InteractionId; } + + private enum RetryResult + { + Retry, + DoNotRetry + } } diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 0aa6157e9e1..7b5bd0e747c 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -2,9 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; -namespace Aspire.Dashboard.Model; +namespace Aspire.Dashboard.ServiceClient; /// /// Provides data about active resources to external components, such as the dashboard. diff --git a/src/Aspire.Hosting/ApplicationModel/InteractionService.cs b/src/Aspire.Hosting/ApplicationModel/InteractionService.cs index ec26e66e7a6..fe8f31fb5a6 100644 --- a/src/Aspire.Hosting/ApplicationModel/InteractionService.cs +++ b/src/Aspire.Hosting/ApplicationModel/InteractionService.cs @@ -315,7 +315,7 @@ internal sealed class InteractionCompletionState [DebuggerDisplay("InteractionId = {InteractionId}, State = {State}, Title = {Title}")] internal class Interaction { - private static int s_nextInteractionId = 1; + private static int s_nextInteractionId; public int InteractionId { get; } public InteractionState State { get; set; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index 962c23eba45..9ae25650059 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -20,10 +20,11 @@ + - + diff --git a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs new file mode 100644 index 00000000000..80b4eb10837 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs @@ -0,0 +1,292 @@ +// 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 Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.DashboardService.Proto.V1; +using Bunit; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Interactions; + +[UseCulture("en-US")] +public partial class InteractionsProviderTests : DashboardTestContext +{ + private readonly ITestOutputHelper _testOutputHelper; + + public InteractionsProviderTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task Initialize_DashboardClientNotEnabled_ProviderDisabledAsync() + { + // Arrange + var dashboardClient = new TestDashboardClient(isEnabled: false); + + SetupInteractionProviderServices(dashboardClient); + + // Act + var cut = RenderComponent(); + + var instance = cut.Instance; + + // Assert + cut.WaitForAssertion(() => + { + Assert.False(instance._enabled); + }); + + await instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task Initialize_DashboardClientEnabled_ProviderEnabledAsync() + { + // Arrange + var interactionsChannel = Channel.CreateUnbounded(); + + var dashboardClient = new TestDashboardClient(isEnabled: true, interactionChannelProvider: () => interactionsChannel); + + SetupInteractionProviderServices(dashboardClient); + + // Act + var cut = RenderComponent(); + + var instance = cut.Instance; + + // Assert + cut.WaitForAssertion(() => + { + Assert.True(instance._enabled); + }); + + await instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task ReceiveData_MessageBoxOpen_OpenDialog() + { + // Arrange + var interactionsChannel = Channel.CreateUnbounded(); + + var dialogReference = new DialogReference("abc", null!); + var dashboardClient = new TestDashboardClient(isEnabled: true, interactionChannelProvider: () => interactionsChannel); + var dialogService = new TestDialogService(onShowDialog: (data, parameters) => Task.FromResult(dialogReference)); + + SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); + + // Act 1 + var cut = RenderComponent(); + + var instance = cut.Instance; + + await interactionsChannel.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, + MessageBox = new InteractionMessageBox() + }); + + // Assert 1 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => + { + var reference = instance._interactionDialogReference; + if (reference == null) + { + return false; + } + + return dialogReference == reference.Dialog && reference.InteractionId == 1; + }, "Wait for dialog reference created."); + + // Act 2 + dialogReference.Dismiss(DialogResult.Ok(true)); + + // Assert 2 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => instance._interactionDialogReference == null, "Wait for dialog reference dismissed."); + + await instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task ReceiveData_MessageBoxOpenAndCompletion_OpenAndCloseDialog() + { + // Arrange + var interactionsChannel = Channel.CreateUnbounded(); + + var dialogReference = new DialogReference("abc", null!); + var dashboardClient = new TestDashboardClient(isEnabled: true, interactionChannelProvider: () => interactionsChannel); + var dialogService = new TestDialogService(onShowDialog: (data, parameters) => Task.FromResult(dialogReference)); + + SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); + + // Act 1 + var cut = RenderComponent(); + + var instance = cut.Instance; + + await interactionsChannel.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, + MessageBox = new InteractionMessageBox() + }); + + // Assert 1 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => + { + var reference = instance._interactionDialogReference; + if (reference == null) + { + return false; + } + + return dialogReference == reference.Dialog && reference.InteractionId == 1; + }, "Wait for dialog reference created."); + + // Act 2 + await interactionsChannel.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, + Complete = new InteractionComplete() + }); + + // Assert 2 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => instance._interactionDialogReference == null, "Wait for dialog reference dismissed."); + + await instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task ReceiveData_InputDialogOpenAndCancel_OpenDialogAndSendCompletion() + { + // Arrange + var interactionsChannel = Channel.CreateUnbounded(); + var sendInteractionUpdatesChannel = Channel.CreateUnbounded(); + + DialogParameters? dialogParameters = null; + var dialogReference = new DialogReference("abc", null!); + var dashboardClient = new TestDashboardClient(isEnabled: true, + interactionChannelProvider: () => interactionsChannel, + sendInteractionUpdateChannel: sendInteractionUpdatesChannel); + var dialogService = new TestDialogService(onShowDialog: (data, parameters) => + { + dialogParameters = parameters; + return Task.FromResult(dialogReference); + }); + + SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); + + // Act 1 + var cut = RenderComponent(); + + var instance = cut.Instance; + + await interactionsChannel.Writer.WriteAsync(new WatchInteractionsResponseUpdate + { + InteractionId = 1, + InputsDialog = new InteractionInputsDialog() + }); + + // Assert 1 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => + { + var reference = instance._interactionDialogReference; + if (reference == null) + { + return false; + } + + return dialogReference == reference.Dialog && reference.InteractionId == 1; + }, "Wait for dialog reference created."); + + // Act 2 + Assert.NotNull(dialogParameters); + + await cut.InvokeAsync(() => dialogParameters.OnDialogResult.InvokeAsync(DialogResult.Cancel())).DefaultTimeout(); + + var update = await sendInteractionUpdatesChannel.Reader.ReadAsync(); + + Assert.Equal(1, update.InteractionId); + Assert.Equal(WatchInteractionsRequestUpdate.KindOneofCase.Complete, update.KindCase); + + await instance.DisposeAsync().DefaultTimeout(); + } + + [Fact] + public async Task ReceiveData_InputDialogOpenAndSubmit_OpenDialogAndSendCompletion() + { + // Arrange + var interactionsChannel = Channel.CreateUnbounded(); + var sendInteractionUpdatesChannel = Channel.CreateUnbounded(); + + InteractionsInputsDialogViewModel? vm = null; + DialogParameters? dialogParameters = null; + var dialogReference = new DialogReference("abc", null!); + var dashboardClient = new TestDashboardClient(isEnabled: true, + interactionChannelProvider: () => interactionsChannel, + sendInteractionUpdateChannel: sendInteractionUpdatesChannel); + var dialogService = new TestDialogService(onShowDialog: (data, parameters) => + { + vm = (InteractionsInputsDialogViewModel)data; + dialogParameters = parameters; + return Task.FromResult(dialogReference); + }); + + SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); + + // Act 1 + var cut = RenderComponent(); + + var instance = cut.Instance; + + var response = new WatchInteractionsResponseUpdate + { + InteractionId = 1, + InputsDialog = new InteractionInputsDialog() + }; + await interactionsChannel.Writer.WriteAsync(response); + + // Assert 1 + await AsyncTestHelpers.AssertIsTrueRetryAsync(() => + { + var reference = instance._interactionDialogReference; + if (reference == null) + { + return false; + } + + return dialogReference == reference.Dialog && reference.InteractionId == 1; + }, "Wait for dialog reference created."); + + // Act 2 + Assert.NotNull(dialogParameters); + Assert.NotNull(vm); + + await vm.OnSubmitCallback(response).DefaultTimeout(); + + var update = await sendInteractionUpdatesChannel.Reader.ReadAsync(); + + Assert.Equal(1, update.InteractionId); + Assert.Equal(WatchInteractionsRequestUpdate.KindOneofCase.InputsDialog, update.KindCase); + + await instance.DisposeAsync().DefaultTimeout(); + } + + private void SetupInteractionProviderServices(TestDashboardClient? dashboardClient = null, TestDialogService? dialogService = null) + { + var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper); + + Services.AddLocalization(); + Services.AddSingleton(loggerFactory); + + Services.AddSingleton(dialogService ?? new TestDialogService()); + Services.AddSingleton(); + Services.AddSingleton(dashboardClient ?? new TestDashboardClient()); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/LoginTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/LoginTests.cs index 6769972b408..2098bbbfc2b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/LoginTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/LoginTests.cs @@ -3,7 +3,6 @@ using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Tests.Shared; -using Aspire.Dashboard.Model; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Tests; using Bunit; diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs index 94eb4a6c5f0..1918d2963e2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDashboardClient.cs @@ -13,7 +13,9 @@ public class TestDashboardClient : IDashboardClient { private readonly Func>>? _consoleLogsChannelProvider; private readonly Func>>? _resourceChannelProvider; + private readonly Func>? _interactionChannelProvider; private readonly Channel? _resourceCommandsChannel; + private readonly Channel? _sendInteractionUpdateChannel; private readonly IList? _initialResources; public bool IsEnabled { get; } @@ -24,13 +26,17 @@ public TestDashboardClient( bool? isEnabled = false, Func>>? consoleLogsChannelProvider = null, Func>>? resourceChannelProvider = null, + Func>? interactionChannelProvider = null, Channel? resourceCommandsChannel = null, + Channel? sendInteractionUpdateChannel = null, IList? initialResources = null) { IsEnabled = isEnabled ?? false; _consoleLogsChannelProvider = consoleLogsChannelProvider; _resourceChannelProvider = resourceChannelProvider; + _interactionChannelProvider = interactionChannelProvider; _resourceCommandsChannel = resourceCommandsChannel; + _sendInteractionUpdateChannel = sendInteractionUpdateChannel; _initialResources = initialResources; } @@ -91,11 +97,31 @@ async static IAsyncEnumerable> BuildSubsc public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) { - throw new NotImplementedException(); + if (_interactionChannelProvider == null) + { + throw new InvalidOperationException("No channel provider set."); + } + + var channel = _interactionChannelProvider(); + + return BuildSubscription(channel, cancellationToken); + + async static IAsyncEnumerable BuildSubscription(Channel channel, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return item; + } + } } - public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) + public async Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) { - throw new NotImplementedException(); + if (_sendInteractionUpdateChannel == null) + { + throw new InvalidOperationException("No resource command channel set."); + } + + await _sendInteractionUpdateChannel.Writer.WriteAsync(request, cancellationToken); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestDialogService.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDialogService.cs new file mode 100644 index 00000000000..dc188e4f602 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestDialogService.cs @@ -0,0 +1,238 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +public class TestDialogService : IDialogService +{ + private readonly Func>? _onShowDialog; + + public TestDialogService(Func>? onShowDialog = null) + { + _onShowDialog = onShowDialog; + } + +#pragma warning disable CS0067 + public event Action? OnShow; + public event Func>? OnShowAsync; + public event Action? OnUpdate; + public event Func>? OnUpdateAsync; + public event Action? OnDialogCloseRequested; +#pragma warning restore CS0067 + + public Task CloseAsync(DialogReference dialog) + { + throw new NotImplementedException(); + } + + public Task CloseAsync(DialogReference dialog, DialogResult result) + { + throw new NotImplementedException(); + } + + public EventCallback CreateDialogCallback(object receiver, Func callback) + { + throw new NotImplementedException(); + } + + public void ShowConfirmation(object receiver, Func callback, string message, string primaryText = "Yes", string secondaryText = "No", string? title = null) + { + throw new NotImplementedException(); + } + + public Task ShowConfirmationAsync(object receiver, Func callback, string message, string primaryText = "Yes", string secondaryText = "No", string? title = null) + { + throw new NotImplementedException(); + } + + public Task ShowConfirmationAsync(string message, string primaryText = "Yes", string secondaryText = "No", string? title = null) + { + throw new NotImplementedException(); + } + + public void ShowDialog(DialogParameters parameters) + where TDialog : IDialogContentComponent + where TData : class + { + throw new NotImplementedException(); + } + + public void ShowDialog(Type dialogComponent, TData data, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } + + public Task ShowDialogAsync(Type dialogComponent, TData data, DialogParameters parameters) where TData : class + { + return _onShowDialog?.Invoke(data, parameters) ?? throw new InvalidOperationException("No dialog callback specified."); + } + + public Task ShowDialogAsync(object data, DialogParameters parameters) where TDialog : IDialogContentComponent + { + return _onShowDialog?.Invoke(data, parameters) ?? throw new InvalidOperationException("No dialog callback specified."); + } + + public Task ShowDialogAsync(DialogParameters parameters) where TDialog : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public Task ShowDialogAsync(RenderFragment renderFragment, DialogParameters dialogParameters) + { + throw new NotImplementedException(); + } + + public Task ShowDialogAsync(DialogParameters parameters) + where TDialog : IDialogContentComponent + where TData : class + { + throw new NotImplementedException(); + } + + public void ShowError(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public Task ShowErrorAsync(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public void ShowInfo(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public Task ShowInfoAsync(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public void ShowMessageBox(DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public Task ShowMessageBoxAsync(DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public void ShowPanel(DialogParameters parameters) + where TDialog : IDialogContentComponent + where TData : class + { + throw new NotImplementedException(); + } + + public void ShowPanel(Type dialogComponent, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } + + public Task ShowPanelAsync(Type dialogComponent, TData data, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } + + public Task ShowPanelAsync(object data, DialogParameters parameters) where TDialog : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public Task ShowPanelAsync(DialogParameters parameters) where TDialog : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public Task ShowPanelAsync(DialogParameters parameters) + where TDialog : IDialogContentComponent + where TData : class + { + throw new NotImplementedException(); + } + + public Task ShowPanelAsync(Type dialogComponent, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } + + public void ShowSplashScreen(object receiver, Func callback, DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public void ShowSplashScreen(object receiver, Func callback, DialogParameters parameters) where T : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public void ShowSplashScreen(Type component, object receiver, Func callback, DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(object receiver, Func callback, DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(object receiver, Func callback, DialogParameters parameters) where T : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(DialogParameters parameters) where T : IDialogContentComponent + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(Type component, object receiver, Func callback, DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public Task ShowSplashScreenAsync(Type component, DialogParameters parameters) + { + throw new NotImplementedException(); + } + + public void ShowSuccess(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public Task ShowSuccessAsync(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public void ShowWarning(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public Task ShowWarningAsync(string message, string? title = null, string? primaryText = null) + { + throw new NotImplementedException(); + } + + public void UpdateDialog(string id, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } + + public Task UpdateDialogAsync(string id, DialogParameters parameters) where TData : class + { + throw new NotImplementedException(); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index 27287dbd9d8..d78796810ab 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -38,5 +38,9 @@ + + + + diff --git a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs index 92857d7c085..48a690ba245 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs @@ -4,7 +4,6 @@ using System.Net; using System.Threading.Channels; using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index 67b48b87b19..c7a1042f49c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -3,7 +3,6 @@ using System.Reflection; using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.Model; using Aspire.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 7002d7bb980..389b7e0af2f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -2,15 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; -using Google.Protobuf.WellKnownTypes; using Aspire.DashboardService.Proto.V1; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; -using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Dashboard.Tests.Model; @@ -152,7 +152,6 @@ public async Task SubscribeResources_HasInitialData_InitialDataReturned() public async Task SubscribeInteractions_OnCancel_ChannelRemoved() { await using var instance = CreateResourceServiceClient(); - instance.SetInitialDataReceived(); IDashboardClient client = instance; @@ -182,7 +181,6 @@ public async Task SubscribeInteractions_OnCancel_ChannelRemoved() public async Task SubscribeInteractions_OnDispose_ChannelRemoved() { await using var instance = CreateResourceServiceClient(); - instance.SetInitialDataReceived(); IDashboardClient client = instance; @@ -220,7 +218,6 @@ public async Task SubscribeInteractions_ThrowsIfDisposed() public async Task SubscribeInteractions_IncreasesSubscriberCount() { await using var instance = CreateResourceServiceClient(); - instance.SetInitialDataReceived(); IDashboardClient client = instance; @@ -235,6 +232,79 @@ public async Task SubscribeInteractions_IncreasesSubscriberCount() Assert.Equal(0, instance.OutgoingInteractionSubscriberCount); } + [Fact] + public async Task WhenConnected_InteractionMethodUnimplemented_InteractionWatchCompleted() + { + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient()); + + await instance.WhenConnected.DefaultTimeout(); + + await instance.InteractionWatchCompleteTask.DefaultTimeout(); + } + + private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient + { + public override AsyncDuplexStreamingCall WatchInteractions(CallOptions options) + { + return new AsyncDuplexStreamingCall( + new ClientStreamWriter(), + new AsyncStreamReader(), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.Unimplemented, "Unimplemented!"), + () => new Metadata(), + () => { }); + } + + public override AsyncUnaryCall GetApplicationInformationAsync(ApplicationInformationRequest request, CallOptions options) + { + return new AsyncUnaryCall( + Task.FromResult(new ApplicationInformationResponse + { + ApplicationName = "TestApplication" + }), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + } + + public override AsyncServerStreamingCall WatchResources(WatchResourcesRequest request, CallOptions options) + { + return new AsyncServerStreamingCall( + new AsyncStreamReader(), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + } + } + + private sealed class AsyncStreamReader : IAsyncStreamReader + { + public T Current { get; } = default!; + + public Task MoveNext(CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + } + + private sealed class ClientStreamWriter : IClientStreamWriter + { + public WriteOptions? WriteOptions { get; set; } + + public Task CompleteAsync() + { + throw new NotImplementedException(); + } + + public Task WriteAsync(T message) + { + throw new NotImplementedException(); + } + } + private DashboardClient CreateResourceServiceClient() { return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new MockKnownPropertyLookup()); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 4a8db009c0c..3febbdbb15a 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -38,7 +38,7 @@ public async Task WatchResourceConsoleLogs_NoFollow_ResultsEnd() var resourceLoggerService = new ResourceLoggerService(); resourceLoggerService.SetConsoleLogsService(consoleLogsService); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService); + var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService); var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService); var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), NullLogger.Instance); @@ -91,7 +91,7 @@ public async Task WatchResourceConsoleLogs_LargePendingData_BatchResults() // Arrange const int LongLineCharacters = DashboardServiceImpl.LogMaxBatchCharacters / 3; var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService); + var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService); var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService); var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), NullLogger.Instance); @@ -143,7 +143,7 @@ public async Task WatchResources_ResourceHasCommands_CommandsSentWithResponse() var logger = loggerFactory.CreateLogger(); var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = new ResourceNotificationService(loggerFactory.CreateLogger(), new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService); + var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService); using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService); var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), loggerFactory.CreateLogger()); @@ -210,6 +210,154 @@ await dashboardServiceData.WaitForResourceAsync(testResource.Name, r => await CancelTokenAndAwaitTask(cts, task).DefaultTimeout(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(bool? result) + { + // Arrange + var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddXunit(testOutputHelper); + }); + + var logger = loggerFactory.CreateLogger(); + var interactionService = new InteractionService( + loggerFactory.CreateLogger(), + new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider()); + using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); + var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), loggerFactory.CreateLogger()); + + var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writer = new TestServerStreamWriter(context); + var reader = new TestAsyncStreamReader(context); + + // Act + logger.LogInformation("Calling WatchInteractions."); + var task = dashboardService.WatchInteractions( + reader, + writer, + context); + + var resultTask = interactionService.PromptMessageBoxAsync( + title: "Title!", + message: "Message!"); + + // Assert + logger.LogInformation("Reading result from writer."); + var update = await writer.ReadNextAsync().DefaultTimeout(); + + Assert.NotEqual(0, update.InteractionId); + Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.MessageBox, update.KindCase); + + Assert.False(resultTask.IsCompleted); + + logger.LogInformation("Send result to reader."); + if (result != null) + { + update.MessageBox.Result = result.Value; + reader.AddMessage(new WatchInteractionsRequestUpdate + { + InteractionId = update.InteractionId, + MessageBox = update.MessageBox + }); + + Assert.Equal(result, (await resultTask.DefaultTimeout()).Data); + } + else + { + reader.AddMessage(new WatchInteractionsRequestUpdate + { + InteractionId = update.InteractionId, + Complete = new InteractionComplete() + }); + + Assert.True((await resultTask.DefaultTimeout()).Canceled); + } + + await CancelTokenAndAwaitTask(cts, task).DefaultTimeout(); + } + + [Fact] + public async Task WatchInteractions_ReaderError_CompleteWithError() + { + // Arrange + var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddXunit(testOutputHelper); + }); + + var logger = loggerFactory.CreateLogger(); + var interactionService = new InteractionService( + loggerFactory.CreateLogger(), + new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider()); + using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); + var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), loggerFactory.CreateLogger()); + + var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writer = new TestServerStreamWriter(context); + var reader = new TestAsyncStreamReader(context); + + // Act + logger.LogInformation("Calling WatchInteractions."); + var task = dashboardService.WatchInteractions( + reader, + writer, + context); + + reader.Complete(new InvalidOperationException("Error!")); + + // Assert + await Assert.ThrowsAnyAsync(() => task).DefaultTimeout(); + } + + [Fact] + public async Task WatchInteractions_WriterError_CompleteWithError() + { + // Arrange + var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + builder.AddXunit(testOutputHelper); + }); + + var logger = loggerFactory.CreateLogger(); + var interactionService = new InteractionService( + loggerFactory.CreateLogger(), + new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider()); + using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService); + var dashboardService = new DashboardServiceImpl(dashboardServiceData, new TestHostEnvironment(), new TestHostApplicationLifetime(), loggerFactory.CreateLogger()); + + var cts = new CancellationTokenSource(); + var context = TestServerCallContext.Create(cancellationToken: cts.Token); + var writer = new TestServerStreamWriter(context); + var reader = new TestAsyncStreamReader(context); + + // Act + logger.LogInformation("Calling WatchInteractions."); + var task = dashboardService.WatchInteractions( + reader, + writer, + context); + + writer.Complete(new InvalidOperationException("Error!")); + + _ = interactionService.PromptMessageBoxAsync( + title: "Title!", + message: "Message!"); + + // Assert + await Assert.ThrowsAnyAsync(() => task).DefaultTimeout(); + } + [Fact] public void WithCommandOverloadNotAmbiguous() { @@ -226,21 +374,30 @@ public void WithCommandOverloadNotAmbiguous() } private static DashboardServiceData CreateDashboardServiceData( - ResourceLoggerService resourceLoggerService, - ResourceNotificationService resourceNotificationService, - ILoggerFactory? loggerFactory = null) + ResourceLoggerService? resourceLoggerService = null, + ResourceNotificationService? resourceNotificationService = null, + ILoggerFactory? loggerFactory = null, + InteractionService? interactionService = null) { + resourceLoggerService ??= new ResourceLoggerService(); loggerFactory ??= NullLoggerFactory.Instance; + resourceNotificationService ??= CreateResourceNotificationService(resourceLoggerService); + interactionService ??= new InteractionService( + NullLogger.Instance, + new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider()); return new DashboardServiceData( resourceNotificationService, resourceLoggerService, loggerFactory.CreateLogger(), new ResourceCommandService(resourceNotificationService, resourceLoggerService, new ServiceCollection().BuildServiceProvider()), - new InteractionService( - NullLogger.Instance, - new DistributedApplicationOptions(), - new ServiceCollection().BuildServiceProvider())); + interactionService); + } + + private static ResourceNotificationService CreateResourceNotificationService(ResourceLoggerService resourceLoggerService) + { + return new ResourceNotificationService(NullLogger.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService); } private sealed class TestHostEnvironment : IHostEnvironment diff --git a/tests/Aspire.Hosting.Tests/Utils/Grpc/TestAsyncStreamReader.cs b/tests/Aspire.Hosting.Tests/Utils/Grpc/TestAsyncStreamReader.cs index 529f8e11b40..8d38af2c673 100644 --- a/tests/Aspire.Hosting.Tests/Utils/Grpc/TestAsyncStreamReader.cs +++ b/tests/Aspire.Hosting.Tests/Utils/Grpc/TestAsyncStreamReader.cs @@ -27,9 +27,9 @@ public void AddMessage(T message) } } - public void Complete() + public void Complete(Exception? ex = null) { - _channel.Writer.Complete(); + _channel.Writer.Complete(ex); } public async Task MoveNext(CancellationToken cancellationToken) diff --git a/tests/Aspire.Hosting.Tests/Utils/Grpc/TestServerStreamWriter.cs b/tests/Aspire.Hosting.Tests/Utils/Grpc/TestServerStreamWriter.cs index 579b8028872..b1fa1959875 100644 --- a/tests/Aspire.Hosting.Tests/Utils/Grpc/TestServerStreamWriter.cs +++ b/tests/Aspire.Hosting.Tests/Utils/Grpc/TestServerStreamWriter.cs @@ -20,9 +20,9 @@ public TestServerStreamWriter(ServerCallContext serverCallContext) _serverCallContext = serverCallContext; } - public void Complete() + public void Complete(Exception? ex = null) { - _channel.Writer.Complete(); + _channel.Writer.Complete(ex); } public IAsyncEnumerable ReadAllAsync()