Skip to content
Open
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
29 changes: 28 additions & 1 deletion playground/Stress/Stress.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;

Expand Down Expand Up @@ -124,7 +125,33 @@
await ExecuteCommandForAllResourcesAsync(c.ServiceProvider, KnownResourceCommands.StartCommand, c.CancellationToken);
return CommandResults.Success();
},
commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled });
commandOptions: new() { IconName = "Play", IconVariant = IconVariant.Filled })
.WithCommand(
name: "generate-token",
displayName: "Generate Token",
executeCommand: (c) =>
{
var token = new
{
accessToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Convert.ToBase64String(Guid.NewGuid().ToByteArray()),
tokenType = "Bearer",
expiresIn = 3600,
scope = "api.read api.write",
issuedAt = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true });
return Task.FromResult(CommandResults.Success(json, CommandResultFormat.Json));
},
commandOptions: new() { IconName = "Key", Description = "Generate a temporary access token" })
.WithCommand(
name: "get-connection-string",
displayName: "Get Connection String",
executeCommand: (c) =>
{
var connectionString = $"Server=localhost,1433;Database=StressDb;User Id=sa;Password={Guid.NewGuid():N};TrustServerCertificate=true";
return Task.FromResult(CommandResults.Success(connectionString, CommandResultFormat.Text));
},
commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" });

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
Expand Down
9 changes: 9 additions & 0 deletions src/Aspire.Cli/Commands/ResourceCommandHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,22 @@ public static async Task<int> ExecuteGenericCommandAsync(
{
logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName);

// Route status messages to stderr so command results can be piped (e.g., | jq)
interactionService.Console = ConsoleOutput.Error;

var response = await interactionService.ShowStatusAsync(
$"Executing command '{commandName}' on resource '{resourceName}'...",
async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken));

if (response.Success)
{
interactionService.DisplaySuccess($"Command '{commandName}' executed successfully on resource '{resourceName}'.");

if (response.Result is not null)
{
interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard);
}
Comment on lines +73 to +76
Copy link
Member

Choose a reason for hiding this comment

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

Why only display result on success? I can imagine wanting to display output on error.

Comment on lines +74 to +76
Copy link
Member

Choose a reason for hiding this comment

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

This result handling only exists in ExecuteGenericCommandAsync. The sibling path via ExecuteResourceCommandAsyncHandleResponse doesn't check response.Result, so if a well-known command (start/stop/restart) ever returns result data, it will be silently discarded. Worth either forwarding results in HandleResponse too, or documenting that well-known commands intentionally ignore result data.


return ExitCodeConstants.Success;
}
else if (response.Canceled)
Expand Down
12 changes: 11 additions & 1 deletion src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,19 @@ public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext co

if (response.Success)
{
var content = new List<TextContentBlock>
{
new() { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }
};

if (response.Result is not null)
{
content.Add(new TextContentBlock { Text = response.Result });
}
Comment on lines +77 to +85
Copy link
Member

Choose a reason for hiding this comment

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

As noted in previous comment, result is only available for success result.


return new CallToolResult
{
Content = [new TextContentBlock { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }]
Content = [.. content]
};
}
else if (response.Canceled)
Expand Down
17 changes: 15 additions & 2 deletions src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
Expand All @@ -13,7 +14,7 @@ namespace Aspire.Dashboard.Model;

