Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/Aspire.Cli/Commands/DashboardCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Commands;

/// <summary>
/// Command that starts a standalone Aspire Dashboard instance.
/// </summary>
internal sealed class DashboardCommand : BaseCommand
{
internal override HelpGroup HelpGroup => HelpGroup.Monitoring;

private readonly IInteractionService _interactionService;
private readonly ILayoutDiscovery _layoutDiscovery;
private readonly ILogger<DashboardCommand> _logger;

private static readonly Option<bool> s_detachOption = new("--detach")
{
Description = DashboardCommandStrings.DetachOptionDescription
};

public DashboardCommand(
IInteractionService interactionService,
ILayoutDiscovery layoutDiscovery,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
ILogger<DashboardCommand> logger,
AspireCliTelemetry telemetry)
: base("dashboard", DashboardCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_layoutDiscovery = layoutDiscovery;
_logger = logger;

Options.Add(s_detachOption);
TreatUnmatchedTokensAsErrors = false;
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var layout = _layoutDiscovery.DiscoverLayout();
if (layout is null)
{
_interactionService.DisplayError(DashboardCommandStrings.BundleNotAvailable);
return ExitCodeConstants.DashboardFailure;
}

var managedPath = layout.GetManagedPath();
if (managedPath is null || !File.Exists(managedPath))
{
_interactionService.DisplayError(DashboardCommandStrings.BundleNotAvailable);
return ExitCodeConstants.DashboardFailure;
}

var dashboardArgs = new List<string> { "dashboard" };
dashboardArgs.AddRange(parseResult.UnmatchedTokens);

Comment on lines +68 to +69
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseResult.UnmatchedTokens can include the "--" delimiter (see how ExecCommand explicitly searches for it). Forwarding the delimiter to aspire-managed dashboard will cause everything after it to be treated as positional args, so aspire dashboard -- --urls ... won't work as intended. Strip the delimiter before appending pass-through args (i.e., forward only the tokens after -- when present).

Suggested change
dashboardArgs.AddRange(parseResult.UnmatchedTokens);
var unmatchedTokens = parseResult.UnmatchedTokens;
var startIndex = 0;
for (var i = 0; i < unmatchedTokens.Count; i++)
{
if (unmatchedTokens[i] == "--")
{
startIndex = i + 1;
break;
}
}
for (var i = startIndex; i < unmatchedTokens.Count; i++)
{
dashboardArgs.Add(unmatchedTokens[i]);
}

Copilot uses AI. Check for mistakes.
var detach = parseResult.GetValue(s_detachOption);

if (detach)
{
return ExecuteDetached(managedPath, dashboardArgs);
}

return await ExecuteForegroundAsync(managedPath, dashboardArgs, cancellationToken).ConfigureAwait(false);
}
Comment on lines +67 to +78
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current tests cover --help and the bundle-not-available failure, but they don't cover the new argument pass-through behavior (especially handling of --), or the detach/foreground launch paths. Add unit tests that verify the forwarded argument list and the --detach behavior (e.g., by introducing an injectable process launcher abstraction so tests can assert the executable/args/working directory without spawning real processes).

Copilot uses AI. Check for mistakes.

private int ExecuteDetached(string managedPath, List<string> dashboardArgs)
{
_logger.LogDebug("Starting dashboard in detached mode: {ManagedPath}", managedPath);

var process = DetachedProcessLauncher.Start(managedPath, dashboardArgs, Directory.GetCurrentDirectory());

_interactionService.DisplayMessage(KnownEmojis.Rocket,
Comment on lines +84 to +86
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detached mode uses Directory.GetCurrentDirectory() for the child process working directory, which can diverge from ExecutionContext.WorkingDirectory (notably in tests and when the CLI changes working directory semantics). Use ExecutionContext.WorkingDirectory.FullName (and consider passing the same working directory to LayoutProcessRunner.Start for consistency).

Copilot uses AI. Check for mistakes.
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardStarted, process.Id));

return ExitCodeConstants.Success;
}

private async Task<int> ExecuteForegroundAsync(string managedPath, List<string> dashboardArgs, CancellationToken cancellationToken)
{
_logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath);

using var process = LayoutProcessRunner.Start(managedPath, dashboardArgs, redirectOutput: false);

try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}

return ExitCodeConstants.Success;
}

if (process.ExitCode != 0)
{
_interactionService.DisplayError(
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardExitedWithError, process.ExitCode));
}

