Skip to content
Merged
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
48 changes: 45 additions & 3 deletions playground/Stress/Stress.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@
issuedAt = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true });
return Task.FromResult(CommandResults.Success(json, CommandResultFormat.Json));
var resultData = new CommandResultData
{
Value = json,
Format = CommandResultFormat.Json
};
return Task.FromResult(CommandResults.Success("Generated token.", resultData));
},
commandOptions: new() { IconName = "Key", Description = "Generate a temporary access token" })
.WithCommand(
Expand All @@ -149,7 +154,18 @@
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));
var message = """
Retrieved connection string. The database connection was established successfully
after verifying TLS certificates and negotiating encryption parameters.

The server responded with protocol version 7.4 and confirmed support for multiple
active result sets. Connection pooling is enabled with a maximum pool size of 100
connections and a minimum of 10 idle connections maintained.

The login handshake completed in 42ms with SSPI authentication. All pre-login
checks passed including network library validation and instance name resolution.
""";
return Task.FromResult(CommandResults.Success(message, new CommandResultData { Value = connectionString, DisplayImmediately = true }));
},
commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" })
.WithCommand(
Expand All @@ -169,7 +185,33 @@
{
return Task.FromResult(CommandResults.Failure("Health check failed", "Connection refused: ECONNREFUSED 127.0.0.1:5432\nRetries exhausted after 3 attempts", CommandResultFormat.Text));
},
commandOptions: new() { IconName = "HeartBroken", Description = "Check resource health (always fails with details)" });
commandOptions: new() { IconName = "HeartBroken", Description = "Check resource health (always fails with details)" })
.WithCommand(
name: "migrate-database",
displayName: "Migrate Database",
executeCommand: (c) =>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is the experience not consistent with no pop-ups showing up for Migrate Database when there is one for Get Connection String? It feels a little incongruous

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

They're test commands. It's for testing different scenarios.

{
var markdown = """
# ⚙️ Database Migration Summary

| Table | Result |
|------------|----------------------------|
| Customers | ✅ 1,200 rows |
| Products | ✅ 850 rows |
| Orders | ✅ 3,400 rows |
| OrderItems | ✅ 8,750 rows |
| Categories | ✅ 45 rows |
| Reviews | ❌ FK constraint violation |
| Inventory | ✅ 850 rows |
| Shipping | ✅ 3,400 rows |
| Payments | ❌ Timeout after 30s |
| Coupons | ✅ 120 rows |

**Summary:** 8 of 10 tables migrated successfully. 2 tables failed.
""";
return Task.FromResult(CommandResults.Success("Database migrated.", new CommandResultData { Value = markdown, Format = CommandResultFormat.Markdown }));
},
commandOptions: new() { IconName = "CloudDatabase", Description = "Migrate the database with sample store data" });

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
Expand Down
28 changes: 22 additions & 6 deletions src/Aspire.Cli/Commands/ResourceCommandHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,15 @@ public static async Task<int> ExecuteGenericCommandAsync(
}
else
{
var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage);
#pragma warning disable CS0618 // Type or member is obsolete
var errorMessage = GetFriendlyErrorMessage(response.Message ?? response.ErrorMessage);
#pragma warning restore CS0618 // Type or member is obsolete
interactionService.DisplayError($"Failed to execute command '{commandName}' on resource '{resourceName}': {errorMessage}");
}

if (response.Result is not null)
if (response.Value is not null)
{
interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard);
DisplayCommandResult(interactionService, response.Value);
}

return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand;
Expand All @@ -108,18 +110,32 @@ private static int HandleResponse(
}
else
{
var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage);
#pragma warning disable CS0618 // Type or member is obsolete
var errorMessage = GetFriendlyErrorMessage(response.Message ?? response.ErrorMessage);
#pragma warning restore CS0618 // Type or member is obsolete
interactionService.DisplayError($"Failed to {baseVerb} resource '{resourceName}': {errorMessage}");
}

