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