|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Net;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.ResourceService.Proto.V1;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Color = Microsoft.FluentUI.AspNetCore.Components.Color;
using MessageIntentDto = Aspire.ResourceService.Proto.V1.MessageIntent;
using MessageIntentUI = Microsoft.FluentUI.AspNetCore.Components.MessageIntent;
namespace Aspire.Dashboard.Components.Interactions;
public class InteractionsProvider : ComponentBase, IAsyncDisposable
{
private record InteractionMessageBarReference(WatchInteractionsResponseUpdate Interaction, Message Message);
private record InteractionDialogReference(WatchInteractionsResponseUpdate Interaction, IDialogReference Dialog);
private readonly CancellationTokenSource _cts = new();
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private readonly KeyedInteractionCollection _pendingInteractions = new();
private readonly List<InteractionMessageBarReference> _openMessageBars = new();
private Task? _interactionsDisplayTask;
private Task? _watchInteractionsTask;
private TaskCompletionSource _interactionAvailableTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private InteractionDialogReference? _interactionDialogReference;
[Inject]
public required IDashboardClient DashboardClient { get; init; }
[Inject]
public required IDialogService DialogService { get; init; }
[Inject]
public required IMessageService MessageService { get; init; }
[Inject]
public required IStringLocalizer<Resources.Dialogs> Loc { get; init; }
[Inject]
public required ILogger<InteractionsProvider> Logger { get; init; }
protected override void OnInitialized()
{
// Exit quickly if the dashboard client is not enabled. For example, the dashboard is running in the standalone container.
if (!DashboardClient.IsEnabled)
{
return;
}
_interactionsDisplayTask = Task.Run(async () =>
{
var waitForInteractionAvailableTask = Task.CompletedTask;
while (!_cts.IsCancellationRequested)
{
// If there are no pending interactions then wait on this task to get notified when one is added.
await waitForInteractionAvailableTask.WaitAsync(_cts.Token).ConfigureAwait(false);
IDialogReference? currentDialogReference = null;
await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false);
try
{
if (_pendingInteractions.Count == 0)
{
// Task is set when a new interaction is added.
// Continue here will exit the async lock and wait for the task to complete.
waitForInteractionAvailableTask = _interactionAvailableTcs.Task;
continue;
}
waitForInteractionAvailableTask = Task.CompletedTask;
var item = ((IList<WatchInteractionsResponseUpdate>)_pendingInteractions)[0];
_pendingInteractions.RemoveAt(0);
Func<IDialogService, Task<IDialogReference>> openDialog;
if (item.MessageBox is { } messageBox)
{
var dialogParameters = CreateDialogParameters(item, messageBox.Intent);
dialogParameters.OnDialogResult = EventCallback.Factory.Create<DialogResult>(this, async dialogResult =>
{
var request = new WatchInteractionsRequestUpdate
{
InteractionId = item.InteractionId
};
if (dialogResult.Cancelled)
{
// There will be data in the dialog result on cancel if the secondary button is clicked.
if (dialogResult.Data != null)
{
messageBox.Result = false;
request.MessageBox = messageBox;
}
else
{
request.Complete = new InteractionComplete();
}
}
else
{
messageBox.Result = true;
request.MessageBox = messageBox;
}
await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
});
var content = new MessageBoxContent
{
Title = item.Title,
MarkupMessage = new MarkupString(item.Message),
};
switch (messageBox.Intent)
{
case MessageIntentDto.Success:
content.IconColor = Color.Success;
content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.CheckmarkCircle();
break;
case MessageIntentDto.Warning:
content.IconColor = Color.Warning;
content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Warning();
break;
case MessageIntentDto.Error:
content.IconColor = Color.Error;
content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.DismissCircle();
break;
case MessageIntentDto.Information:
content.IconColor = Color.Info;
content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Info();
break;
case MessageIntentDto.Confirmation:
content.IconColor = Color.Success;
content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.QuestionCircle();
break;
}
openDialog = dialogService => ShowMessageBoxAsync(dialogService, content, dialogParameters);
}
else if (item.InputsDialog is { } inputs)
{
var vm = new InteractionsInputsDialogViewModel
{
Interaction = item,
Inputs = inputs.InputItems.ToList()
};
var dialogParameters = CreateDialogParameters(item, intent: null);
dialogParameters.OnDialogResult = EventCallback.Factory.Create<DialogResult>(this, async dialogResult =>
{
var request = new WatchInteractionsRequestUpdate
{
InteractionId = item.InteractionId
};
if (dialogResult.Cancelled)
{
request.Complete = new InteractionComplete();
}
else
{
request.InputsDialog = item.InputsDialog;
}
await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
});
openDialog = dialogService => dialogService.ShowDialogAsync<InteractionsInputDialog>(vm, dialogParameters);
}
else
{
Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase);
continue;
}
await InvokeAsync(async () =>
{
currentDialogReference = await openDialog(DialogService);
});
Debug.Assert(currentDialogReference != null, "Dialog should have been created in UI thread.");
_interactionDialogReference = new InteractionDialogReference(item, currentDialogReference);
}
finally
{
_semaphore.Release();
}
try
{
if (currentDialogReference != null)
{
await currentDialogReference.Result.WaitAsync(_cts.Token);
}
}
catch
{
// Ignore any exceptions that occur while waiting for the dialog to close.
}
}
});
_watchInteractionsTask = Task.Run(async () =>
{
var interactions = DashboardClient.SubscribeInteractionsAsync(_cts.Token);
await foreach (var item in interactions)
{
await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false);
try
{
switch (item.KindCase)
{
case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox:
case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog:
// New or updated interaction.
_pendingInteractions.Remove(item.InteractionId);
_pendingInteractions.Add(item);
NotifyInteractionAvailable();
break;
case WatchInteractionsResponseUpdate.KindOneofCase.MessageBar:
var messageBar = item.MessageBar;
Message? message = null;
await InvokeAsync(async () =>
{
message = await MessageService.ShowMessageBarAsync(options =>
{
options.Title = WebUtility.HtmlEncode(item.Title);
options.Body = item.Message; // Message is already HTML encoded depending on options.
options.Intent = MapMessageIntent(messageBar.Intent);
options.Section = DashboardUIHelpers.MessageBarSection;
options.AllowDismiss = item.ShowDismiss;
var primaryButtonText = item.PrimaryButtonText;
var secondaryButtonText = item.SecondaryButtonText;
if (messageBar.Intent == MessageIntentDto.Confirmation)
{
primaryButtonText = string.IsNullOrEmpty(primaryButtonText) ? "OK" : primaryButtonText;
secondaryButtonText = string.IsNullOrEmpty(secondaryButtonText) ? "Cancel" : secondaryButtonText;
}
bool? result = null;
if (!string.IsNullOrEmpty(primaryButtonText))
{
options.PrimaryAction = new ActionButton<Message>
{
Text = primaryButtonText,
OnClick = m =>
{
result = true;
m.Close();
return Task.CompletedTask;
}
};
}
if (item.ShowSecondaryButton && !string.IsNullOrEmpty(secondaryButtonText))
{
options.SecondaryAction = new ActionButton<Message>
{
Text = secondaryButtonText,
OnClick = m =>
{
result = false;
m.Close();
return Task.CompletedTask;
}
};
}
options.OnClose = async m =>
{
// Only send complete notification if in the open message bars list.
var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId);
if (openMessageBar != null)
{
var request = new WatchInteractionsRequestUpdate
{
InteractionId = item.InteractionId
};
if (result == null)
{
request.Complete = new InteractionComplete();
}
else
{
messageBar.Result = result.Value;
request.MessageBar = messageBar;
}
_openMessageBars.Remove(openMessageBar);
await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
}
};
});
});
Debug.Assert(message != null, "Message should have been created in UI thread.");
_openMessageBars.Add(new InteractionMessageBarReference(item, message));
break;
case WatchInteractionsResponseUpdate.KindOneofCase.Complete:
// Complete interaction.
_pendingInteractions.Remove(item.InteractionId);
// Close the interaction's dialog if it is open.
if (_interactionDialogReference?.Interaction.InteractionId == item.InteractionId)
{
try
{
await InvokeAsync(async () =>
{
await _interactionDialogReference.Dialog.CloseAsync();
});
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Unexpected error when closing interaction {InteractionId} dialog reference.", item.InteractionId);
}
finally
{
_interactionDialogReference = null;
}
}
var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId);
if (openMessageBar != null)
{
// Open message bars is used to decide whether to report completion to the server.
// It's already complete so remove before close.
_openMessageBars.Remove(openMessageBar);
// InvokeAsync not necessary here. It is called internally.
openMessageBar.Message.Close();
}
break;
default:
Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase);
break;
}
}
finally
{
_semaphore.Release();
}
}
});
}
private static MessageIntentUI MapMessageIntent(MessageIntentDto intent)
{
switch (intent)
{
case MessageIntentDto.Success:
return MessageIntentUI.Success;
case MessageIntentDto.Warning:
return MessageIntentUI.Warning;
case MessageIntentDto.Error:
return MessageIntentUI.Error;
case MessageIntentDto.Information:
return MessageIntentUI.Info;
default:
return MessageIntentUI.Info;
}
}
private DialogParameters CreateDialogParameters(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent)
{
var dialogParameters = new DialogParameters
{
ShowDismiss = interaction.ShowDismiss,
DismissTitle = Loc[nameof(Resources.Dialogs.DialogCloseButtonText)],
PrimaryAction = ResolvedPrimaryButtonText(interaction, intent),
SecondaryAction = ResolvedSecondaryButtonText(interaction),
PreventDismissOnOverlayClick = true,
Title = interaction.Title
};
return dialogParameters;
}
private string ResolvedPrimaryButtonText(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent)
{
if (interaction.PrimaryButtonText is { Length: > 0 } primaryText)
{
return primaryText;
}
if (intent == MessageIntentDto.Error)
{
return Loc[nameof(Resources.Dialogs.InteractionButtonClose)];
}
return Loc[nameof(Resources.Dialogs.InteractionButtonOk)];
}
private string ResolvedSecondaryButtonText(WatchInteractionsResponseUpdate interaction)
{
if (!interaction.ShowSecondaryButton)
{
return string.Empty;
}
return interaction.SecondaryButtonText is { Length: > 0 } secondaryText
? secondaryText
: Loc[nameof(Resources.Dialogs.InteractionButtonCancel)];
}
public async Task<IDialogReference> ShowMessageBoxAsync(IDialogService dialogService, MessageBoxContent content, DialogParameters parameters)
{
var dialogParameters = new DialogParameters
{
DialogType = DialogType.MessageBox,
Alignment = HorizontalAlignment.Center,
Title = content.Title,
ShowDismiss = false,
PrimaryAction = parameters.PrimaryAction,
SecondaryAction = parameters.SecondaryAction,
Width = parameters.Width,
Height = parameters.Height,
AriaLabel = (content.Title ?? ""),
OnDialogResult = parameters.OnDialogResult
};
return await dialogService.ShowDialogAsync(typeof(MessageBox), content, dialogParameters);
}
private void NotifyInteractionAvailable()
{
// Let current waiters know that an interaction is available.
_interactionAvailableTcs.TrySetResult();
// Reset the task completion source for future waiters.
_interactionAvailableTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
await TaskHelpers.WaitIgnoreCancelAsync(_interactionsDisplayTask);
await TaskHelpers.WaitIgnoreCancelAsync(_watchInteractionsTask);
}
private class KeyedInteractionCollection : KeyedCollection<int, WatchInteractionsResponseUpdate>
{
protected override int GetKeyForItem(WatchInteractionsResponseUpdate item)
{
return item.InteractionId;
}
}
}
|