File: Commands\DashboardRunCommand.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 Aspire.Cli.Bundles;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Command that starts a standalone Aspire Dashboard instance.
/// </summary>
internal sealed class DashboardRunCommand : BaseCommand
{
    private static readonly TimeSpan s_bundleStatusDelay = TimeSpan.FromMilliseconds(200);
 
    internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
 
    protected override bool UpdateNotificationsEnabled => true;
 
    protected override string CancellationMessage => DashboardCommandStrings.StoppingDashboard;
 
    private readonly IBundleService _bundleService;
    private readonly LayoutProcessRunner _layoutProcessRunner;
    private readonly FileLoggerProvider _fileLoggerProvider;
    private readonly IEnvironment _environment;
    private readonly ILogger<DashboardRunCommand> _logger;
 
    private static readonly Option<string?> s_frontendUrlOption = new("--frontend-url")
    {
        Description = DashboardCommandStrings.FrontendUrlOptionDescription
    };
 
    private static readonly Option<string?> s_otlpGrpcUrlOption = new("--otlp-grpc-url")
    {
        Description = DashboardCommandStrings.OtlpGrpcUrlOptionDescription
    };
 
    private static readonly Option<string?> s_otlpHttpUrlOption = new("--otlp-http-url")
    {
        Description = DashboardCommandStrings.OtlpHttpUrlOptionDescription
    };
 
    // This option explicitly opts into unsecured endpoints. See the security considerations at
    // https://aspire.dev/dashboard/security-considerations/ before enabling it outside local development.
    // When the corresponding endpoints are enabled, the dashboard logs warnings and displays a warning
    // in the UI to inform users about the risks of anonymous access.
    private static readonly Option<bool> s_allowAnonymousOption = new("--allow-anonymous")
    {
        Description = DashboardCommandStrings.AllowAnonymousOptionDescription
    };
 
    private static readonly Option<string?> s_configFilePathOption = new("--config-file-path")
    {
        Description = DashboardCommandStrings.ConfigFilePathOptionDescription
    };
 
    public DashboardRunCommand(
        IBundleService bundleService,
        LayoutProcessRunner layoutProcessRunner,
        FileLoggerProvider fileLoggerProvider,
        IEnvironment environment,
        ILogger<DashboardRunCommand> logger,
        CommonCommandServices services)
        : base("run", DashboardCommandStrings.RunDescription, services)
    {
        _bundleService = bundleService;
        _layoutProcessRunner = layoutProcessRunner;
        _fileLoggerProvider = fileLoggerProvider;
        _environment = environment;
        _logger = logger;
 
        Options.Add(s_frontendUrlOption);
        Options.Add(s_otlpGrpcUrlOption);
        Options.Add(s_otlpHttpUrlOption);
        Options.Add(s_allowAnonymousOption);
        Options.Add(s_configFilePathOption);
        TreatUnmatchedTokensAsErrors = false;
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var layoutLease = await EnsureDashboardBundleAsync(cancellationToken).ConfigureAwait(false);
        var layout = layoutLease?.Layout;
        if (layout is null)
        {
            return CommandResult.Failure(CliExitCodes.DashboardFailure, DashboardCommandStrings.BundleLayoutNotFound);
        }
 
        var managedPath = layout.GetManagedPath();
        if (managedPath is null || !File.Exists(managedPath))
        {
            return CommandResult.Failure(CliExitCodes.DashboardFailure, DashboardCommandStrings.ManagedBinaryNotFound);
        }
 
        var dashboardArgs = new List<string> { "dashboard" };
 
        // Build args from typed options. These are added before unmatched tokens
        // so that raw pass-through arguments (unmatched tokens) take precedence.
        var unmatchedTokens = parseResult.UnmatchedTokens;
        var allowAnonymous = parseResult.GetValue(s_allowAnonymousOption);
        AddOptionArgs(parseResult, dashboardArgs, unmatchedTokens, _environment);
 
        // Set a browser token for frontend auth unless anonymous access is enabled.
        // Tokens and keys are passed via environment variables (not command-line args)
        // to avoid exposing them in process listings (e.g. ps, Task Manager).
        string? browserToken = null;
        var environmentVariables = new Dictionary<string, string>();
        layoutLease?.AddEnvironment(environmentVariables);
        if (!allowAnonymous && !ConfigSettingHasValue(unmatchedTokens, _environment, KnownConfigNames.DashboardUnsecuredAllowAnonymous))
        {
            if (!ConfigSettingHasValue(unmatchedTokens, _environment, DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName))
            {
                browserToken = TokenGenerator.GenerateToken();
                environmentVariables[DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName] = browserToken;
            }
 
            // Enable API key authentication for the telemetry API so that only
            // callers who possess the key (or the browser token) can query it.
            if (!ConfigSettingHasValue(unmatchedTokens, _environment, DashboardConfigNames.DashboardApiPrimaryApiKeyName.EnvVarName))
            {
                var apiKey = TokenGenerator.GenerateToken();
                environmentVariables[DashboardConfigNames.DashboardApiPrimaryApiKeyName.EnvVarName] = apiKey;
 
                if (!ConfigSettingHasValue(unmatchedTokens, _environment, DashboardConfigNames.DashboardApiAuthModeName.EnvVarName))
                {
                    environmentVariables[DashboardConfigNames.DashboardApiAuthModeName.EnvVarName] = "ApiKey";
                }
            }
        }
 
        dashboardArgs.AddRange(unmatchedTokens);
 
        // Resolve URLs for the summary display.
        var dashboardInfo = ResolveDashboardInfo(dashboardArgs, unmatchedTokens, _environment, browserToken);
 
        return await ExecuteForegroundAsync(managedPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<BundleLayoutLease?> EnsureDashboardBundleAsync(CancellationToken cancellationToken)
    {
        var layoutTask = _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dashboard", cancellationToken);
 
        // Cached bundle acquisition normally completes quickly, so wait briefly before showing a status to avoid flicker during typical usage.
        if (await Task.WhenAny(layoutTask, Task.Delay(s_bundleStatusDelay, CancellationToken.None)).ConfigureAwait(false) == layoutTask)
        {
            return await layoutTask.ConfigureAwait(false);
        }
 
        return await InteractionService.ShowStatusAsync(
            DashboardCommandStrings.EnsuringDashboardBundle,
            () => layoutTask).ConfigureAwait(false);
    }
 
    private static void AddOptionArgs(ParseResult parseResult, List<string> args, IReadOnlyList<string> unmatchedTokens, IEnvironment environment)
    {
        AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_frontendUrlOption, KnownAspNetCoreConfigNames.Urls, defaultValue: "http://localhost:18888");
        AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpGrpcUrlOption, KnownConfigNames.DashboardOtlpGrpcEndpointUrl, defaultValue: "http://localhost:4317");
        AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpHttpUrlOption, KnownConfigNames.DashboardOtlpHttpEndpointUrl, defaultValue: "http://localhost:4318");
        AddBoolOptionArg(parseResult, args, unmatchedTokens, environment, s_allowAnonymousOption, KnownConfigNames.DashboardUnsecuredAllowAnonymous);
 