if (response.Result is not null)
if (response.Value is not null)
{
interactionService.DisplayRawText(response.Result, ConsoleOutput.Standard);
DisplayCommandResult(interactionService, response.Value);
}

return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand;
}

private static void DisplayCommandResult(IInteractionService interactionService, ExecuteResourceCommandResult result)
{
if (result.Format is CommandResultFormat.Markdown)
{
interactionService.DisplayMarkdown(result.Value, ConsoleOutput.Standard);
}
else
{
interactionService.DisplayRawText(result.Value, ConsoleOutput.Standard);
}
}

private static string GetFriendlyErrorMessage(string? errorMessage)
{
return string.IsNullOrEmpty(errorMessage) ? "Unknown error occurred." : errorMessage;
Expand Down
6 changes: 4 additions & 2 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,12 @@ public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null)
target.Profile.Out.Writer.WriteLine(text);
}

public void DisplayMarkdown(string markdown)
public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null)
{
var effectiveConsole = consoleOverride ?? Console;
var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole;
var spectreMarkup = MarkdownToSpectreConverter.ConvertToSpectre(markdown);
MessageConsole.MarkupLine(spectreMarkup);
target.MarkupLine(spectreMarkup);
}

public void DisplayMarkupLine(string markup)
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Interaction/ExtensionInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,13 +391,13 @@ public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null)
_consoleInteractionService.DisplayRawText(text, consoleOverride);
}

public void DisplayMarkdown(string markdown)
public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null)
{
// Send raw markdown to extension (it can handle markdown natively)
// Convert to Spectre markup for console display
var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.LogMessageAsync(LogLevel.Information, markdown, _cancellationToken));
Debug.Assert(result);
_consoleInteractionService.DisplayMarkdown(markdown);
_consoleInteractionService.DisplayMarkdown(markdown, consoleOverride);
}

public void DisplayMarkupLine(string markup)
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Interaction/IInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal interface IInteractionService
void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false);
void DisplayPlainText(string text);
void DisplayRawText(string text, ConsoleOutput? consoleOverride = null);
void DisplayMarkdown(string markdown);
void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null);
void DisplayMarkupLine(string markup);
void DisplaySuccess(string message, bool allowMarkup = false);
void DisplaySubtleMessage(string message, bool allowMarkup = false);
Expand Down
12 changes: 7 additions & 5 deletions src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext co
new() { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }
};

if (response.Result is not null)
if (response.Value is not null)
{
content.Add(new TextContentBlock { Text = response.Result });
content.Add(new TextContentBlock { Text = response.Value.Value });
}

