File: Components\Pages\ResourceOverview.Details.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.Controls;
using Aspire.Dashboard.Components.Controls.PropertyValues;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Extensions;
using ProtobufValue = Google.Protobuf.WellKnownTypes.Value;
 
namespace Aspire.Dashboard.Components.Pages;
 
// Properties, health checks, volumes and environment variables cards of the resource overview.
public sealed partial class ResourceOverview
{
    private const int DetailsPreviewCount = 10;
 
    // The status card already displays these properties, and the header displays the resource name.
    private static readonly HashSet<string> s_statusCardPropertyNames = new(StringComparers.ResourcePropertyName)
    {
        KnownProperties.Resource.DisplayName,
        KnownProperties.Resource.State,
        KnownProperties.Resource.HealthState,
        KnownProperties.Resource.Type,
        KnownProperties.Resource.StartTime,
        KnownProperties.Resource.StopTime,
        KnownProperties.Resource.ExitCode,
        KnownProperties.Resource.Source,
    };
 
    private readonly List<DisplayedResourcePropertyViewModel> _properties = new();
    private readonly MaskState _propertiesMask = new();
    private readonly MaskState _environmentMask = new();
    private ResourceViewModel? _detailsResource;
    private Dictionary<string, ComponentMetadata>? _propertyValueComponents;
    private bool _showAllProperties;
    private bool _showAllEnvironmentVariables;
    private string _environmentFilter = string.Empty;
    private bool _propertiesExpanded;
    private bool _environmentExpanded;
 
    /// <summary>
    /// Rebuilds the property view models when the selected resource is updated. The resource view model is replaced on
    /// every update, so masking choices are stored separately and reapplied to the new view models.
    /// </summary>
    private void UpdateDetails()
    {
        var resource = Resource;
        if (ReferenceEquals(resource, _detailsResource))
        {
            return;
        }
 
        if (!string.Equals(resource?.Name, _detailsResource?.Name, StringComparisons.ResourceName))
        {
            _propertiesMask.Reset();
            _environmentMask.Reset();
            _environmentFilter = string.Empty;
            _propertiesExpanded = false;
            _environmentExpanded = false;
        }
 
        _detailsResource = resource;
        _properties.Clear();
        _propertyValueComponents = null;
 
        if (resource is null)
        {
            return;
        }
 
        foreach (var property in resource.Properties.Values)
        {
            if (s_statusCardPropertyNames.Contains(property.Name) || !HasValue(property.Value))
            {
                continue;
            }
 
            var displayedProperty = property;
 
            // An unresolved secret parameter has no value to hide, so keep the placeholder visible instead of
            // routing it through masking behavior.
            if (resource.HasMissingParameterValueState() &&
                string.Equals(property.Name, KnownProperties.Parameter.Value, StringComparisons.ResourcePropertyName) &&
                property.IsValueSensitive)
            {
                displayedProperty = new ResourcePropertyViewModel(
                    name: property.Name,
                    value: property.Value,
                    isValueSensitive: false,
                    knownProperty: property.KnownProperty,
                    sortOrder: property.SortOrder,
                    displayName: property.DisplayName,
                    isHighlighted: property.IsHighlighted);
            }
 
            _properties.Add(new DisplayedResourcePropertyViewModel(displayedProperty, Loc, TimeProvider));
        }
 
        _propertiesMask.Apply(_properties);
        _environmentMask.Apply(resource.Environment);
 
        // Render the same "Value not set" affordance as the parameters grid for parameters whose value is unset.
        if (resource.HasMissingParameterValueState())
        {
            var metadata = new ComponentMetadata
            {
                Type = typeof(ParameterValueDisplayCell),
                Parameters =
                {
                    ["Resource"] = resource,
                    ["OnExecuteCommandAsync"] = (Func<ResourceViewModel, CommandViewModel, Task>)ExecuteResourceCommandAsync,
                    ["IsCommandExecuting"] = (Func<ResourceViewModel, CommandViewModel, bool>)((r, command) => DashboardCommandExecutor.IsExecuting(r.Name, command.Name)),
                }
            };
 
            // New resource servers send the parameter value as a known property, while legacy fallback metadata
            // exposes it as an unknown property. Register both keys so the renderer works in both cases.
            _propertyValueComponents ??= [];
            _propertyValueComponents[KnownProperties.Parameter.Value] = metadata;
            _propertyValueComponents[DisplayedResourcePropertyViewModel.GetUnknownKey(KnownProperties.Parameter.Value)] = metadata;
        }
 
        // Link the container image to its registry page (Docker Hub or MCR) when we're confident of the URL.
        if (resource.TryGetContainerImage(out _))
        {
            var metadata = new ComponentMetadata { Type = typeof(ContainerImageValue) };
 
            // Resource servers with producer-supplied metadata send the container image as a known property,
            // while legacy fallback metadata (see LegacyResourcePropertyMetadata) also keys it by the same
            // name. Register both the known and unknown-property keys so the renderer works in both cases.
            _propertyValueComponents ??= [];
            _propertyValueComponents[KnownProperties.Container.Image] = metadata;
            _propertyValueComponents[DisplayedResourcePropertyViewModel.GetUnknownKey(KnownProperties.Container.Image)] = metadata;
        }
    }
 