public sealed class DashboardCommandExecutor(
IDashboardClient dashboardClient,
IDialogService dialogService,
IServiceProvider serviceProvider,
Copy link
Member

Choose a reason for hiding this comment

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

Why not pass in DashboardDialogService directly? It and the containing type have the same lifetime (scoped) so shouldn't it be fine?

IToastService toastService,
IStringLocalizer<Dashboard.Resources.Resources> loc,
NavigationManager navigationManager,
Expand Down Expand Up @@ -86,7 +87,7 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel
{
if (!string.IsNullOrWhiteSpace(command.ConfirmationMessage))
{
var dialogReference = await dialogService.ShowConfirmationAsync(command.ConfirmationMessage).ConfigureAwait(false);
var dialogReference = await serviceProvider.GetRequiredService<DashboardDialogService>().ShowConfirmationAsync(command.ConfirmationMessage).ConfigureAwait(false);
var result = await dialogReference.Result.ConfigureAwait(false);
if (result.Cancelled)
{
Expand Down Expand Up @@ -148,6 +149,18 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel
toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], messageResourceName, command.GetDisplayName());
toastParameters.Intent = ToastIntent.Success;
toastParameters.Icon = GetIntentIcon(ToastIntent.Success);

if (response.Result is not null)
{
var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null;
Copy link
Member

Choose a reason for hiding this comment

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

Is this the reason there is a resultformat? JSON content is automatically detected by the visualizer dialog so this isn't nessessary.

Copy link
Member

@JamesNK JamesNK Mar 27, 2026

Choose a reason for hiding this comment

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

I thought about this more, and being able to return markdown and displaying HTML would be valuable, and you'd need to specify the content is markdown. Don't need to support this now, but leave the resultformat in to extend in the future.

await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
Copy link
Member

Choose a reason for hiding this comment

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

I think this is ok for an initial PR, but it's not a good user experience. With a long running command you could randomly have a modal dialog open while using the dashboard. And you might not care to look at the result, just care whether it succeeded or failed.

I think what we should have long term is a notification icon in the header that opens and displays recent notifications. From here you could choose to look at the result from a command.

Basically, copy what Azure Portal (and may other websites) do with notifications for async tasks.

{
DialogService = serviceProvider.GetRequiredService<DashboardDialogService>(),
ValueDescription = command.GetDisplayName(),
Value = response.Result,
FixedFormat = fixedFormat
}).ConfigureAwait(false);
}
Comment on lines +153 to +163
Copy link
Member

Choose a reason for hiding this comment

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

The await TextVisualizerDialog.OpenDialogAsync(...) blocks until the user dismisses the dialog. This runs before the toast update logic below, so while the dialog is open the toast is either stuck in the "Executing command…" progress state or auto-closes via closeToastCts. The success toast update is deferred until after the dialog is dismissed.

Consider either moving this call to after the toast update block, or fire-and-forgetting the dialog so the toast updates immediately.

Copy link
Member

@JamesNK JamesNK Mar 27, 2026

Choose a reason for hiding this comment

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

I tested and I didn't see this. And this wouldn't be a concern after switching to accessing result via persistent notification.

}
else if (response.Kind == ResourceCommandResponseKind.Cancelled)
{
Expand Down
23 changes: 23 additions & 0 deletions src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public class ResourceCommandResponseViewModel
{
public required ResourceCommandResponseKind Kind { get; init; }
public string? ErrorMessage { get; init; }
public string? Result { get; init; }
public CommandResultFormat ResultFormat { get; init; }
}

// Must be kept in sync with ResourceCommandResponseKind in the resource_service.proto file
Expand All @@ -17,3 +19,24 @@ public enum ResourceCommandResponseKind
Failed = 2,
Cancelled = 3
}

/// <summary>
/// Specifies the format of a command result.
/// </summary>
public enum CommandResultFormat
{
/// <summary>
/// No result data.
/// </summary>
None = 0,

/// <summary>
/// Plain text result.
/// </summary>
Text = 1,

/// <summary>
/// JSON result.
/// </summary>
Json = 2
}
4 changes: 3 additions & 1 deletion src/Aspire.Dashboard/ServiceClient/Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ public ResourceCommandResponseViewModel ToViewModel()
return new ResourceCommandResponseViewModel()
{
ErrorMessage = ErrorMessage,
Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind
Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind,
Result = HasResult ? Result : null,
ResultFormat = (Dashboard.Model.CommandResultFormat)ResultFormat
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ public enum IconVariant
Filled
}

/// <summary>
/// Specifies the format of a command result.
/// </summary>
public enum CommandResultFormat
{
/// <summary>
/// No result data.
/// </summary>
None = 0,

/// <summary>
/// Plain text result.
/// </summary>
Text = 1,

/// <summary>
/// JSON result.
/// </summary>
Json = 2
}

/// <summary>
/// A factory for <see cref="ExecuteCommandResult"/>.
/// </summary>
Expand All @@ -124,6 +145,13 @@ public static class CommandResults
/// </summary>
public static ExecuteCommandResult Success() => new() { Success = true };

/// <summary>
/// Produces a success result with result data.
/// </summary>
/// <param name="result">The result data.</param>
/// <param name="resultFormat">The format of the result data. Defaults to <see cref="CommandResultFormat.Text"/>.</param>
public static ExecuteCommandResult Success(string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Result = result, ResultFormat = resultFormat };

