File: Model\ResourceViewModel.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.Collections.Concurrent;
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Utils;
using Google.Protobuf.WellKnownTypes;
using Humanizer;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.FluentUI.AspNetCore.Components;
 
namespace Aspire.Dashboard.Model;
 
[DebuggerDisplay("Name = {Name}, ResourceType = {ResourceType}, State = {State}, Properties = {Properties.Count}")]
public sealed class ResourceViewModel
{
    private readonly ImmutableArray<HealthReportViewModel> _healthReports = [];
    private readonly KnownResourceState? _knownState;
 
    public required string Name { get; init; }
    public required string ResourceType { get; init; }
    public required string DisplayName { get; init; }
    public required string Uid { get; init; }
    public required string? State { get; init; }
    public required string? StateStyle { get; init; }
    public required DateTime? CreationTimeStamp { get; init; }
    public required DateTime? StartTimeStamp { get; init; }
    public required DateTime? StopTimeStamp { get; init; }
    public required ImmutableArray<EnvironmentVariableViewModel> Environment { get; init; }
    public required ImmutableArray<UrlViewModel> Urls { get; init; }
    public required ImmutableArray<VolumeViewModel> Volumes { get; init; }
    public required FrozenDictionary<string, ResourcePropertyViewModel> Properties { get; init; }
    public required ImmutableArray<CommandViewModel> Commands { get; init; }
    /// <summary>The health status of the resource. <see langword="null"/> indicates that health status is expected but not yet available.</summary>
    public HealthStatus? HealthStatus { get; private set; }
 
    public required ImmutableArray<HealthReportViewModel> HealthReports
    {
        get => _healthReports;
        init
        {
            _healthReports = value;
            HealthStatus = ComputeHealthStatus(value, KnownState);
        }
    }
 
    public KnownResourceState? KnownState
    {
        get => _knownState;
        init
        {
            _knownState = value;
            HealthStatus = ComputeHealthStatus(_healthReports, value);
        }
    }
 
    internal bool MatchesFilter(string filter)
    {
        // TODO let ResourceType define the additional data values we include in searches
        return Name.Contains(filter, StringComparisons.UserTextSearch);
    }
 
    internal static HealthStatus? ComputeHealthStatus(ImmutableArray<HealthReportViewModel> healthReports, KnownResourceState? state)
    {
        if (state != KnownResourceState.Running)
        {
            return null;
        }
 
        return healthReports.Length == 0
            // If there are no health reports and the resource is running, assume it's healthy.
            ? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy
            // If there are health reports, the health status is the minimum of the health status of the reports.
            // If any of the reports is null (first health check has not returned), the health status is unhealthy.
            : healthReports.MinBy(r => r.HealthStatus)?.HealthStatus
              ?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy;
    }
 
    public static string GetResourceName(ResourceViewModel resource, IDictionary<string, ResourceViewModel> allResources)
    {
        var count = 0;
        foreach (var (_, item) in allResources)
        {
            if (item.IsHiddenState())
            {
                continue;
            }
 
            if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName))
            {
                count++;
                if (count >= 2)
                {
                    // There are multiple resources with the same display name so they're part of a replica set.
                    // Need to use the name which has a unique ID to tell them apart.
                    return resource.Name;
                }
            }
        }
 
        return resource.DisplayName;
    }
}
 
public sealed class ResourceViewModelNameComparer : IComparer<ResourceViewModel>
{
    public static readonly ResourceViewModelNameComparer Instance = new();
 
    public int Compare(ResourceViewModel? x, ResourceViewModel? y)
    {
        Debug.Assert(x != null);
        Debug.Assert(y != null);
 
        // Use display name by itself first.
        // This is to avoid the problem of using the full name where one resource is called "database" and another is called "database-admin".
        // The full names could end up "database-xyz" and "database-admin-xyz", which would put resources out of order.
        var displayNameResult = StringComparers.ResourceName.Compare(x.DisplayName, y.DisplayName);
        if (displayNameResult != 0)
        {
            return displayNameResult;
        }
 
        // Display names are the same so compare with full names.
        return StringComparers.ResourceName.Compare(x.Name, y.Name);
    }
}
 
[DebuggerDisplay("Name = {Name}, DisplayName = {DisplayName}")]
public sealed class CommandViewModel
{
    private sealed record IconKey(string IconName, IconVariant IconVariant);
    private static readonly ConcurrentDictionary<IconKey, CustomIcon?> s_iconCache = new();
 
    public string Name { get; }
    public CommandViewModelState State { get; }
    public string DisplayName { get; }
    public string DisplayDescription { get; }
    public string ConfirmationMessage { get; }
    public Value? Parameter { get; }
    public bool IsHighlighted { get; }
    public string IconName { get; }
    public IconVariant IconVariant { get; }
 
    public CommandViewModel(string name, CommandViewModelState state, string displayName, string displayDescription, string confirmationMessage, Value? parameter, bool isHighlighted, string iconName, IconVariant iconVariant)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
 
        Name = name;
        State = state;
        DisplayName = displayName;
        DisplayDescription = displayDescription;
        ConfirmationMessage = confirmationMessage;
        Parameter = parameter;
        IsHighlighted = isHighlighted;
        IconName = iconName;
        IconVariant = iconVariant;
    }
 
    public static CustomIcon? ResolveIconName(string iconName, IconVariant? iconVariant)
    {
        // Icons.GetInstance isn't efficient. Cache icon lookup.
        return s_iconCache.GetOrAdd(new IconKey(iconName, iconVariant ?? IconVariant.Regular), static key =>
        {
            // We display 16px icons in the UI. Some icons aren't available in 16px size so fall back to 20px.
            CustomIcon? icon;
            if (TryGetIcon(key, IconSize.Size16, out icon))
            {
                return icon;
            }
            if (TryGetIcon(key, IconSize.Size20, out icon))
            {
                return icon;
            }
 
            return null;
        });
    }
 
    private static bool TryGetIcon(IconKey key, IconSize size, [NotNullWhen(true)] out CustomIcon? icon)
    {
        try
        {
            icon = Icons.GetInstance(new IconInfo
            {
                Name = key.IconName,
                Variant = key.IconVariant,
                Size = size
            });
            return true;
        }
        catch
        {
            // Icon name or size couldn't be found.
            icon = null;
            return false;
        }
    }
}
 