        // Always enable the telemetry API so CLI commands (e.g. aspire otel) can query the dashboard,
        // unless the user has explicitly configured either the enabled or disabled setting.
        if (!ConfigSettingHasValue(unmatchedTokens, environment, KnownConfigNames.DashboardApiEnabled) &&
            !ConfigSettingHasValue(unmatchedTokens, environment, KnownConfigNames.DashboardApiDisabled))
        {
            args.Add($"--{KnownConfigNames.DashboardApiEnabled}=true");
        }
 
        AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_configFilePathOption, KnownConfigNames.DashboardConfigFilePath, defaultValue: null);
    }
 
    private static void AddStringOptionArg(ParseResult parseResult, List<string> args, IReadOnlyList<string> unmatchedTokens,
        IEnvironment environment, Option<string?> option, string envVarName, string? defaultValue)
    {
        if (ConfigSettingHasValue(unmatchedTokens, environment, envVarName))
        {
            return;
        }
 
        var value = parseResult.GetResult(option) is not null
            ? parseResult.GetValue(option)
            : defaultValue;
 
        if (value is not null)
        {
            args.Add($"--{envVarName}={value}");
        }
    }
 
    private static void AddBoolOptionArg(ParseResult parseResult, List<string> args, IReadOnlyList<string> unmatchedTokens,
        IEnvironment environment, Option<bool> option, string envVarName, bool? defaultValue = null)
    {
        if (ConfigSettingHasValue(unmatchedTokens, environment, envVarName))
        {
            return;
        }
 
        var result = parseResult.GetResult(option);
 
        // When the user explicitly specified the option, use their value.
        // When a defaultValue is provided and the user did not specify the option, use the default.
        // Without a defaultValue, skip when the result comes from the option's default value rather
        // than explicit user input, to avoid always emitting e.g. "--ALLOW_ANONYMOUS=false".
        if (result is not null && !result.Implicit)
        {
            var value = parseResult.GetValue(option);
            args.Add($"--{envVarName}={value.ToString().ToLowerInvariant()}");
        }
        else if (defaultValue is not null)
        {
            args.Add($"--{envVarName}={defaultValue.Value.ToString().ToLowerInvariant()}");
        }
    }
 
    internal static bool ConfigSettingHasValue(IReadOnlyList<string> unmatchedTokens, IEnvironment environment, string envVarName)
    {
        // Check if already provided via unmatched tokens.
        var prefix = $"--{envVarName}=";
        for (var i = 0; i < unmatchedTokens.Count; i++)
        {
            if (unmatchedTokens[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
 
            // Also handle bare "--KEY" (boolean flag) or "--KEY value" (space-separated) forms.
            // A bare key is treated as present because it will be forwarded to the
            // child process via AddRange(unmatchedTokens), which uses last-wins
            // semantics. For booleans (e.g. "--ALLOW_ANONYMOUS") it means true;
            // for strings the bare key overrides our default regardless.
            if (unmatchedTokens[i].Equals($"--{envVarName}", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }
 
        // Check if already set as an environment variable.
        if (environment.GetEnvironmentVariable(envVarName) is not null)
        {
            return true;
        }
 
        return false;
    }
 
    internal static DashboardInfo ResolveDashboardInfo(List<string> dashboardArgs, IReadOnlyList<string> unmatchedTokens, IEnvironment environment, string? browserToken)
    {
        var frontendUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, environment, KnownAspNetCoreConfigNames.Urls) ?? "http://localhost:18888";
        var otlpGrpcUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, environment, KnownConfigNames.DashboardOtlpGrpcEndpointUrl) ?? "http://localhost:4317";
        var otlpHttpUrl = ResolveSettingValue(dashboardArgs, unmatchedTokens, environment, KnownConfigNames.DashboardOtlpHttpEndpointUrl) ?? "http://localhost:4318";
 
        // Take the first URL if multiple are specified (semicolon-separated).
        var parts = frontendUrl.Split(';', StringSplitOptions.RemoveEmptyEntries);
        var firstUrl = parts.Length > 0 ? parts[0].TrimEnd('/') : "http://localhost:18888";
 
        var dashboardUrl = browserToken is not null
            ? $"{firstUrl}/login?t={browserToken}"
            : firstUrl;
 
        return new DashboardInfo(dashboardUrl, otlpGrpcUrl, otlpHttpUrl);
    }
 
    /// <summary>
    /// Resolves a setting value by checking, in order: args (--KEY=value), unmatched tokens
    /// (--KEY value with space separator), and environment variables.
    /// </summary>
    internal static string? ResolveSettingValue(List<string> args, IReadOnlyList<string> unmatchedTokens, IEnvironment environment, string key)
    {
        // First check --KEY=value in args (last-wins).
        var result = ResolveArgValue(args, key);
        if (result is not null)
        {
            return result;
        }
 
        // Check unmatched tokens for space-separated form: --KEY value
        var bareKey = $"--{key}";
        for (var i = 0; i < unmatchedTokens.Count; i++)
        {
            if (unmatchedTokens[i].Equals(bareKey, StringComparison.OrdinalIgnoreCase) && i + 1 < unmatchedTokens.Count)
            {
                return unmatchedTokens[i + 1];
            }
        }
 
        // Fall back to environment variable.
        return environment.GetEnvironmentVariable(key);
    }
 
    internal static string? ResolveArgValue(List<string> args, string key)
    {
        // Scan for --KEY=value (last-wins).
        string? result = null;
        var prefix = $"--{key}=";
        foreach (var arg in args)
        {
            if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                result = arg.Substring(prefix.Length);
            }
        }
 
        return result;
    }
 
    internal sealed record DashboardInfo(string DashboardUrl, string OtlpGrpcUrl, string OtlpHttpUrl);
 
    private static string GetExitCodeMessage(int exitCode)
    {
        return exitCode switch
        {
            DashboardExitCodes.UnexpectedError => DashboardCommandStrings.DashboardExitedUnexpectedError,
            DashboardExitCodes.ValidationFailure => DashboardCommandStrings.DashboardExitedValidationFailure,
            DashboardExitCodes.AddressInUse => DashboardCommandStrings.DashboardExitedAddressInUse,
            _ => string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardExitedWithError, exitCode),
        };
    }
 
    internal static void RenderDashboardSummary(IInteractionService interactionService, DashboardInfo info, string logFilePath)
    {
        interactionService.DisplayEmptyLine();
        var grid = new Grid();
        grid.AddColumn();
        grid.AddColumn();
 
        var dashboardLabel = DashboardCommandStrings.DashboardLabel;
        var otlpGrpcLabel = DashboardCommandStrings.OtlpGrpcLabel;
        var otlpHttpLabel = DashboardCommandStrings.OtlpHttpLabel;
        var logsLabel = DashboardCommandStrings.LogsLabel;
 
        var labels = new List<string> { dashboardLabel, otlpGrpcLabel, otlpHttpLabel, logsLabel };
 
        var longestLabelLength = labels.Max(s => s.Length) + 1; // +1 for colon
        grid.Columns[0].Width = longestLabelLength;
 
        // Dashboard row
        grid.AddRow(
            new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right),
            new Markup(MarkupHelpers.SafeLink(interactionService, info.DashboardUrl)));
        grid.AddRow(Text.Empty, Text.Empty);
 
        // OTLP gRPC row
        grid.AddRow(
            new Align(new Markup($"[bold green]{otlpGrpcLabel}[/]:"), HorizontalAlignment.Right),
            new Text(info.OtlpGrpcUrl));
        grid.AddRow(Text.Empty, Text.Empty);
 
        // OTLP HTTP row
        grid.AddRow(
            new Align(new Markup($"[bold green]{otlpHttpLabel}[/]:"), HorizontalAlignment.Right),
            new Text(info.OtlpHttpUrl));
        grid.AddRow(Text.Empty, Text.Empty);
 
        // Logs row
        grid.AddRow(
            new Align(new Markup($"[bold green]{logsLabel}[/]:"), HorizontalAlignment.Right),
            new Markup(MarkupHelpers.SafeFileLink(interactionService, logFilePath)));
 
        var padder = new Padder(grid, new Padding(3, 0));
        interactionService.DisplayRenderable(padder);
    }
 
    private async Task<CommandResult> ExecuteForegroundAsync(string managedPath, List<string> dashboardArgs, DashboardInfo dashboardInfo, IDictionary<string, string>? environmentVariables, CancellationToken cancellationToken)
    {
        _logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath);
 
        var outputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.Dashboard);
        var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var options = new ProcessInvocationOptions
        {
            StandardOutputCallback = line =>
            {
                outputCollector.AppendOutput(line);
 
                // The dashboard writes "Now listening on: {urls}" when it's ready to accept requests. 
                // Wait for that message before showing the dashboard URL to the user.
                // This message isn't localized, so we can reliably look for it in the output regardless of the user's language/locale.
                if (line.Contains("Now listening on:", StringComparison.OrdinalIgnoreCase))
                {
                    readyTcs.TrySetResult();
                }
            },
            StandardErrorCallback = outputCollector.AppendError,
        };
 
        IProcessExecution process;
        try
        {
            // Foreground `aspire dashboard run`: the dashboard is a child of this CLI and must not
            // outlive it, so bind it to the Windows kill-on-close job as an OS-level backstop on top of
            // the cross-platform parent-liveness watchdog. No-op on non-Windows hosts.
            process = await _layoutProcessRunner.StartAsync(managedPath, dashboardArgs, environmentVariables: environmentVariables, options: options, killOnParentExit: true).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to start dashboard process: {ManagedPath}", managedPath);
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardFailedToStart, ex.Message));
            return CommandResult.Failure(CliExitCodes.DashboardFailure);
        }
 
        await using var _ = process;
 
        // Wait for the dashboard to become ready, the process to exit, or a timeout.
        var processExitTask = process.WaitForExitAsync(cancellationToken);
        var readyOrFailed = Task.WhenAny(readyTcs.Task, processExitTask);
 
        var completedTask = await InteractionService.ShowStatusAsync(
            DashboardCommandStrings.StartingDashboard,
            async () =>
            {
                using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
 
                try
                {
                    return await readyOrFailed.WaitAsync(linkedCts.Token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
                {
                    // Timeout — return the processExitTask so the caller detects it wasn't the ready signal.
                    return processExitTask;
                }
            });
 
        if (cancellationToken.IsCancellationRequested)
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
 
            // Command is designed to be cancellable by the user (e.g. Ctrl+C) at any time.
            // Treat cancellation as a successful exit since the user intentionally stopped the dashboard.
            return CommandResult.Cancelled(CliExitCodes.Success);
        }
 
        if (completedTask != readyTcs.Task)
        {
            // Observe the processExitTask to avoid unobserved task exceptions.
            if (process.HasExited)
            {
                try
                {
                    await processExitTask.ConfigureAwait(false);
                }
                catch
                {
                    /* already handled via ExitCode below */
                }
            }
 
            // Dashboard didn't become ready — either it exited or timed out.
            var exitMessage = process.HasExited
                ? GetExitCodeMessage(process.ExitCode)
                : DashboardCommandStrings.DashboardStartTimedOut;
 
            InteractionService.DisplayError(exitMessage);
 
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
 
            return CommandResult.Failure(CliExitCodes.DashboardFailure);
        }
 
        // Dashboard is ready.
        RenderDashboardSummary(InteractionService, dashboardInfo, ExecutionContext.LogFilePath);
        InteractionService.DisplayEmptyLine();
 
        try
        {
            await processExitTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
 
            return CommandResult.Cancelled(CliExitCodes.Success);
        }
 
        if (process.ExitCode != 0)
        {
            InteractionService.DisplayError(GetExitCodeMessage(process.ExitCode));
        }
 
        return process.ExitCode == 0 ? CommandResult.Success() : CommandResult.Failure(CliExitCodes.DashboardFailure);
    }
}