File: Dashboard\DashboardServiceData.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using Aspire.DashboardService.Proto.V1;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Dashboard;
 
/// <summary>
/// Models the state for <see cref="DashboardService"/>, as that service is constructed
/// for each gRPC request. This long-lived object holds state across requests.
/// </summary>
internal sealed class DashboardServiceData : IDisposable
{
    private readonly CancellationTokenSource _cts = new();
    private readonly ResourcePublisher _resourcePublisher;
    private readonly ResourceCommandService _resourceCommandService;
    private readonly InteractionService _interactionService;
    private readonly ResourceLoggerService _resourceLoggerService;
    private readonly IInteractionFileUploadStore _fileUploadStore;
    private readonly ILogger<DashboardServiceData> _logger;
 
    public DashboardServiceData(
        ResourceNotificationService resourceNotificationService,
        ResourceLoggerService resourceLoggerService,
        ILogger<DashboardServiceData> logger,
        ResourceCommandService resourceCommandService,
        InteractionService interactionService,
        IInteractionFileUploadStore fileUploadStore)
    {
        _resourceLoggerService = resourceLoggerService;
        _resourcePublisher = new ResourcePublisher(_cts.Token);
        _resourceCommandService = resourceCommandService;
        _interactionService = interactionService;
        _fileUploadStore = fileUploadStore;
        _logger = logger;
        var cancellationToken = _cts.Token;
 
        Task.Run(async () =>
        {
            static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, string resourceId, DateTime creationTimestamp, CustomResourceSnapshot snapshot)
            {
                // If the resource has a TerminalAnnotation, stamp the per-replica terminal
                // properties onto the snapshot so the Dashboard can:
                //   * detect that a terminal is available (HasTerminal),
                //   * build a /api/terminal?resource=<name>&replica=<index> URL pointing
                //     at the right replica (TryGetTerminalReplicaInfo).
                //
                // The dashboard never *follows* this path itself - it only displays it
                // (masked) in the resource details panel and uses it via
                // ITerminalConnectionResolver, which resolves replica -> UDS server-side
                // so an authenticated browser cannot coerce the dashboard into
                // connecting to arbitrary UDS endpoints by tampering with the path.
                //
                // The same stamping is applied on the auxiliary backchannel path so that
                // `aspire describe` and the VS Code extension observe identical terminal
                // metadata; both call sites share TerminalResourceSnapshotProperties.
                var terminalProperties = TerminalResourceSnapshotProperties.AddTerminalProperties(resource, resourceId, snapshot.Properties);
 
                // ImmutableArray's == operator compares the underlying array reference, so this is
                // true only when AddTerminalProperties returned the original array unchanged (no
                // TerminalAnnotation). Avoid rebuilding the snapshot in that common case.
                if (terminalProperties != snapshot.Properties)
                {
                    snapshot = snapshot with { Properties = terminalProperties };
                }
 
                return new GenericResourceSnapshot(snapshot)
                {
                    Uid = resourceId,
                    CreationTimeStamp = snapshot.CreationTimeStamp ?? creationTimestamp,
                    StartTimeStamp = snapshot.StartTimeStamp,
                    StopTimeStamp = snapshot.StopTimeStamp,
                    Name = resourceId,
                    DisplayName = resource.Name,
                    Urls = snapshot.Urls,
                    Volumes = snapshot.Volumes,
                    Environment = snapshot.EnvironmentVariables,
                    Relationships = snapshot.Relationships,
                    ExitCode = snapshot.ExitCode,
                    State = snapshot.State?.Text,
                    StateStyle = snapshot.State?.Style,
                    HealthReports = snapshot.HealthReports,
                    Commands = snapshot.Commands,
                    IsHidden = snapshot.IsHidden,
                    SupportsDetailedTelemetry = snapshot.SupportsDetailedTelemetry,
                    IconName = snapshot.IconName,
                    IconVariant = snapshot.IconVariant
                };
            }
 
            var timestamp = DateTime.UtcNow;
 
            await foreach (var @event in resourceNotificationService.WatchAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
            {
                try
                {
                    var snapshot = CreateResourceSnapshot(@event.Resource, @event.ResourceId, timestamp, @event.Snapshot);
 
                    if (logger.IsEnabled(LogLevel.Debug))
                    {
                        logger.LogDebug("Updating resource snapshot for {Name}/{DisplayName}: {State}", snapshot.Name, snapshot.DisplayName, snapshot.State);
                    }
 
                    await _resourcePublisher.IntegrateAsync(@event.Resource, snapshot, ResourceSnapshotChangeType.Upsert)
                            .ConfigureAwait(false);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    logger.LogError(ex, "Error updating resource snapshot for {Name}", @event.Resource.Name);
                }
            }
        },
        cancellationToken);
    }
 
    public void Dispose()
    {
        _cts.Cancel();
        _cts.Dispose();
    }
 
