File: Commands\ResourcesCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Output format for resources command (array wrapper).
/// </summary>
internal sealed class ResourcesOutput
{
    public required ResourceJson[] Resources { get; init; }
}
 
[JsonSerializable(typeof(ResourcesOutput))]
[JsonSerializable(typeof(ResourceJson))]
[JsonSerializable(typeof(ResourceUrlJson))]
[JsonSerializable(typeof(ResourceVolumeJson))]
[JsonSerializable(typeof(ResourceEnvironmentVariableJson))]
[JsonSerializable(typeof(ResourceHealthReportJson))]
[JsonSerializable(typeof(ResourcePropertyJson))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
[JsonSerializable(typeof(ResourceCommandJson))]
[JsonSourceGenerationOptions(
    WriteIndented = true,
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal sealed partial class ResourcesCommandJsonContext : JsonSerializerContext
{
    private static ResourcesCommandJsonContext? s_relaxedEscaping;
    private static ResourcesCommandJsonContext? s_ndjson;
 
    /// <summary>
    /// Gets a context with relaxed JSON escaping for non-ASCII character support (pretty-printed).
    /// </summary>
    public static ResourcesCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    });
 
    /// <summary>
    /// Gets a context for NDJSON streaming (compact, one object per line).
    /// </summary>
    public static ResourcesCommandJsonContext Ndjson => s_ndjson ??= new(new JsonSerializerOptions
    {
        WriteIndented = false,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    });
}
 
internal sealed class ResourcesCommand : BaseCommand
{
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
 
    private static readonly Argument<string?> s_resourceArgument = new("resource")
    {
        Description = ResourcesCommandStrings.ResourceArgumentDescription,
        Arity = ArgumentArity.ZeroOrOne
    };
    private static readonly Option<FileInfo?> s_projectOption = new("--project")
    {
        Description = ResourcesCommandStrings.ProjectOptionDescription
    };
    private static readonly Option<bool> s_watchOption = new("--watch")
    {
        Description = ResourcesCommandStrings.WatchOptionDescription
    };
    private static readonly Option<OutputFormat> s_formatOption = new("--format")
    {
        Description = ResourcesCommandStrings.JsonOptionDescription
    };
 
    public ResourcesCommand(
        IInteractionService interactionService,
        IAuxiliaryBackchannelMonitor backchannelMonitor,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        AspireCliTelemetry telemetry,
        ILogger<ResourcesCommand> logger)
        : base("resources", ResourcesCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
    {
        _interactionService = interactionService;
        _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
 
        Arguments.Add(s_resourceArgument);
        Options.Add(s_projectOption);
        Options.Add(s_watchOption);
        Options.Add(s_formatOption);
    }
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        var resourceName = parseResult.GetValue(s_resourceArgument);
        var passedAppHostProjectFile = parseResult.GetValue(s_projectOption);
        var watch = parseResult.GetValue(s_watchOption);
        var format = parseResult.GetValue(s_formatOption);
 
        // When outputting JSON, suppress status messages to keep output machine-readable
        var scanningMessage = format == OutputFormat.Json ? string.Empty : ResourcesCommandStrings.ScanningForRunningAppHosts;
 
        var result = await _connectionResolver.ResolveConnectionAsync(
            passedAppHostProjectFile,
            scanningMessage,
            ResourcesCommandStrings.SelectAppHost,
            ResourcesCommandStrings.NoInScopeAppHostsShowingAll,
            ResourcesCommandStrings.AppHostNotRunning,
            cancellationToken);
 
        if (!result.Success)
        {
            // No running AppHosts is not an error - similar to Unix 'ps' returning empty
            return ExitCodeConstants.Success;
        }
 
        if (watch)
        {
            return await ExecuteWatchAsync(result.Connection!, resourceName, format, cancellationToken);
        }
        else
        {
            return await ExecuteSnapshotAsync(result.Connection!, resourceName, format, cancellationToken);
        }
    }
 
    private async Task<int> ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
    {
        // Get dashboard URL and resource snapshots in parallel
        var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
        var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);
 
        await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);
 
        var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false);
        var snapshots = await snapshotsTask.ConfigureAwait(false);
 
        // Filter by resource name if specified
        if (resourceName is not null)
        {
            snapshots = snapshots.Where(s => string.Equals(s.Name, resourceName, StringComparison.OrdinalIgnoreCase)).ToList();
        }
 
        // Check if resource was not found
        if (resourceName is not null && snapshots.Count == 0)
        {
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ResourcesCommandStrings.ResourceNotFound, resourceName));
            return ExitCodeConstants.FailedToFindProject;
        }
 
        // Use the dashboard base URL if available
        var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
        var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl);
 
        if (format == OutputFormat.Json)
        {
            var output = new ResourcesOutput { Resources = resourceList.ToArray() };
            var json = JsonSerializer.Serialize(output, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
            _interactionService.DisplayRawText(json);
        }
        else
        {
            DisplayResourcesTable(snapshots);
        }
 
        return ExitCodeConstants.Success;
    }
 
    private async Task<int> ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
    {
        // Get dashboard URL first for generating resource links
        var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
        var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
 
        // Maintain a dictionary of all resources seen so far for relationship resolution
        var allResources = new Dictionary<string, ResourceSnapshot>(StringComparer.OrdinalIgnoreCase);
 
        // Stream resource snapshots
        await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false))
        {
            // Update the dictionary with the latest snapshot for this resource
            allResources[snapshot.Name] = snapshot;
 
            // Filter by resource name if specified
            if (resourceName is not null && !string.Equals(snapshot.Name, resourceName, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            var resourceJson = ResourceSnapshotMapper.MapToResourceJson(snapshot, allResources.Values.ToList(), dashboardBaseUrl);
 
            if (format == OutputFormat.Json)
            {
                // NDJSON output - compact, one object per line for streaming
                var json = JsonSerializer.Serialize(resourceJson, ResourcesCommandJsonContext.Ndjson.ResourceJson);
                _interactionService.DisplayRawText(json);
            }
            else
            {
                // Human-readable update
                DisplayResourceUpdate(snapshot, allResources);
            }
        }
 
        return ExitCodeConstants.Success;
    }
 
    private void DisplayResourcesTable(IReadOnlyList<ResourceSnapshot> snapshots)
    {
        if (snapshots.Count == 0)
        {
            _interactionService.DisplayPlainText("No resources found.");
            return;
        }
 
        // Get display names for all resources
        var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots)))
            .OrderBy(x => x.DisplayName)
            .ToList();
 
        var table = new Table();
        table.AddColumn("Name");
        table.AddColumn("Type");
        table.AddColumn("State");
        table.AddColumn("Health");
        table.AddColumn("Endpoints");
 
        foreach (var (snapshot, displayName) in orderedItems)
        {
            var endpoints = snapshot.Urls.Length > 0
                ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
                : "-";
 
            var type = snapshot.ResourceType ?? "-";
            var state = snapshot.State ?? "Unknown";
            var health = snapshot.HealthStatus ?? "-";
 
            // Color the state based on value
            var stateText = state.ToUpperInvariant() switch
            {
                "RUNNING" => $"[green]{state}[/]",
                "FINISHED" or "EXITED" => $"[grey]{state}[/]",
                "FAILEDTOSTART" or "FAILED" => $"[red]{state}[/]",
                "STARTING" or "WAITING" => $"[yellow]{state}[/]",
                _ => state
            };
 
            // Color the health based on value
            var healthText = health.ToUpperInvariant() switch
            {
                "HEALTHY" => $"[green]{health}[/]",
                "UNHEALTHY" => $"[red]{health}[/]",
                "DEGRADED" => $"[yellow]{health}[/]",
                _ => health
            };
 
            table.AddRow(displayName, type, stateText, healthText, endpoints);
        }
 
        AnsiConsole.Write(table);
    }
 
    private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary<string, ResourceSnapshot> allResources)
    {
        var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, allResources);
 
        var endpoints = snapshot.Urls.Length > 0
            ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
            : "";
 
        var health = !string.IsNullOrEmpty(snapshot.HealthStatus) ? $" ({snapshot.HealthStatus})" : "";
        var endpointsStr = !string.IsNullOrEmpty(endpoints) ? $" - {endpoints}" : "";
 
        _interactionService.DisplayPlainText($"[{displayName}] {snapshot.State ?? "Unknown"}{health}{endpointsStr}");
    }
}