return new CallToolResult
Expand All @@ -95,16 +95,18 @@ public override async ValueTask<CallToolResult> CallToolAsync(CallToolContext co
}
else
{
var message = response.ErrorMessage is { Length: > 0 } ? response.ErrorMessage : "Unknown error. See logs for details.";
#pragma warning disable CS0618 // Type or member is obsolete
var message = (response.Message ?? response.ErrorMessage) is { Length: > 0 } errorMsg ? errorMsg : "Unknown error. See logs for details.";
#pragma warning restore CS0618 // Type or member is obsolete

var content = new List<TextContentBlock>
{
new() { Text = $"Command '{commandName}' failed for resource '{resourceName}': {message}" }
};

if (response.Result is not null)
if (response.Value is not null)
{
content.Add(new TextContentBlock { Text = response.Result });
content.Add(new TextContentBlock { Text = response.Value.Value });
}

return new CallToolResult
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@using Aspire.Dashboard.Model
@using Aspire.Dashboard.Otlp.Model
@using Aspire.Dashboard.Resources
@using Microsoft.FluentUI.AspNetCore.Components.Extensions
@inject TimeProvider TimeProvider
@inject IStringLocalizer<Dialogs> Loc

<div class="notification-entry @IntentClass">
@* Header *@
<div class="notification-entry-icon">
<FluentIcon Value="@Icon" Color="@IconColor" />
</div>
<div class="notification-entry-message">
@Entry.Title
</div>
<div class="notification-entry-close">
<FluentButton Appearance="Appearance.Stealth"
aria-label="@Loc[nameof(Dialogs.NotificationEntryDismiss)]"
OnClick="@HandleDismiss">
<FluentIcon Value="@(new Icons.Regular.Size16.Dismiss())" Color="@Color.Neutral" />
</FluentButton>
</div>

@* Detailed content *@
<div class="notification-entry-content">
@if (!string.IsNullOrEmpty(Entry.Body))
{
@OtlpHelpers.TruncateString(Entry.Body, 500)
}
@if (Entry.PrimaryAction is { } primaryAction)
{
<FluentButton Appearance="Appearance.Neutral"
OnClick="@HandlePrimaryAction"
Class="notification-entry-action">
@primaryAction.Text
</FluentButton>
}
</div>

@* Timestamp *@
<div class="notification-entry-time">
@((TimeProvider.GetUtcNow() - Entry.Timestamp).ToTimeAgo())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: The relative timestamp (ToTimeAgo()) is computed at render time and won't auto-refresh while the dialog stays open — so "5 minutes ago" will remain frozen until something triggers a re-render. I think this is fine as-is (notification centers are typically opened briefly, and it refreshes whenever new notifications arrive), but wanted to flag it in case others feel differently.

</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Model;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;

namespace Aspire.Dashboard.Components.Dialogs;

public partial class NotificationEntryComponent : ComponentBase
{
[Parameter, EditorRequired]
public required NotificationEntry Entry { get; set; }

[Parameter]
public EventCallback OnDismiss { get; set; }

private string IntentClass => Entry.Intent switch
{
MessageIntent.Success => "intent-success",
MessageIntent.Error => "intent-error",
MessageIntent.Warning => "intent-warning",
_ => "intent-info"
};

private Icon Icon => Entry.Intent switch
{
MessageIntent.Success => new Icons.Filled.Size20.CheckmarkCircle(),
MessageIntent.Error => new Icons.Filled.Size20.DismissCircle(),
MessageIntent.Warning => new Icons.Filled.Size20.Warning(),
_ => new Icons.Filled.Size20.Info()
};

private Color IconColor => Entry.Intent switch
{
MessageIntent.Success => Color.Success,
MessageIntent.Error => Color.Error,
MessageIntent.Warning => Color.Warning,
_ => Color.Info
};

private async Task HandleDismiss()
{
await OnDismiss.InvokeAsync();
}

private async Task HandlePrimaryAction()
{
if (Entry.PrimaryAction is { } primaryAction)
{
await primaryAction.OnClick();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
.notification-entry {
font-family: var(--body-font);
color: var(--neutral-foreground-rest);
display: grid;
grid-template-columns: 24px 1fr auto;
width: 100%;
min-height: 36px;
padding: 0 8px;
column-gap: 4px;
border-top: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
}

.notification-entry.intent-info {
fill: var(--info);
}

.notification-entry.intent-warning {
fill: var(--warning);
}

.notification-entry.intent-error {
fill: var(--error);
}

.notification-entry.intent-success {
fill: var(--success);
}

.notification-entry-icon {
grid-column: 1;
grid-row: 1;
display: flex;
justify-content: center;
align-self: center;
}

.notification-entry-message {
grid-column: 2;
grid-row: 1;
padding: 10px 0;
align-self: center;
font-weight: 600;
font-size: 12px;
line-height: 16px;
white-space: unset;
}

.notification-entry-close {
grid-column: 3;
grid-row: 1;
padding: 4px;
display: flex;
justify-content: center;
justify-self: center;
cursor: pointer;
}

.notification-entry-content {
grid-column: 1 / 4;
grid-row: 2;
padding: 6px 6px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 12px;
word-break: break-word;
}

.notification-entry-time {
grid-column: 2 / 4;
grid-row: 3;
font-size: 12px;
text-align: right;
padding: 4px 4px 8px 0px;
color: var(--foreground-subtext-rest);
}

::deep .notification-entry-action {
height: 24px;
font-size: 12px;
}
Loading
Loading