public enum CommandViewModelState
{
    Enabled,
    Disabled,
    Hidden
}
 
[DebuggerDisplay("Name = {Name}, Value = {Value}, FromSpec = {FromSpec}, IsValueMasked = {IsValueMasked}")]
public sealed class EnvironmentVariableViewModel : IPropertyGridItem
{
    public string Name { get; }
    public string? Value { get; }
    public bool FromSpec { get; }
 
    public bool IsValueMasked { get; set; } = true;
 
    public bool IsValueSensitive => true;
 
    public EnvironmentVariableViewModel(string name, string? value, bool fromSpec)
    {
        // Name should always have a value, but somehow an empty/whitespace name can reach this point.
        // Better to allow the dashboard to run with an env var with no name than break when loading resources.
        // https://github.com/dotnet/aspire/issues/5309
        Name = name;
        Value = value;
        FromSpec = fromSpec;
    }
}
 
[DebuggerDisplay("Name = {Name}, Value = {Value}, IsValueSensitive = {IsValueSensitive}, IsValueMasked = {IsValueMasked}")]
public sealed class ResourcePropertyViewModel : IPropertyGridItem
{
    private readonly Lazy<string> _displayValue;
    private readonly Lazy<string> _tooltip;
 
    public string Name { get; }
    public Value Value { get; }
    public KnownProperty? KnownProperty { get; }
    public string ToolTip => _tooltip.Value;
    public bool IsValueSensitive { get; }
    public bool IsValueMasked { get; set; }
    internal int Priority { get; }
    private readonly string _key;
 
    string IPropertyGridItem.Name => KnownProperty?.DisplayName ?? Name;
    string? IPropertyGridItem.Value => _displayValue.Value;
 
    object IPropertyGridItem.Key => _key;
 
    public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive, KnownProperty? knownProperty, int priority, BrowserTimeProvider timeProvider)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 
        Name = name;
        Value = value;
        IsValueSensitive = isValueSensitive;
        KnownProperty = knownProperty;
        Priority = priority;
        IsValueMasked = isValueSensitive;
 
        // Known and unknown properties are displayed together. Avoid any duplicate keys.
        _key = KnownProperty != null ? KnownProperty.Key : $"unknown-{Name}";
 
        _tooltip = new(() => value.HasStringValue ? value.StringValue : value.ToString());
 
        _displayValue = new(() =>
        {
            var value = Value is { HasStringValue: true, StringValue: var stringValue }
                ? stringValue
                // Complex values such as arrays and objects will be output as JSON.
                // Consider how complex values are rendered in the future.
                : Value.ToString();
 
            if (Name == KnownProperties.Container.Id)
            {
                // Container images have a short ID of 12 characters
                if (value.Length > 12)
                {
                    value = value[..12];
                }
            }
            else
            {
                // Dates are returned as ISO 8601 text. Try to parse. If successful, format with the current culture.
                if (DateTime.TryParseExact(value, "o", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
                {
                    value = FormatHelpers.FormatDateTime(timeProvider, date, cultureInfo: CultureInfo.CurrentCulture);
                }
            }
 
            return value;
        });
    }
 
    public bool MatchesFilter(string filter) =>
        Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) ||
        ToolTip.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
}
 
public sealed record KnownProperty(string Key, string DisplayName);
 
[DebuggerDisplay("Name = {Name}, Url = {Url}, IsInternal = {IsInternal}")]
public sealed class UrlViewModel
{
    public string Name { get; }
    public Uri Url { get; }
    public bool IsInternal { get; }
 
    public UrlViewModel(string name, Uri url, bool isInternal)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentNullException.ThrowIfNull(url);
 
        Name = name;
        Url = url;
        IsInternal = isInternal;
    }
}
 
public sealed record class VolumeViewModel(int index, string Source, string Target, string MountType, bool IsReadOnly) : IPropertyGridItem
{
    string IPropertyGridItem.Name => Source;
    string? IPropertyGridItem.Value => Target;
 
    // Source could be empty for an anomymous volume so it can't be used as a key.
    // Because there is no good key in data, use index of the volume in results.
    object IPropertyGridItem.Key => index;
 
    public bool MatchesFilter(string filter) =>
        Source?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true ||
        Target?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true;
}
 
public sealed record class HealthReportViewModel(string Name, HealthStatus? HealthStatus, string? Description, string? ExceptionText)
{
    private readonly string? _humanizedHealthStatus = HealthStatus?.Humanize();
 
    public string? DisplayedDescription
    {
        get
        {
            if (!string.IsNullOrWhiteSpace(Description))
            {
                return Description;
            }
 
            if (!string.IsNullOrWhiteSpace(ExceptionText))
            {
                var newLineIndex = ExceptionText.IndexOfAny(['\n', '\r']);
                return newLineIndex > 0 ? ExceptionText[..newLineIndex] : ExceptionText;
            }
 
            return null;
        }
    }
 
    public bool MatchesFilter(string filter)
    {
        return
            Name?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true ||
            Description?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true ||
            _humanizedHealthStatus?.Contains(filter, StringComparison.OrdinalIgnoreCase) is true;
    }
}