/// <summary>
/// Produces an unsuccessful result with an error message.
/// </summary>
Expand Down Expand Up @@ -162,6 +190,16 @@ public sealed class ExecuteCommandResult
/// An optional error message that can be set when the command is unsuccessful.
/// </summary>
public string? ErrorMessage { get; init; }

/// <summary>
/// An optional result value produced by the command.
/// </summary>
public string? Result { get; init; }

/// <summary>
/// The format of the <see cref="Result"/> value.
/// </summary>
public CommandResultFormat ResultFormat { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ public async Task<ExecuteCommandResult> ExecuteCommandAsync(IResource resource,

if (failures.Count == 0 && cancellations.Count == 0)
{
return new ExecuteCommandResult { Success = true };
var successWithResult = results.FirstOrDefault(r => r.Result is not null);
return new ExecuteCommandResult
{
Success = true,
Result = successWithResult?.Result,
ResultFormat = successWithResult?.ResultFormat ?? CommandResultFormat.None
};
}
else if (failures.Count == 0 && cancellations.Count > 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ public async Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(Ex
{
Success = result.Success,
Canceled = result.Canceled,
ErrorMessage = result.ErrorMessage
ErrorMessage = result.ErrorMessage,
Result = result.Result,
ResultFormat = result.ResultFormat.ToString().ToLowerInvariant()
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ internal sealed class ExecuteResourceCommandResponse
/// Gets the error message if the command failed.
/// </summary>
public string? ErrorMessage { get; init; }

/// <summary>
/// Gets the result data produced by the command.
/// </summary>
public string? Result { get; init; }

/// <summary>
/// Gets the format of the result data (e.g. "none", "text", "json").
/// </summary>
public string? ResultFormat { get; init; }
}

#endregion
Expand Down
14 changes: 11 additions & 3 deletions src/Aspire.Hosting/Dashboard/DashboardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ async Task WatchResourceConsoleLogsInternal(bool suppressFollow, CancellationTok

public override async Task<ResourceCommandResponse> ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context)
{
var (result, errorMessage) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false);
var (result, errorMessage, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false);
var responseKind = result switch
{
ExecuteCommandResultType.Success => ResourceCommandResponseKind.Succeeded,
Expand All @@ -369,11 +369,19 @@ public override async Task<ResourceCommandResponse> ExecuteResourceCommand(Resou
_ => ResourceCommandResponseKind.Undefined
};

return new ResourceCommandResponse
var response = new ResourceCommandResponse
{
Kind = responseKind,
ErrorMessage = errorMessage ?? string.Empty
ErrorMessage = errorMessage ?? string.Empty,
ResultFormat = (Aspire.DashboardService.Proto.V1.CommandResultFormat)resultFormat
};

if (commandResult is not null)
{
response.Result = commandResult;
}

return response;
}

private async Task ExecuteAsync(Func<CancellationToken, Task> execute, ServerCallContext serverCallContext)
Expand Down
8 changes: 4 additions & 4 deletions src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,21 @@ public void Dispose()
_cts.Dispose();
}

internal async Task<(ExecuteCommandResultType result, string? errorMessage)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken)
internal async Task<(ExecuteCommandResultType result, string? errorMessage, string? commandResult, ApplicationModel.CommandResultFormat resultFormat)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken)
{
try
{
var result = await _resourceCommandService.ExecuteCommandAsync(resourceId, type, cancellationToken).ConfigureAwait(false);
if (result.Canceled)
{
return (ExecuteCommandResultType.Canceled, result.ErrorMessage);
return (ExecuteCommandResultType.Canceled, result.ErrorMessage, null, ApplicationModel.CommandResultFormat.None);
}
return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage);
return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage, result.Result, result.ResultFormat);
}
catch
{
// Note: Exception is already logged in the command executor.
return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.");
return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.", null, ApplicationModel.CommandResultFormat.None);
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ enum ResourceCommandResponseKind {
message ResourceCommandResponse {
ResourceCommandResponseKind kind = 1;
optional string error_message = 2;
optional string result = 3;
CommandResultFormat result_format = 4;
}

enum CommandResultFormat {
COMMAND_RESULT_FORMAT_NONE = 0;
COMMAND_RESULT_FORMAT_TEXT = 1;
COMMAND_RESULT_FORMAT_JSON = 2;
}

////////////////////////////////////////////
Expand Down
Loading
Loading