return process.ExitCode == 0 ? ExitCodeConstants.Success : ExitCodeConstants.DashboardFailure;
Comment on lines +84 to +118
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DetachedProcessLauncher.Start(...) / LayoutProcessRunner.Start(...) can throw (e.g., permission issues, missing execute bit, invalid binary). Right now those exceptions bubble to the top-level handler and become a generic "unexpected error" with exit code 1, rather than returning ExitCodeConstants.DashboardFailure with a dashboard-specific message. Catch exceptions around process start/launch and convert them into a clear DisplayError(...) + DashboardFailure exit code.

Suggested change
var process = DetachedProcessLauncher.Start(managedPath, dashboardArgs, Directory.GetCurrentDirectory());
_interactionService.DisplayMessage(KnownEmojis.Rocket,
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardStarted, process.Id));
return ExitCodeConstants.Success;
}
private async Task<int> ExecuteForegroundAsync(string managedPath, List<string> dashboardArgs, CancellationToken cancellationToken)
{
_logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath);
using var process = LayoutProcessRunner.Start(managedPath, dashboardArgs, redirectOutput: false);
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
return ExitCodeConstants.Success;
}
if (process.ExitCode != 0)
{
_interactionService.DisplayError(
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardExitedWithError, process.ExitCode));
}
return process.ExitCode == 0 ? ExitCodeConstants.Success : ExitCodeConstants.DashboardFailure;
try
{
var process = DetachedProcessLauncher.Start(managedPath, dashboardArgs, Directory.GetCurrentDirectory());
_interactionService.DisplayMessage(
KnownEmojis.Rocket,
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardStarted, process.Id));
return ExitCodeConstants.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start dashboard in detached mode: {ManagedPath}", managedPath);
_interactionService.DisplayError(
string.Format(CultureInfo.CurrentCulture, "Failed to start Aspire dashboard: {0}", ex.Message));
return ExitCodeConstants.DashboardFailure;
}
}
private async Task<int> ExecuteForegroundAsync(string managedPath, List<string> dashboardArgs, CancellationToken cancellationToken)
{
_logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath);
try
{
using var process = LayoutProcessRunner.Start(managedPath, dashboardArgs, redirectOutput: false);
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
return ExitCodeConstants.Success;
}
if (process.ExitCode != 0)
{
_interactionService.DisplayError(
string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardExitedWithError, process.ExitCode));
}
return process.ExitCode == 0 ? ExitCodeConstants.Success : ExitCodeConstants.DashboardFailure;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start dashboard in foreground: {ManagedPath}", managedPath);
_interactionService.DisplayError(
string.Format(CultureInfo.CurrentCulture, "Failed to start Aspire dashboard: {0}", ex.Message));
return ExitCodeConstants.DashboardFailure;
}

Copilot uses AI. Check for mistakes.
}
}
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Commands/RootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ public RootCommand(
AgentCommand agentCommand,
TelemetryCommand telemetryCommand,
ExportCommand exportCommand,
DashboardCommand dashboardCommand,
DocsCommand docsCommand,
SecretCommand secretCommand,
SdkCommand sdkCommand,
Expand Down Expand Up @@ -222,6 +223,7 @@ public RootCommand(
Subcommands.Add(telemetryCommand);
Subcommands.Add(exportCommand);
Subcommands.Add(docsCommand);
Subcommands.Add(dashboardCommand);
Subcommands.Add(secretCommand);

#if DEBUG
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, CliStartu
builder.Services.AddTransient<CertificatesCleanCommand>();
builder.Services.AddTransient<CertificatesTrustCommand>();
builder.Services.AddTransient<DoctorCommand>();
builder.Services.AddTransient<DashboardCommand>();
builder.Services.AddTransient<UpdateCommand>();
builder.Services.AddTransient<DeployCommand>();
builder.Services.AddTransient<DoCommand>();
Expand Down
79 changes: 79 additions & 0 deletions src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 135 additions & 0 deletions src/Aspire.Cli/Resources/DashboardCommandStrings.resx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema

Version 2.0

The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.

Example:

... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>

There are any number of "resheader" rows that contain simple
name/value pairs.

Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.

The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:

Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.

mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Description" xml:space="preserve">
<value>Start the Aspire dashboard</value>
</data>
<data name="DetachOptionDescription" xml:space="preserve">
<value>Run the dashboard in the background</value>
</data>
<data name="BundleNotAvailable" xml:space="preserve">
<value>The Aspire dashboard requires the CLI bundle. The bundle was not found.</value>
</data>
<data name="DashboardStarted" xml:space="preserve">
<value>Dashboard started (PID {0}).</value>
</data>
<data name="DashboardExitedWithError" xml:space="preserve">
<value>Dashboard exited with exit code {0}.</value>
</data>
</root>
32 changes: 32 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading