// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Nodes;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.Shared;
using Aspire.Shared.Model;
using Aspire.Shared.Model.Serialization;
namespace Aspire.Cli.Backchannel;
/// <summary>
/// Maps <see cref="ResourceSnapshot"/> to <see cref="ResourceJson"/> for serialization.
/// </summary>
internal static class ResourceSnapshotMapper
{
/// <summary>
/// Filters resource snapshots by name, matching against both
/// <see cref="ResourceSnapshot.Name"/> and <see cref="ResourceSnapshot.DisplayName"/>.
/// </summary>
internal static IEnumerable<ResourceSnapshot> WhereMatchesResourceName(IEnumerable<ResourceSnapshot> snapshots, string resourceName)
{
return snapshots.Where(s => string.Equals(s.Name, resourceName, StringComparisons.ResourceName)
|| string.Equals(s.DisplayName, resourceName, StringComparisons.ResourceName));
}
/// <summary>
/// Maps a list of <see cref="ResourceSnapshot"/> to a list of <see cref="ResourceJson"/>.
/// </summary>
/// <param name="snapshots">The resource snapshots to map.</param>
/// <param name="dashboardBaseUrl">Optional base URL of the Aspire Dashboard for generating resource URLs.</param>
/// <param name="includeEnvironmentVariableValues">Whether to include environment variable values. Defaults to <c>true</c>. Set to <c>false</c> to exclude values for security reasons.</param>
/// <param name="includeDisabledCommands">Whether to include disabled commands. Hidden commands are always excluded.</param>
public static List<ResourceJson> MapToResourceJsonList(IEnumerable<ResourceSnapshot> snapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true, bool includeDisabledCommands = false)
{
var snapshotList = snapshots.ToList();
return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl, includeEnvironmentVariableValues, includeDisabledCommands)).ToList();
}
/// <summary>
/// Maps a <see cref="ResourceSnapshot"/> to <see cref="ResourceJson"/>.
/// </summary>
/// <param name="snapshot">The resource snapshot to map.</param>
/// <param name="allSnapshots">All resource snapshots for resolving relationships.</param>
/// <param name="dashboardBaseUrl">Optional base URL of the Aspire Dashboard for generating resource URLs.</param>
/// <param name="includeEnvironmentVariableValues">Whether to include environment variable values. Defaults to <c>true</c>. Set to <c>false</c> to exclude values for security reasons.</param>
/// <param name="includeDisabledCommands">Whether to include disabled commands. Hidden commands are always excluded.</param>
public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allSnapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true, bool includeDisabledCommands = false)
{
var urls = snapshot.Urls
.Select(u => new ResourceUrlJson
{
Name = u.Name,
DisplayName = u.DisplayProperties?.DisplayName,
Url = u.Url,
IsInternal = u.IsInternal
})
.ToArray();
var volumes = snapshot.Volumes
.Select(v => new ResourceVolumeJson
{
Source = v.Source,
Target = v.Target,
MountType = v.MountType,
IsReadOnly = v.IsReadOnly
})
.ToArray();
var healthReports = snapshot.HealthReports.OrderBy(h => h.Name).ToDistinctDictionary(
h => h.Name,
h => new ResourceHealthReportJson
{
Status = h.Status,
Description = h.Description,
ExceptionMessage = h.ExceptionText
});
var environment = snapshot.EnvironmentVariables
.Where(e => e.IsFromSpec)
.OrderBy(e => e.Name)
.ToDistinctDictionary(
e => e.Name,
e => includeEnvironmentVariableValues ? e.Value : null);
var properties = snapshot.Properties.OrderBy(p => p.Key).ToDistinctDictionary(
p => p.Key,
p => p.Value?.DeepClone());
var waitingFor = GetResolvedWaitingForDependencies(snapshot, allSnapshots);
// Build relationships by matching DisplayName
var relationships = new List<ResourceRelationshipJson>();
foreach (var relationship in snapshot.Relationships)
{
var matches = allSnapshots
.Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName))
.ToList();
foreach (var match in matches)
{
relationships.Add(new ResourceRelationshipJson
{
Type = relationship.Type,
ResourceName = match.Name
});
}
}
// Include only API-visible enabled commands by default; the include-disabled stream
// also surfaces UI-only commands for UI consumers. Hidden commands are never emitted.
// Capture each command's index (before filtering) and stamp it as SortOrder so consumers
// can sort by (SortOrder, Name).
var commands = snapshot.Commands
.Select((command, index) => (command, index))
.Where(c => IsCommandVisibleForConsumer(c.command.Visibility, includeDisabledCommands) && IsCommandVisibleToConsumer(c.command.State, includeDisabledCommands))
.OrderBy(c => c.command.Name)
.ToDistinctDictionary(
c => c.command.Name,
c => new ResourceCommandJson
{
DisplayName = string.IsNullOrWhiteSpace(c.command.DisplayName) ? null : c.command.DisplayName.Trim(),
Description = c.command.Description,
Visibility = IsDefaultCommandVisibility(c.command.Visibility) ? null : c.command.Visibility,
State = c.command.State,
SortOrder = c.index,
ArgumentInputs = c.command.ArgumentInputs.Length > 0
? c.command.ArgumentInputs.Select(MapCommandArgumentInput).ToArray()
: null
});
// Get source information using the shared ResourceSourceViewModel
var stringProperties = snapshot.Properties.OrderBy(p => p.Key).ToDistinctDictionary(
p => p.Key,
p => ConvertJsonNodeToString(p.Value));
var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, stringProperties);
// Generate dashboard URL for this resource if a base URL is provided
string? dashboardUrl = null;
if (!string.IsNullOrEmpty(dashboardBaseUrl))
{
var resourcePath = DashboardUrls.ResourcesUrl(snapshot.Name);
dashboardUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, resourcePath);
}
return new ResourceJson
{
Name = snapshot.Name,
DisplayName = snapshot.DisplayName,
ResourceType = snapshot.ResourceType,
State = snapshot.State,
WaitingFor = waitingFor,
StateStyle = snapshot.StateStyle,
HealthStatus = snapshot.HealthStatus,
Source = sourceViewModel?.Value,
ExitCode = snapshot.ExitCode,
CreationTimestamp = snapshot.CreatedAt,
StartTimestamp = snapshot.StartedAt,
StopTimestamp = snapshot.StoppedAt,
DashboardUrl = dashboardUrl,
Urls = urls,
Volumes = volumes,
Environment = environment,
HealthReports = healthReports,
Properties = properties,
Relationships = relationships.ToArray(),
Commands = commands
};
}
private static string[]? GetResolvedWaitingForDependencies(ResourceSnapshot snapshot, IReadOnlyList<ResourceSnapshot> allSnapshots)
{
var waitingFor = snapshot.WaitingFor;
if (waitingFor is not { Length: > 0 } &&
snapshot.Properties.TryGetValue(KnownProperties.Resource.WaitingFor, out var waitingForProperty) &&
TryConvertJsonNodeToString(waitingForProperty, out var waitingForPropertyString) &&
!string.IsNullOrWhiteSpace(waitingForPropertyString))
{
waitingFor = waitingForPropertyString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
if (waitingFor is not { Length: > 0 })
{
return null;
}
var dependencies = new List<string>();
var seenDependencies = new HashSet<string>(StringComparers.ResourceName);
foreach (var dependency in waitingFor)
{
var dependencyName = dependency;
var matches = ResolveResources(dependency, allSnapshots);
if (matches.Count == 1)
{
dependencyName = GetResourceName(matches[0], allSnapshots);
}
if (seenDependencies.Add(dependencyName))
{
dependencies.Add(dependencyName);
}
}
return dependencies.Count > 0 ? [.. dependencies] : null;
}
private static string? ConvertJsonNodeToString(JsonNode? node)
{
return TryConvertJsonNodeToString(node, out var value) ? value : null;
}
private static bool TryConvertJsonNodeToString(JsonNode? node, [System.Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] out string? value)
{
if (node is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var stringValue))
{
value = stringValue;
return true;
}
value = null;
return false;
}
internal static bool IsCommandAvailableToApi(ResourceSnapshotCommand command)
{
return string.Equals(command.State, "Enabled", StringComparison.OrdinalIgnoreCase) &&
IsCommandVisibleToApi(command.Visibility);
}
private static bool IsDefaultCommandVisibility(string? visibility)
{
return string.Equals(visibility, KnownCommandVisibility.Default, StringComparison.OrdinalIgnoreCase);
}
private static bool IsCommandVisibleToApi(string? visibility)
{
return visibility?.Split(',').Any(static value => string.Equals(value.Trim(), KnownCommandVisibility.Api, StringComparison.OrdinalIgnoreCase)) is true;
}
private static bool IsCommandVisibleForConsumer(string? visibility, bool includeDisabledCommands)
{
return IsCommandVisibleToApi(visibility)
|| (includeDisabledCommands && visibility?.Split(',').Any(static value => string.Equals(value.Trim(), KnownCommandVisibility.UI, StringComparison.OrdinalIgnoreCase)) is true);
}
private static bool IsCommandVisibleToConsumer(string state, bool includeDisabledCommands)
{
return string.Equals(state, KnownCommandState.Enabled, StringComparison.OrdinalIgnoreCase)
|| (includeDisabledCommands && string.Equals(state, KnownCommandState.Disabled, StringComparison.OrdinalIgnoreCase));
}
internal static ResourceCommandArgumentJson MapCommandArgumentInput(ResourceSnapshotCommandArgument input)
{
return new ResourceCommandArgumentJson
{
Name = input.Name,
Label = input.Label,
Description = input.Description,
EnableDescriptionMarkdown = input.EnableDescriptionMarkdown,
InputType = input.InputType,
Required = input.Required,
Placeholder = input.Placeholder,
Value = IsSecretCommandArgument(input) ? null : input.Value,
Options = input.Options,
AllowCustomChoice = input.AllowCustomChoice,
Disabled = input.Disabled,
MaxLength = input.MaxLength,
DynamicLoading = MapDynamicLoading(input.DynamicLoading)
};
}
private static bool IsSecretCommandArgument(ResourceSnapshotCommandArgument input)
{
return string.Equals(input.InputType, nameof(InputType.SecretText), StringComparison.OrdinalIgnoreCase);
}
private static ResourceCommandArgumentDynamicLoadingJson? MapDynamicLoading(ResourceSnapshotCommandArgumentDynamicLoading? dynamicLoading)
{
return dynamicLoading is null
? null
: new ResourceCommandArgumentDynamicLoadingJson
{
AlwaysLoadOnStart = dynamicLoading.AlwaysLoadOnStart,
DependsOnInputs = dynamicLoading.DependsOnInputs
};
}
/// <summary>
/// Resolves a user-provided resource name to matching snapshots.
/// First tries an exact match on <see cref="ResourceSnapshot.Name"/>, then falls back
/// to matching by <see cref="ResourceSnapshot.DisplayName"/> only when the display name is
/// unique (i.e., not a replica set).
/// </summary>
/// <param name="resourceName">The user-provided resource name to resolve.</param>
/// <param name="snapshots">All available resource snapshots.</param>
/// <returns>The matching snapshots.</returns>
public static IReadOnlyList<ResourceSnapshot> ResolveResources(string resourceName, IReadOnlyList<ResourceSnapshot> snapshots)
{
// First try exact match on the unique resource Name.
var exactMatches = snapshots.Where(s => string.Equals(s.Name, resourceName, StringComparison.OrdinalIgnoreCase)).ToList();
if (exactMatches.Count > 0)
{
return exactMatches;
}
// Fall back to matching by DisplayName, but only when there is exactly one match
// (no replicas). When there are replicas the user must specify the full suffixed name.
var displayNameMatches = snapshots.Where(s => string.Equals(s.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase)).ToList();
if (displayNameMatches.Count == 1)
{
return displayNameMatches;
}
return [];
}
/// <summary>
/// Gets the display name for a resource, returning the unique name if there are multiple resources
/// with the same display name (replicas).
/// </summary>
/// <param name="resource">The resource to get the name for.</param>
/// <param name="allResources">All resources to check for duplicates.</param>
/// <returns>The display name if unique, otherwise the unique resource name.</returns>
public static string GetResourceName(ResourceSnapshot resource, IDictionary<string, ResourceSnapshot> allResources)
{
return GetResourceName(resource, allResources.Values);
}
/// <summary>
/// Gets the display name for a resource, returning the unique name if there are multiple resources
/// with the same display name (replicas).
/// </summary>
/// <param name="resource">The resource to get the name for.</param>
/// <param name="allResources">All resources to check for duplicates.</param>
/// <returns>The display name if unique, otherwise the unique resource name.</returns>
public static string GetResourceName(ResourceSnapshot resource, IEnumerable<ResourceSnapshot> allResources)
{
var count = 0;
foreach (var item in allResources)
{
// Skip hidden resources
if (string.Equals(item.State, "Hidden", StringComparison.OrdinalIgnoreCase))
{
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 ?? resource.Name;
}
/// <summary>
/// Determines whether a resource snapshot represents a hidden resource.
/// A resource is hidden if its <see cref="ResourceSnapshot.IsHidden"/> flag is set
/// or its <see cref="ResourceSnapshot.State"/> is "Hidden".
/// </summary>
internal static bool IsHiddenResource(ResourceSnapshot snapshot)
{
return snapshot.IsHidden || string.Equals(snapshot.State, "Hidden", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Filters a list of all resource snapshots based on hidden-resource visibility,
/// returning the visible snapshot list and a set of hidden resource names for log filtering.
/// When <paramref name="includeHidden"/> is <see langword="true"/> or <paramref name="resourceName"/>
/// is specified, all resources are included and the hidden set is empty.
/// </summary>
/// <param name="allSnapshots">All resource snapshots (including hidden).</param>
/// <param name="includeHidden">Whether the user explicitly requested hidden resources.</param>
/// <param name="resourceName">The specific resource name requested, or <see langword="null"/> for all.</param>
/// <returns>The effective include-hidden flag, the filtered snapshot list, and the set of hidden resource names.</returns>
internal static (bool EffectiveIncludeHidden, List<ResourceSnapshot> Snapshots, HashSet<string> HiddenResourceNames) FilterHiddenResources(
IReadOnlyList<ResourceSnapshot> allSnapshots,
bool includeHidden,
string? resourceName)
{
var effectiveIncludeHidden = includeHidden || resourceName is not null;
var hiddenResourceNames = effectiveIncludeHidden
? new HashSet<string>(StringComparers.ResourceName)
: new HashSet<string>(allSnapshots.Where(IsHiddenResource).Select(s => s.Name), StringComparers.ResourceName);
var snapshots = effectiveIncludeHidden
? allSnapshots.ToList()
: allSnapshots.Where(s => !IsHiddenResource(s)).ToList();
return (effectiveIncludeHidden, snapshots, hiddenResourceNames);
}
}