    internal async Task<(ExecuteCommandResultType result, string? message, ApplicationModel.CommandResultData? value, InteractionInputCollection? invalidArguments)> ExecuteCommandAsync(string resourceId, string type, ExecuteResourceCommandOptions options, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(options);
 
        try
        {
            var result = await _resourceCommandService.ExecuteCommandAsync(
                resourceId,
                type,
                new ResourceCommandExecutionOptions
                {
                    ArgumentValues = options.ArgumentValues,
                    ArgumentsProvided = options.ArgumentValues is not null,
                    NonInteractive = options.NonInteractive
                },
                cancellationToken).ConfigureAwait(false);
            if (result.Canceled)
            {
                return (ExecuteCommandResultType.Canceled, result.Message, null, null);
            }
            return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.Message, result.Data, result.InvalidArguments);
        }
        catch
        {
            // Note: Exception is already logged in the command executor.
            return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.", null, null);
        }
    }
 
    internal IAsyncEnumerable<Interaction> SubscribeInteractionUpdates()
    {
        return _interactionService.SubscribeInteractionUpdates();
    }
 
    internal ResourceSnapshotSubscription SubscribeResources()
    {
        return _resourcePublisher.Subscribe();
    }
 
    internal IAsyncEnumerable<IReadOnlyList<LogLine>>? SubscribeConsoleLogs(string resourceName)
    {
        var sequence = _resourceLoggerService.WatchAsync(resourceName);
 
        return sequence is null ? null : Enumerate();
 
        async IAsyncEnumerable<IReadOnlyList<LogLine>> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
 
            await foreach (var item in sequence.WithCancellation(linked.Token).ConfigureAwait(false))
            {
                yield return item;
            }
        }
    }
 
    internal IAsyncEnumerable<IReadOnlyList<LogLine>>? GetConsoleLogs(string resourceName)
    {
        var sequence = _resourceLoggerService.GetAllAsync(resourceName);
 
        return sequence is null ? null : Enumerate();
 
        async IAsyncEnumerable<IReadOnlyList<LogLine>> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
 
            await foreach (var item in sequence.WithCancellation(linked.Token).ConfigureAwait(false))
            {
                yield return item;
            }
        }
    }
 
    internal async Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken)
    {
        await _interactionService.ProcessInteractionFromClientAsync(
            request.InteractionId,
            (interaction, serviceProvider, logger) =>
            {
                switch (request.KindCase)
                {
                    case WatchInteractionsRequestUpdate.KindOneofCase.MessageBox:
                        return new InteractionCompletionState { Complete = true, State = request.MessageBox.Result };
                    case WatchInteractionsRequestUpdate.KindOneofCase.Notification:
                        return new InteractionCompletionState { Complete = true, State = request.Notification.Result };
                    case WatchInteractionsRequestUpdate.KindOneofCase.PromptProgress:
                        return new InteractionCompletionState { Complete = true, State = request.PromptProgress.Result };
                    case WatchInteractionsRequestUpdate.KindOneofCase.InputsDialog:
                        var inputsInfo = (Interaction.InputsInteractionInfo)interaction.InteractionInfo;
                        var options = (InputsDialogInteractionOptions)interaction.Options;
                        var submittedInputs = request.InputsDialog.InputItems
                            .Select(i => new InputDto(i.Name, i.Value, DashboardService.MapInputType(i.InputType)))
                            .ToList();
                        var inputDtos = CreateInputDtos(_fileUploadStore, interaction.InteractionId, inputsInfo, submittedInputs);
 
                        ProcessInputs(
                            serviceProvider,
                            logger,
                            inputsInfo,
                            inputDtos,
                            request.ResponseUpdate,
                            interaction.CancellationToken);
 
                        // If the interaction was sent to the server because an input changed, don't try to complete the interaction.
                        return new InteractionCompletionState { Complete = !request.ResponseUpdate, State = inputsInfo.Inputs };
                    default:
                        return new InteractionCompletionState { Complete = true };
                }
            },
            cancellationToken).ConfigureAwait(false);
    }
 
    public record InputFileDto(string Id, string Name, string FilePath, Action? Delete = null);
 
    public record InputDto(string Name, string Value, InputType InputType, IReadOnlyList<InputFileDto>? Files = null);
 
    internal static List<InputDto> CreateInputDtos(
        IInteractionFileUploadStore fileUploadStore,
        int interactionId,
        Interaction.InputsInteractionInfo inputsInfo,
        IReadOnlyList<InputDto> submittedInputs)
    {
        var submittedInputsByName = new Dictionary<string, InputDto>(StringComparers.InteractionInputName);
        foreach (var submittedInput in submittedInputs)
        {
            submittedInputsByName[submittedInput.Name] = submittedInput;
        }
 
        var inputDtos = new List<InputDto>(inputsInfo.Inputs.Count);
        foreach (var input in inputsInfo.Inputs)
        {
            if (submittedInputsByName.TryGetValue(input.Name, out var submittedInput))
            {
                var files = input.InputType == InputType.File
                    ? GetAcceptedFiles(fileUploadStore, submittedInput.Value, interactionId, input.Name)
                    : null;
                inputDtos.Add(new InputDto(input.Name, submittedInput.Value, input.InputType, files));
            }
            else if (input.InputType == InputType.File)
            {
                var files = GetAcceptedFiles(fileUploadStore, jsonValue: null, interactionId, input.Name);
                inputDtos.Add(new InputDto(input.Name, input.Value ?? string.Empty, input.InputType, files));
            }
        }
 
        return inputDtos;
    }
 
    internal static IReadOnlyList<InputFileDto>? GetAcceptedFiles(
        IInteractionFileUploadStore fileUploadStore,
        string? jsonValue,
        int interactionId,
        string inputName)
    {
        IReadOnlyList<InteractionFileUpload>? files = fileUploadStore.GetCompletedFiles(interactionId, inputName);
        if (files.Count == 0)
        {
            files = null;
        }
        files = InteractionFileUploadStore.ValidateFileReferences(jsonValue, inputName, files);
        if (files is null)
        {
            return null;
        }
 
        fileUploadStore.MarkFilesAccepted(interactionId, inputName, files.Select(file => file.Id).ToArray());
        return files
            .Select(file => new InputFileDto(
                file.Id,
                file.Name,
                file.FilePath,
                () => fileUploadStore.RemoveEntry(interactionId, file.Id)))
            .ToArray();
    }
 
    public static void ProcessInputs(IServiceProvider serviceProvider, ILogger logger, Interaction.InputsInteractionInfo inputsInfo, List<InputDto> inputDtos, bool dependencyChange, CancellationToken cancellationToken)
    {
        var choiceInteractionsToUpdate = new HashSet<InteractionInput>();
 
        for (var i = 0; i < inputDtos.Count; i++)
        {
            var requestInput = inputDtos[i];
            if (!inputsInfo.Inputs.TryGetByName(requestInput.Name, out var modelInput))
            {
                continue;
            }
 
            var incomingValue = requestInput.Value;
 
            // Ensure checkbox value is either true or false.
            if (requestInput.InputType == InputType.Boolean)
            {
                incomingValue = (bool.TryParse(incomingValue, out var b) && b) ? "true" : "false";
            }
 
            if (!string.Equals(modelInput.Value ?? string.Empty, incomingValue ?? string.Empty) || requestInput.InputType == InputType.File)
            {
                modelInput.Value = incomingValue;
 
                // For File inputs, build InteractionFile instances from the resolved file info.
                if (requestInput.InputType == InputType.File)
                {
                    if (requestInput.Files is { Count: > 0 })
                    {
                        var interactionFiles = requestInput.Files
                            .Select(f => new InteractionFile(f.Id, f.Name, f.FilePath))
                            .ToArray();
                        modelInput.SetFiles(new InteractionFileCollection(
                            interactionFiles,
                            () =>
                            {
                                foreach (var file in requestInput.Files)
                                {
                                    file.Delete?.Invoke();
                                }
                            }));
                    }
                    else
                    {
                        // Clear stale file references when the selection is empty.
                        modelInput.SetFiles(new InteractionFileCollection([]));
                    }
                }
 
                // If we're processing updates because of a dependency change, check to see if this input is depended on.
                if (dependencyChange)
                {
                    // Response updates are sent by the dashboard when an input marked
                    // UpdateStateOnChange changes. Only queue dependents whose source value actually
                    // changed; otherwise a validation/update roundtrip for one field could repeatedly
                    // restart unrelated dynamic loads. Inputs that need an initial load before any user
                    // change must opt into AlwaysLoadOnStart.
                    var dependentInputs = inputsInfo.Inputs.Where(
                        i => i.DynamicLoading is { } dynamic &&
                        (dynamic.DependsOnInputs?.Any(d => string.Equals(modelInput.Name, d, StringComparisons.InteractionInputName)) ?? false));
 
                    foreach (var dependentInput in dependentInputs)
                    {
                        choiceInteractionsToUpdate.Add(dependentInput);
                    }
                }
            }
        }
 
        // Refresh options for choice inputs that depend on other inputs.
        foreach (var inputToUpdate in choiceInteractionsToUpdate)
        {
            var options = new QueueLoadOptions(logger, cancellationToken, inputToUpdate, inputsInfo.Inputs, serviceProvider);
            inputToUpdate.DynamicLoadingState!.QueueLoad(options);
        }
    }
}
 
internal sealed class ExecuteResourceCommandOptions
{
    public IReadOnlyDictionary<string, string?>? ArgumentValues { get; init; }
 
    public bool NonInteractive { get; init; }
}
 
internal enum ExecuteCommandResultType
{
    Success,
    Failure,
    Canceled
}