File: Model\DashboardCommandExecutor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using FluentMessageIntent = Microsoft.FluentUI.AspNetCore.Components.MessageBarIntent;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
 
namespace Aspire.Dashboard.Model;
 
public sealed class DashboardCommandExecutor(
    IDashboardClient dashboardClient,
    DashboardDialogService dialogService,
    Microsoft.FluentUI.AspNetCore.Components.INotificationService toastService,
    IStringLocalizer<Dashboard.Resources.Resources> loc,
    NavigationManager navigationManager,
    DashboardTelemetryService telemetryService,
    INotificationService notificationService)
{
    private readonly HashSet<(string ResourceName, string CommandName)> _executingCommands = [];
    private readonly object _lock = new object();
 
    public bool IsExecuting(string resourceName, string commandName)
    {
        lock (_lock)
        {
            return _executingCommands.Contains((resourceName, commandName));
        }
    }
 
    public async Task ExecuteAsync(ResourceViewModel resource, CommandViewModel command, Func<ResourceViewModel, string> getResourceName)
    {
        var executingCommandKey = (resource.Name, command.Name);
        lock (_lock)
        {
            _executingCommands.Add(executingCommandKey);
        }
 
        var startEvent = telemetryService.StartOperation(TelemetryEventKeys.ExecuteCommand,
            new Dictionary<string, AspireTelemetryProperty>
            {
                { TelemetryPropertyKeys.ResourceType, new AspireTelemetryProperty(TelemetryPropertyValues.GetResourceTypeTelemetryValue(resource.ResourceType, resource.SupportsDetailedTelemetry)) },
                { TelemetryPropertyKeys.CommandName, new AspireTelemetryProperty(TelemetryPropertyValues.GetCommandNameTelemetryValue(command.Name)) },
            });
 
        var operationId = startEvent.Properties.FirstOrDefault();
 
        try
        {
            await ExecuteAsyncCore(resource, command, getResourceName).ConfigureAwait(false);
 
            if (operationId is not null)
            {
                telemetryService.EndOperation(operationId, TelemetryResult.Success);
            }
        }
        catch (Exception ex)
        {
            if (operationId is not null)
            {
                telemetryService.EndOperation(operationId, TelemetryResult.Failure, ex.Message);
            }
        }
        finally
        {
            // There may be a delay between a command finishing and the arrival of a new resource state with updated commands sent to the client.
            // For example:
            // 1. Click the stop command on a resource. The command is disabled while running.
            // 2. The stop command finishes, and it is re-enabled.
            // 3. A new resource state arrives in the dashboard, replacing the stop command with the run command.
            //
            // To prevent the stop command from being temporarily enabled, introduce a delay between a command finishing and re-enabling it in the dashboard.
            // This delay is chosen to balance avoiding an incorrect temporary state (since the new resource state should arrive within a second) and maintaining responsiveness.
            await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
 
            lock (_lock)
            {
                _executingCommands.Remove(executingCommandKey);
            }
        }
    }
 
    public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel command, Func<ResourceViewModel, string> getResourceName)
    {
        if (!string.IsNullOrWhiteSpace(command.ConfirmationMessage))
        {
            var result = await dialogService.ShowConfirmationAsync(command.ConfirmationMessage).ConfigureAwait(false);
            if (result.Cancelled)
            {
                return;
            }
        }
 
        var messageBarStartingTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], command.GetDisplayName());
        var toastStartingTitle = $"{getResourceName(resource)} {messageBarStartingTitle}";
 
        using var executeCommandCts = new CancellationTokenSource();
        var cancelingTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandCanceling)], command.GetDisplayName());
        var cancelRequested = false;
        var cancelLock = new object();
 
        // Fluent v5 retains the ToastOptions instance, which lets us update an open progress toast in place.
        // Use a manual lifetime because Fluent does not expose a way to restart a rendered toast's lifetime
        // when those options change from progress to a result.
        var toastId = Guid.NewGuid().ToString();
        var toastOptions = new ToastOptions
        {
            Id = toastId,
            Class = "resource-command-toast",
            Width = "350px",
            Intent = ToastIntent.Progress,
            Title = toastStartingTitle,
            Lifetime = TimeSpan.Zero
        };
 
        string? progressNotificationId = null;
        progressNotificationId = notificationService.AddNotification(new NotificationEntry
        {
            Title = messageBarStartingTitle,
            Intent = FluentMessageIntent.Info,
            PrimaryAction = CreateCancelNotificationAction(loc, RequestCancelAsync)
        });
 
        toastOptions.QuickAction1.Label = loc[nameof(Dashboard.Resources.Resources.ResourceCommandCancel)];
        toastOptions.QuickAction1.OnClickAsync = _ => RequestCancelAsync();
 
        ResourceCommandResponseViewModel response;
        using var progressToastCloseCts = new CancellationTokenSource();
        await toastService.ShowToastAsync(toastOptions).ConfigureAwait(false);
        // Keep the progress timeout cancellable so command completion can replace it with a full result timeout.
        var progressToastCloseTask = CloseToastAfterDelayAsync(toastId, progressToastCloseCts.Token);
 
        try
        {
            response = await dashboardClient.ExecuteResourceCommandAsync(
                resource.Name,
                resource.ResourceType,
                command,
                new ExecuteResourceCommandOptions(),
                executeCommandCts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (executeCommandCts.IsCancellationRequested)
        {
            response = new ResourceCommandResponseViewModel
            {
                Kind = ResourceCommandResponseKind.Cancelled
            };
        }
 
        // Update toast and notification with the result.
        ClearToastActions(toastOptions);
        if (response.Kind == ResourceCommandResponseKind.Succeeded)
        {
            var successTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], command.GetDisplayName());
            toastOptions.Title = $"{getResourceName(resource)} {successTitle}";
            toastOptions.Intent = ToastIntent.Success;
            toastOptions.Icon = GetIntentIcon(ToastIntent.Success);
 
            if (response.Result is not null)
            {
                toastOptions.QuickAction1.Label = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)];
                toastOptions.QuickAction1.OnClickAsync = _ => OpenViewResponseDialogAsync(dialogService, command, response);
            }
 
            notificationService.ReplaceNotification(GetProgressNotificationId(), new NotificationEntry
            {
                Title = successTitle,
                Body = response.Message,
                Intent = FluentMessageIntent.Success,
                PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(loc, command, response) : null
            });
 
            if (response.Result?.DisplayImmediately == true)
            {
                await OpenViewResponseDialogAsync(dialogService, command, response).ConfigureAwait(false);
            }
        }
        else if (response.Kind == ResourceCommandResponseKind.Cancelled)
        {
            var canceledTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandCanceled)], command.GetDisplayName());
 
            // For cancelled commands, just close the existing toast and don't show any success or error message.
            progressToastCloseCts.Cancel();
            await progressToastCloseTask.ConfigureAwait(false);
            await toastService.CloseAsync(toastId).ConfigureAwait(false);
 
            notificationService.ReplaceNotification(GetProgressNotificationId(), new NotificationEntry
            {
                Title = canceledTitle,
                Body = response.Message,
                Intent = FluentMessageIntent.Info,
            });
            return;
        }
        else
        {
            var failedTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], command.GetDisplayName());
            toastOptions.Title = $"{getResourceName(resource)} {failedTitle}";
            toastOptions.Intent = ToastIntent.Error;
            toastOptions.Icon = GetIntentIcon(ToastIntent.Error);
            toastOptions.QuickAction1.Label = loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)];
            toastOptions.QuickAction1.OnClickAsync = _ =>
            {
                navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)));
                return Task.CompletedTask;
            };
            toastOptions.Message = response.Message;
 
            if (response.Result is not null)
            {
                toastOptions.QuickAction2.Label = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)];
                toastOptions.QuickAction2.OnClickAsync = _ => OpenViewResponseDialogAsync(dialogService, command, response);
            }
 
            notificationService.ReplaceNotification(GetProgressNotificationId(), new NotificationEntry
            {
                Title = failedTitle,
                Body = response.Message,
                Intent = FluentMessageIntent.Error,
                PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(loc, command, response) : null
            });
 
            if (response.Result?.DisplayImmediately == true)
            {
                await OpenViewResponseDialogAsync(dialogService, command, response).ConfigureAwait(false);
            }
        }
 
        progressToastCloseCts.Cancel();
        await progressToastCloseTask.ConfigureAwait(false);
        if (IsToastOpen(toastId))
        {
            // ToastInstance references toastOptions, so the notification replacement above causes MainLayout
            // to rerender FluentToastProvider with the result values without replacing the toast component.
            // Restart the close delay so the result remains visible for the full timeout.
            _ = CloseToastAfterDelayAsync(toastId, CancellationToken.None);
        }
        else
        {
            // Fluent keeps dismissed toasts registered during their exit animation. Use a fresh ID when the
            // progress toast is no longer open and let Fluent manage the new result toast's lifetime.
            toastOptions.Id = Guid.NewGuid().ToString();
            toastOptions.Lifetime = DashboardUIHelpers.ToastTimeout;
            await toastService.ShowToastAsync(toastOptions).ConfigureAwait(false);
        }
 
        Task RequestCancelAsync()
        {
            lock (cancelLock)
            {
                if (cancelRequested)
                {
                    return Task.CompletedTask;
                }
 
                cancelRequested = true;
            }
 
            executeCommandCts.Cancel();
            ClearToastActions(toastOptions);
            toastOptions.Title = $"{getResourceName(resource)} {cancelingTitle}";
            toastOptions.Intent = ToastIntent.Progress;
            toastOptions.Icon = GetIntentIcon(ToastIntent.Progress);
            toastOptions.Lifetime = TimeSpan.Zero;
 
            notificationService.ReplaceNotification(GetProgressNotificationId(), new NotificationEntry
            {
                Title = cancelingTitle,
                Intent = FluentMessageIntent.Info,
            });
 
            return Task.CompletedTask;
        }
 
        bool IsToastOpen(string id)
        {
            // Dismissed instances remain discoverable until Fluent finishes their exit animation, but they
            // can no longer be updated in place.
            return toastService.GetToastInstance(id)?.LifecycleStatus is ToastLifecycleStatus.Queued or ToastLifecycleStatus.Visible;
        }
 
        async Task CloseToastAfterDelayAsync(string id, CancellationToken cancellationToken)
        {
            try
            {
                await Task.Delay(DashboardUIHelpers.ToastTimeout, cancellationToken).ConfigureAwait(false);
                await toastService.CloseAsync(id).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // Command completion cancels the progress timeout before scheduling the result timeout.
            }
        }
 
        string GetProgressNotificationId()
        {
            return progressNotificationId ?? throw new InvalidOperationException("The progress notification has not been created.");
        }
    }
 
    // Copied from FluentUI.
    private static Icon? GetIntentIcon(ToastIntent intent)
    {
        return intent switch
        {
            ToastIntent.Success => new Icons.Filled.Size24.CheckmarkCircle().WithColor(Color.Success),
            ToastIntent.Warning => new Icons.Filled.Size24.Warning().WithColor(Color.Warning),
            ToastIntent.Error => new Icons.Filled.Size24.DismissCircle().WithColor(Color.Error),
            ToastIntent.Info => new Icons.Filled.Size24.Info().WithColor(Color.Info),
            ToastIntent.Progress => new Icons.Regular.Size24.Flash(),
            _ => throw new InvalidOperationException()
        };
    }
 
    private static void ClearToastActions(ToastOptions toastOptions)
    {
        toastOptions.QuickAction1.Label = null;
        toastOptions.QuickAction1.OnClickAsync = null;
        toastOptions.QuickAction2.Label = null;
        toastOptions.QuickAction2.OnClickAsync = null;
    }
 
    private static NotificationAction CreateCancelNotificationAction(IStringLocalizer<Dashboard.Resources.Resources> loc, Func<Task> onCancelAsync)
    {
        return new NotificationAction
        {
            Text = loc[nameof(Dashboard.Resources.Resources.ResourceCommandCancel)],
            OnClick = _ => onCancelAsync()
        };
    }
 
    private static NotificationAction CreateViewResponseNotificationAction(IStringLocalizer<Dashboard.Resources.Resources> loc, CommandViewModel command, ResourceCommandResponseViewModel response)
    {
        return new NotificationAction
        {
            Text = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)],
            OnClick = (services) =>
            {
                // Get dialog service from passed in services since this data is long lived.
                // Using the dialog service from executor could cause closure over scoped services.
                var dialogService = services.GetRequiredService<DashboardDialogService>();
                return OpenViewResponseDialogAsync(dialogService, command, response);
            }
        };
    }
 
    private static async Task OpenViewResponseDialogAsync(DashboardDialogService dialogService, CommandViewModel command, ResourceCommandResponseViewModel response)
    {
        var fixedFormat = response.Result!.Format switch
        {
            CommandResultFormat.Json => DashboardUIHelpers.JsonFormat,
            CommandResultFormat.Markdown => DashboardUIHelpers.MarkdownFormat,
            _ => null
        };
 
        var reference = await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
        {
            DialogService = dialogService,
            ValueDescription = command.GetDisplayName(),
            Value = response.Result.Value,
            FixedFormat = fixedFormat
        }).ConfigureAwait(true);
 
        // Await the result to wait here until the dialog is closed.
        await reference.Result.ConfigureAwait(true);
    }
}