    private static bool HasValue(ProtobufValue value) =>
        value is { HasNullValue: false } and not { KindCase: ProtobufValue.KindOneofCase.ListValue, ListValue.Values.Count: 0 };
 
    // Unknown properties are custom properties published by integrations. They're hidden by default, like in the
    // resource details panel, unless the integration marks them as highlighted. AppHost traits are always shown:
    // the app author declared them to describe the resource, unlike integration implementation details.
    private static bool IsCoreProperty(DisplayedResourcePropertyViewModel property) => property.KnownProperty is not null || property.IsHighlighted || property.IsTrait;
 
    private bool HasNonCoreProperties => _properties.Any(p => !IsCoreProperty(p));
 
    private List<DisplayedResourcePropertyViewModel> GetVisibleProperties() =>
        _properties
            .Where(p => _showAllProperties || IsCoreProperty(p))
            // Keep traits together after the resource's own properties.
            .OrderBy(p => p.IsTrait)
            .ThenBy(p => p.SortOrder)
            .ThenBy(p => p.DisplayName, StringComparer.CurrentCulture)
            .ToList();
 
    private bool HasSensitiveProperties => _properties.Any(p => ((IPropertyGridItem)p).IsValueSensitive);
 
    private void ToggleShowAllProperties() => _showAllProperties = !_showAllProperties;
 
    private void TogglePropertiesMasked() => _propertiesMask.Toggle(_properties);
 
    private void OnPropertyMaskedChanged(DisplayedResourcePropertyViewModel property) => _propertiesMask.OnItemChanged(_properties, property);
 
    private List<EnvironmentVariableViewModel> GetVisibleEnvironmentVariables(ResourceViewModel resource) =>
        resource.Environment
            .Where(e => (_showAllEnvironmentVariables || e.FromSpec) && ((IPropertyGridItem)e).MatchesFilter(_environmentFilter))
            .OrderBy(e => e.Name, StringComparers.EnvironmentVariableName)
            .ToList();
 
    private void TogglePropertiesExpanded() => _propertiesExpanded = !_propertiesExpanded;
 
    private void ToggleEnvironmentExpanded() => _environmentExpanded = !_environmentExpanded;
 
    private void ToggleShowAllEnvironmentVariables() => _showAllEnvironmentVariables = !_showAllEnvironmentVariables;
 
    private void ToggleEnvironmentMasked(ResourceViewModel resource) => _environmentMask.Toggle(resource.Environment);
 
    private void OnEnvironmentVariableMaskedChanged(ResourceViewModel resource, EnvironmentVariableViewModel variable) => _environmentMask.OnItemChanged(resource.Environment, variable);
 
    private string? GetStateDescription(ResourceViewModel resource, string stateText)
    {
        var description = ResourceStateViewModel.GetResourceStateTooltip(resource, ColumnsLoc, ResourcesLayout?.ResourceByName.Values);
        return string.IsNullOrWhiteSpace(description) || string.Equals(description, stateText, StringComparison.Ordinal) ? null : description;
    }
 
    private static List<HealthReportViewModel> GetHealthReports(ResourceViewModel resource) =>
        resource.HealthReports.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList();
 
    private static List<VolumeViewModel> GetVolumes(ResourceViewModel resource) =>
        resource.Volumes.OrderBy(v => v.Source, StringComparer.OrdinalIgnoreCase).ToList();
 
    private async Task RevealVolumeSourceAsync(VolumeViewModel volume)
    {
        if (!VolumePathLauncher.TryReveal(volume.Source))
        {
            await ToastService.ShowErrorToastAsync(string.Format(CultureInfo.CurrentCulture, ControlsStringsLoc[nameof(Dashboard.Resources.ControlsStrings.VolumeRevealFailed)], volume.Source));
        }
    }
 
    /// <summary>
    /// Tracks whether the sensitive values of a card are masked. Individual values can be unmasked from the grid, so
    /// the state is either "all masked", "all unmasked" or a set of unmasked value names.
    /// </summary>
    private sealed class MaskState
    {
        private readonly HashSet<string> _unmaskedNames = new(StringComparer.Ordinal);
        private bool? _isAllMasked = true;
 
        public bool IsAllMasked => _isAllMasked ?? false;
 
        public void Reset()
        {
            _isAllMasked = true;
            _unmaskedNames.Clear();
        }
 
        public void Apply(IEnumerable<IPropertyGridItem> items)
        {
            foreach (var item in items.Where(static i => i.IsValueSensitive))
            {
                item.IsValueMasked = _isAllMasked ?? !_unmaskedNames.Contains(item.Name);
            }
        }
 
        public void Toggle(IEnumerable<IPropertyGridItem> items)
        {
            _isAllMasked = !IsAllMasked;
            _unmaskedNames.Clear();
            Apply(items);
        }
 
        public void OnItemChanged(IEnumerable<IPropertyGridItem> items, IPropertyGridItem item)
        {
            var maskedValues = items.Where(static i => i.IsValueSensitive).Select(static i => i.IsValueMasked).Distinct().ToList();
            if (maskedValues.Count == 1)
            {
                _isAllMasked = maskedValues[0];
                _unmaskedNames.Clear();
                return;
            }
 
            _isAllMasked = null;
            if (item.IsValueMasked)
            {
                _unmaskedNames.Remove(item.Name);
            }
            else
            {
                _unmaskedNames.Add(item.Name);
            }
        }
    }
}