From 7513cc8256f1a45e3427935b599c146caf577071 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 09:49:50 +0800 Subject: [PATCH 01/16] Add notifications dialog --- .../Dialogs/NotificationsDialog.razor | 60 ++++++++++ .../Dialogs/NotificationsDialog.razor.cs | 48 ++++++++ .../Dialogs/NotificationsDialog.razor.css | 49 ++++++++ .../Components/Layout/MainLayout.razor | 2 + .../Components/Layout/MainLayout.razor.cs | 37 ++++++ .../Components/Layout/MobileNavMenu.razor | 3 + .../Components/Layout/MobileNavMenu.razor.cs | 6 + .../Layout/NotificationsHeaderButton.razor | 28 +++++ .../Layout/NotificationsHeaderButton.razor.cs | 35 ++++++ .../DashboardWebApplication.cs | 2 + .../Model/DashboardCommandExecutor.cs | 30 ++++- .../Model/INotificationService.cs | 51 +++++++++ .../Model/NotificationItem.cs | 36 ++++++ .../Model/NotificationService.cs | 107 ++++++++++++++++++ .../Resources/Dialogs.Designer.cs | 18 +++ src/Aspire.Dashboard/Resources/Dialogs.resx | 6 + .../Resources/Layout.Designer.cs | 18 +++ src/Aspire.Dashboard/Resources/Layout.resx | 6 + .../Resources/xlf/Dialogs.cs.xlf | 10 ++ .../Resources/xlf/Dialogs.de.xlf | 10 ++ .../Resources/xlf/Dialogs.es.xlf | 10 ++ .../Resources/xlf/Dialogs.fr.xlf | 10 ++ .../Resources/xlf/Dialogs.it.xlf | 10 ++ .../Resources/xlf/Dialogs.ja.xlf | 10 ++ .../Resources/xlf/Dialogs.ko.xlf | 10 ++ .../Resources/xlf/Dialogs.pl.xlf | 10 ++ .../Resources/xlf/Dialogs.pt-BR.xlf | 10 ++ .../Resources/xlf/Dialogs.ru.xlf | 10 ++ .../Resources/xlf/Dialogs.tr.xlf | 10 ++ .../Resources/xlf/Dialogs.zh-Hans.xlf | 10 ++ .../Resources/xlf/Dialogs.zh-Hant.xlf | 10 ++ .../Resources/xlf/Layout.cs.xlf | 10 ++ .../Resources/xlf/Layout.de.xlf | 10 ++ .../Resources/xlf/Layout.es.xlf | 10 ++ .../Resources/xlf/Layout.fr.xlf | 10 ++ .../Resources/xlf/Layout.it.xlf | 10 ++ .../Resources/xlf/Layout.ja.xlf | 10 ++ .../Resources/xlf/Layout.ko.xlf | 10 ++ .../Resources/xlf/Layout.pl.xlf | 10 ++ .../Resources/xlf/Layout.pt-BR.xlf | 10 ++ .../Resources/xlf/Layout.ru.xlf | 10 ++ .../Resources/xlf/Layout.tr.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hans.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hant.xlf | 10 ++ .../Shared/FluentUISetupHelpers.cs | 1 + 45 files changed, 800 insertions(+), 3 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css create mode 100644 src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor create mode 100644 src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs create mode 100644 src/Aspire.Dashboard/Model/INotificationService.cs create mode 100644 src/Aspire.Dashboard/Model/NotificationItem.cs create mode 100644 src/Aspire.Dashboard/Model/NotificationService.cs diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor new file mode 100644 index 00000000000..98e333b5c45 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor @@ -0,0 +1,60 @@ +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Resources +@inject IStringLocalizer Loc + + +
+ + + @if (_notifications.Count > 0) + { + + @Loc[nameof(Dialogs.NotificationCenterDismissAll)] + + } + +
+ @if (_notifications.Count == 0) + { +
+ +

@Loc[nameof(Dialogs.NotificationCenterEmpty)]

+
+ } + else + { + @foreach (var notification in _notifications) + { +
+
+ @switch (notification.Intent) + { + case NotificationIntent.Success: + + break; + case NotificationIntent.Error: + + break; + case NotificationIntent.Progress: + + break; + default: + + break; + } +
+
+
@notification.Title
+ @if (!string.IsNullOrEmpty(notification.Message)) + { +
@notification.Message
+ } +
+ @FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, notification.Timestamp.ToLocalTime()) +
+
+
+ } + } +
+
diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs new file mode 100644 index 00000000000..f13ce32095b --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs @@ -0,0 +1,48 @@ +// 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; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class NotificationsDialog : IDialogContentComponent, IDisposable +{ + private IReadOnlyList _notifications = []; + + [Inject] + public required INotificationService NotificationService { get; init; } + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [CascadingParameter] + public FluentDialog Dialog { get; set; } = default!; + + protected override void OnInitialized() + { + NotificationService.MarkAllAsRead(); + _notifications = NotificationService.GetNotifications(); + NotificationService.OnChange += HandleNotificationsChanged; + } + + private void HandleNotificationsChanged() + { + InvokeAsync(() => + { + _notifications = NotificationService.GetNotifications(); + StateHasChanged(); + }); + } + + private void DismissAll() + { + NotificationService.ClearAll(); + } + + public void Dispose() + { + NotificationService.OnChange -= HandleNotificationsChanged; + } +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css new file mode 100644 index 00000000000..68469c2d7fa --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css @@ -0,0 +1,49 @@ +.notification-center-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 16px; + color: var(--neutral-foreground-hint); + gap: 8px; +} + +.notification-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--neutral-stroke-divider-rest); +} + +.notification-item:last-child { + border-bottom: none; +} + +.notification-item-icon { + flex-shrink: 0; + padding-top: 2px; +} + +.notification-item-content { + flex: 1; + min-width: 0; +} + +.notification-item-title { + font-size: var(--type-ramp-base-font-size); + font-weight: 600; + line-height: var(--type-ramp-base-line-height); +} + +.notification-item-message { + font-size: var(--type-ramp-minus-1-font-size); + color: var(--neutral-foreground-hint); + margin-top: 4px; + word-break: break-word; +} + +.notification-item-timestamp { + font-size: var(--type-ramp-minus-1-font-size); + color: var(--neutral-foreground-hint); + margin-top: 4px; +} diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index d8b881c611e..e54115cfdb9 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -41,6 +41,7 @@ } + @@ -79,6 +80,7 @@ CloseNavMenu="@CloseMobileNavMenu" LaunchHelpAsync="@LaunchHelpAsync" LaunchAIAssistantAsync="@LaunchAssistantAsync" + LaunchNotificationsAsync="@LaunchNotificationsAsync" LaunchSettingsAsync="@LaunchSettingsAsync" /> } diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index fed6d140e8e..794be05e35a 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -30,6 +30,7 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable private IDisposable? _aiDisplayChangedSubscription; private const string SettingsDialogId = "SettingsDialog"; private const string HelpDialogId = "HelpDialog"; + private const string NotificationCenterDialogId = "NotificationCenterDialog"; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -285,6 +286,42 @@ public async Task LaunchSettingsAsync() } } + public async Task LaunchNotificationsAsync() + { + var parameters = new DialogParameters + { + Title = Loc[nameof(Resources.Layout.MainLayoutNotificationCenterTitle)], + PrimaryAction = Loc[nameof(Resources.Layout.MainLayoutSettingsDialogClose)].Value, + SecondaryAction = null, + TrapFocus = true, + Modal = true, + Alignment = HorizontalAlignment.Right, + Width = "350px", + Height = "auto", + Id = NotificationCenterDialogId, + OnDialogClosing = EventCallback.Factory.Create(this, HandleDialogClose) + }; + + if (_openPageDialog is not null) + { + if (Equals(_openPageDialog.Id, NotificationCenterDialogId) && !_openPageDialog.Result.IsCompleted) + { + return; + } + + await _openPageDialog.CloseAsync(); + } + + if (ViewportInformation.IsDesktop) + { + _openPageDialog = await DialogService.ShowPanelAsync(parameters).ConfigureAwait(true); + } + else + { + _openPageDialog = await DialogService.ShowDialogAsync(parameters).ConfigureAwait(true); + } + } + public async Task LaunchAssistantAsync() { if (AIContextProvider.AssistantChatViewModel != null && AIContextProvider.ShowAssistantSidebarDialog) diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor index 56a6927d05c..adf37bc5d8a 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor @@ -41,6 +41,9 @@ [Parameter, EditorRequired] public required Func LaunchAIAssistantAsync { get; set; } + [Parameter, EditorRequired] + public required Func LaunchNotificationsAsync { get; set; } + [Parameter, EditorRequired] public required Func LaunchSettingsAsync { get; set; } } diff --git a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs index 42ab3f27090..8809a238d59 100644 --- a/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs @@ -98,6 +98,12 @@ private IEnumerable GetMobileNavMenuEntries() ); } + yield return new MobileNavMenuEntry( + Loc[nameof(Resources.Layout.MainLayoutLaunchNotifications)], + LaunchNotificationsAsync, + new Icons.Regular.Size24.Alert() + ); + yield return new MobileNavMenuEntry( Loc[nameof(Resources.Layout.MainLayoutLaunchSettings)], LaunchSettingsAsync, diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor new file mode 100644 index 00000000000..acbb290bc8f --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor @@ -0,0 +1,28 @@ +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Resources + +@if (NotificationService.UnreadCount > 0) +{ + + + + + + + +} +else +{ + + + +} diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs new file mode 100644 index 00000000000..1c67678860d --- /dev/null +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs @@ -0,0 +1,35 @@ +// 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.Extensions.Localization; + +namespace Aspire.Dashboard.Components.Layout; + +public partial class NotificationsHeaderButton : ComponentBase, IDisposable +{ + [Parameter, EditorRequired] + public required Func OnClick { get; set; } + + [Inject] + public required INotificationService NotificationService { get; init; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + protected override void OnInitialized() + { + NotificationService.OnChange += HandleNotificationsChanged; + } + + private void HandleNotificationsChanged() + { + InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + NotificationService.OnChange -= HandleNotificationsChanged; + } +} diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index c2f0539fe85..dd6d15dc5ff 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -270,6 +270,8 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddScoped(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 3eaf9a60e04..8913ed6f381 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -18,7 +18,8 @@ public sealed class DashboardCommandExecutor( IToastService toastService, IStringLocalizer loc, NavigationManager navigationManager, - DashboardTelemetryService telemetryService) + DashboardTelemetryService telemetryService, + INotificationService notificationService) { private readonly HashSet<(string ResourceName, string CommandName)> _executingCommands = []; private readonly object _lock = new object(); @@ -97,6 +98,16 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel var messageResourceName = getResourceName(resource); + // Add a progress notification to the notification center. + var notification = new NotificationItem + { + Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], messageResourceName, command.GetDisplayName()), + Intent = NotificationIntent.Progress, + ResourceName = resource.Name, + CommandName = command.Name + }; + notificationService.AddNotification(notification); + // When a resource command starts a toast is immediately shown. // The toast is open for a certain amount of time and then automatically closed. // When the resource command is finished the status is displayed in a toast. @@ -143,20 +154,27 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastService.OnClose -= closeCallback; } - // Update toast with the result; + // Update toast and notification with the result. if (response.Kind == ResourceCommandResponseKind.Succeeded) { toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], messageResourceName, command.GetDisplayName()); toastParameters.Intent = ToastIntent.Success; toastParameters.Icon = GetIntentIcon(ToastIntent.Success); + + notification.Title = toastParameters.Title; + notification.Intent = NotificationIntent.Success; + notification.Timestamp = DateTime.UtcNow; + notificationService.UpdateNotification(notification.Id); } else if (response.Kind == ResourceCommandResponseKind.Cancelled) { - // For cancelled commands, just close the existing toast and don't show any success or error message + // For cancelled commands, just close the existing toast and don't show any success or error message. if (!toastClosed) { toastService.CloseToast(toastParameters.Id); } + + notificationService.RemoveNotification(notification.Id); return; } else @@ -167,6 +185,12 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.Content.Details = response.ErrorMessage; toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); + + notification.Title = toastParameters.Title; + notification.Message = response.ErrorMessage; + notification.Intent = NotificationIntent.Error; + notification.Timestamp = DateTime.UtcNow; + notificationService.UpdateNotification(notification.Id); } if (response.Result is not null) diff --git a/src/Aspire.Dashboard/Model/INotificationService.cs b/src/Aspire.Dashboard/Model/INotificationService.cs new file mode 100644 index 00000000000..22422069550 --- /dev/null +++ b/src/Aspire.Dashboard/Model/INotificationService.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +/// +/// Service for managing dashboard notifications. Implementations must be thread-safe +/// since the service is registered as a singleton and accessed from multiple Blazor circuits. +/// +public interface INotificationService +{ + /// + /// Gets all notifications, newest first. + /// + IReadOnlyList GetNotifications(); + + /// + /// Gets the count of unread notifications (added since the panel was last opened). + /// + int UnreadCount { get; } + + /// + /// Adds a new notification and raises . + /// + void AddNotification(NotificationItem item); + + /// + /// Signals that an existing notification was mutated (e.g., Progress → Success) and raises . + /// + void UpdateNotification(string id); + + /// + /// Removes a specific notification (e.g., for cancelled commands) and raises . + /// + void RemoveNotification(string id); + + /// + /// Marks all current notifications as read, resetting to zero. + /// + void MarkAllAsRead(); + + /// + /// Clears all notifications and raises . + /// + void ClearAll(); + + /// + /// Raised when notifications are added, updated, removed, or cleared. + /// + event Action? OnChange; +} diff --git a/src/Aspire.Dashboard/Model/NotificationItem.cs b/src/Aspire.Dashboard/Model/NotificationItem.cs new file mode 100644 index 00000000000..2b1ee65550a --- /dev/null +++ b/src/Aspire.Dashboard/Model/NotificationItem.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +/// +/// Represents the intent/severity of a notification. +/// +public enum NotificationIntent +{ + Progress, + Success, + Error, + Info +} + +/// +/// A mutable notification item that can transition from one state to another +/// (e.g., Progress to Success/Error when a command completes). +/// +public sealed class NotificationItem +{ + public string Id { get; } = Guid.NewGuid().ToString(); + + public string Title { get; set; } = string.Empty; + + public string? Message { get; set; } + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + public NotificationIntent Intent { get; set; } + + public string? ResourceName { get; set; } + + public string? CommandName { get; set; } +} diff --git a/src/Aspire.Dashboard/Model/NotificationService.cs b/src/Aspire.Dashboard/Model/NotificationService.cs new file mode 100644 index 00000000000..caf8ec62f18 --- /dev/null +++ b/src/Aspire.Dashboard/Model/NotificationService.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +/// +/// Thread-safe singleton implementation of . +/// Notifications persist across browser refreshes since this is registered as a singleton. +/// +public sealed class NotificationService : INotificationService +{ + private readonly List _notifications = []; + private readonly object _lock = new(); + private int _unreadCount; + + public int UnreadCount + { + get + { + lock (_lock) + { + return _unreadCount; + } + } + } + + public event Action? OnChange; + + public IReadOnlyList GetNotifications() + { + lock (_lock) + { + // Return a snapshot in reverse order (newest first). + var snapshot = new List(_notifications.Count); + for (var i = _notifications.Count - 1; i >= 0; i--) + { + snapshot.Add(_notifications[i]); + } + return snapshot; + } + } + + public void AddNotification(NotificationItem item) + { + lock (_lock) + { + _notifications.Add(item); + _unreadCount++; + } + + OnChange?.Invoke(); + } + + public void UpdateNotification(string id) + { + // The caller already mutated the item in-place. We just verify it exists and notify. + bool found; + lock (_lock) + { + found = _notifications.Exists(n => n.Id == id); + } + + if (found) + { + OnChange?.Invoke(); + } + } + + public void RemoveNotification(string id) + { + bool removed; + lock (_lock) + { + removed = _notifications.RemoveAll(n => n.Id == id) > 0; + if (removed && _unreadCount > 0) + { + _unreadCount--; + } + } + + if (removed) + { + OnChange?.Invoke(); + } + } + + public void MarkAllAsRead() + { + lock (_lock) + { + _unreadCount = 0; + } + + OnChange?.Invoke(); + } + + public void ClearAll() + { + lock (_lock) + { + _notifications.Clear(); + _unreadCount = 0; + } + + OnChange?.Invoke(); + } +} diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index 3ad4c1d03a7..d1f70772c96 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1157,5 +1157,23 @@ public static string SettingsDialogTimeFormatTwentyFourHour { return ResourceManager.GetString("SettingsDialogTimeFormatTwentyFourHour", resourceCulture); } } + + /// + /// Looks up a localized string similar to Dismiss all. + /// + public static string NotificationCenterDismissAll { + get { + return ResourceManager.GetString("NotificationCenterDismissAll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No notifications. + /// + public static string NotificationCenterEmpty { + get { + return ResourceManager.GetString("NotificationCenterEmpty", resourceCulture); + } + } } } diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 0bde462b9ba..a40b3fb6856 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -492,4 +492,10 @@ 24-hour + + Dismiss all + + + No notifications + diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 5cf06ad088d..bf5bb9d75c8 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -96,6 +96,24 @@ public static string MainLayoutLaunchSettings { } } + /// + /// Looks up a localized string similar to Notifications. + /// + public static string MainLayoutLaunchNotifications { + get { + return ResourceManager.GetString("MainLayoutLaunchNotifications", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Notifications. + /// + public static string MainLayoutNotificationCenterTitle { + get { + return ResourceManager.GetString("MainLayoutNotificationCenterTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 265a0431459..813b1b006b7 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -138,6 +138,12 @@ Close + + Notifications + + + Notifications + Resources diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 4abedf4c8cd..ce144174389 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -457,6 +457,16 @@ Trasování + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Otevřít ve vizualizéru textu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 9edb168258e..364a974a73f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -457,6 +457,16 @@ Ablaufverfolgungen + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer In Textschnellansicht öffnen diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 67c286a55ee..511844ee29f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -457,6 +457,16 @@ Seguimientos + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Abrir en visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index b4d45da519a..5c3d2124f2b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -457,6 +457,16 @@ Traces + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Ouvrir dans le visualiseur de texte diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index bbd12577f8c..2cbbee0bfa1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -457,6 +457,16 @@ Tracce + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Apri nel visualizzatore di testo diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index c98700bc8c5..5171b6e9d7b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -457,6 +457,16 @@ トレース + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer テキスト ビジュアライザーで開く diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index 4005607a1d0..fc0b9e6fefe 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -457,6 +457,16 @@ 추적 + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer 텍스트 시각화 도우미에서 열기 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 51968696c63..e6bd47f785b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -457,6 +457,16 @@ Ślady + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Otwórz w wizualizatorze tekstu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index efcdf07c74b..aa1f3d99f87 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -457,6 +457,16 @@ Rastreamentos + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Abrir no visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index 815849031c2..fc433589ea3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -457,6 +457,16 @@ Трассировки + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Открыть в визуализаторе текста diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 317a66232de..8ad8b9c7cf1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -457,6 +457,16 @@ İzlemeler + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer Metin görselleştiricide aç diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index 192ce5dddd8..2e028ecd46f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -457,6 +457,16 @@ 跟踪 + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer 在文本可视化工具中打开 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 3f53eb074ae..5a56ffbff24 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -457,6 +457,16 @@ 追蹤 + + Dismiss all + Dismiss all + + + + No notifications + No notifications + + Open in text visualizer 在文字視覺化工具中開啟 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index e8b30000aaa..61b7c938052 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -17,11 +17,21 @@ Úložiště Aspire + + Notifications + Notifications + + Settings Nastavení + + Notifications + Notifications + + Close Zavřít diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index dcf7ebd332b..6233aef3419 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -17,11 +17,21 @@ Aspire-Repository + + Notifications + Notifications + + Settings Einstellungen + + Notifications + Notifications + + Close Schließen diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index a7b4dc66cdf..fe567fc07c3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -17,11 +17,21 @@ Repositorio de Aspire + + Notifications + Notifications + + Settings Configuración + + Notifications + Notifications + + Close Cerrar diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 14ce1089dac..e4398dd3a1e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -17,11 +17,21 @@ Référentiel Aspire + + Notifications + Notifications + + Settings Paramètres + + Notifications + Notifications + + Close Fermer diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 92797b80416..81ca6eb72d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -17,11 +17,21 @@ Repository Aspire + + Notifications + Notifications + + Settings Impostazioni + + Notifications + Notifications + + Close Chiudi diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 690f394c922..154b7c4bc19 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -17,11 +17,21 @@ Aspire リポジトリ + + Notifications + Notifications + + Settings 設定 + + Notifications + Notifications + + Close 閉じる diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index d4a3445aea8..4bf337c211d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -17,11 +17,21 @@ Aspire 리포지토리 + + Notifications + Notifications + + Settings 설정 + + Notifications + Notifications + + Close 닫기 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 4e55919bd9b..cd628d961ac 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -17,11 +17,21 @@ Repozytorium Aspire + + Notifications + Notifications + + Settings Ustawienia + + Notifications + Notifications + + Close Zamknij diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index e9b9a33a638..ee3f0ec8426 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -17,11 +17,21 @@ Repositório do Aspire + + Notifications + Notifications + + Settings Configurações + + Notifications + Notifications + + Close Fechar diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index f16dde28c02..23b72d896e0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -17,11 +17,21 @@ Репозиторий Aspire + + Notifications + Notifications + + Settings Параметры + + Notifications + Notifications + + Close Закрыть diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 6b9931a8c37..81862c837ff 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -17,11 +17,21 @@ Aspire deposu + + Notifications + Notifications + + Settings Ayarlar + + Notifications + Notifications + + Close Kapat diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 5cb24e62d08..5fe074192c2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -17,11 +17,21 @@ Aspire 存储库 + + Notifications + Notifications + + Settings 设置 + + Notifications + Notifications + + Close 关闭 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 0228089de12..07e9e07822b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -17,11 +17,21 @@ Aspire 存放庫 + + Notifications + Notifications + + Settings 設定 + + Notifications + Notifications + + Close 關閉 diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 54944181923..9e941fb0e95 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -148,6 +148,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(themeManager ?? new ThemeManager(new TestThemeResolver())); context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddScoped(); context.Services.AddScoped(); context.Services.AddScoped(); From d3c9fd41fb6c61b7eeddeebb696971565d2f6628 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 16:04:50 +0800 Subject: [PATCH 02/16] Add message to response --- .../Commands/ResourceCommandHelper.cs | 8 +- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 4 +- .../Dialogs/NotificationsDialog.razor | 58 ++------- .../Dialogs/NotificationsDialog.razor.cs | 19 +-- .../Dialogs/NotificationsDialog.razor.css | 52 ++++---- .../Layout/NotificationsHeaderButton.razor | 16 +-- .../Layout/NotificationsHeaderButton.razor.cs | 8 ++ .../DashboardWebApplication.cs | 1 - .../Model/DashboardCommandExecutor.cs | 123 +++++++++++++----- .../Model/INotificationService.cs | 37 ++---- .../Model/NotificationItem.cs | 36 ----- .../Model/NotificationService.cs | 67 +--------- .../Model/ResourceCommandResponseViewModel.cs | 1 + .../Resources/Resources.Designer.cs | 15 ++- src/Aspire.Dashboard/Resources/Resources.resx | 15 ++- .../Resources/xlf/Resources.cs.xlf | 23 ++-- .../Resources/xlf/Resources.de.xlf | 23 ++-- .../Resources/xlf/Resources.es.xlf | 23 ++-- .../Resources/xlf/Resources.fr.xlf | 23 ++-- .../Resources/xlf/Resources.it.xlf | 23 ++-- .../Resources/xlf/Resources.ja.xlf | 23 ++-- .../Resources/xlf/Resources.ko.xlf | 23 ++-- .../Resources/xlf/Resources.pl.xlf | 23 ++-- .../Resources/xlf/Resources.pt-BR.xlf | 23 ++-- .../Resources/xlf/Resources.ru.xlf | 23 ++-- .../Resources/xlf/Resources.tr.xlf | 23 ++-- .../Resources/xlf/Resources.zh-Hans.xlf | 23 ++-- .../Resources/xlf/Resources.zh-Hant.xlf | 23 ++-- .../ServiceClient/DashboardClient.cs | 3 +- .../ServiceClient/Partials.cs | 8 +- .../Utils/DashboardUIHelpers.cs | 1 + .../CommandsConfigurationExtensions.cs | 16 +-- .../ResourceCommandAnnotation.cs | 15 ++- .../ResourceCommandService.cs | 12 +- .../AuxiliaryBackchannelRpcTarget.cs | 5 +- .../Backchannel/BackchannelDataTypes.cs | 6 + .../Dashboard/DashboardService.cs | 7 +- .../Dashboard/DashboardServiceData.cs | 8 +- .../Dashboard/proto/dashboard_service.proto | 3 +- .../Orchestrator/ParameterProcessor.cs | 4 +- .../ResourceBuilderExtensions.cs | 3 +- .../Resources/CommandStrings.Designer.cs | 54 ++++++++ .../Resources/CommandStrings.resx | 24 ++++ .../Resources/xlf/CommandStrings.cs.xlf | 32 ++++- .../Resources/xlf/CommandStrings.de.xlf | 32 ++++- .../Resources/xlf/CommandStrings.es.xlf | 32 ++++- .../Resources/xlf/CommandStrings.fr.xlf | 32 ++++- .../Resources/xlf/CommandStrings.it.xlf | 32 ++++- .../Resources/xlf/CommandStrings.ja.xlf | 32 ++++- .../Resources/xlf/CommandStrings.ko.xlf | 32 ++++- .../Resources/xlf/CommandStrings.pl.xlf | 32 ++++- .../Resources/xlf/CommandStrings.pt-BR.xlf | 32 ++++- .../Resources/xlf/CommandStrings.ru.xlf | 32 ++++- .../Resources/xlf/CommandStrings.tr.xlf | 32 ++++- .../Resources/xlf/CommandStrings.zh-Hans.xlf | 32 ++++- .../Resources/xlf/CommandStrings.zh-Hant.xlf | 32 ++++- .../ResourceCommandServiceTests.cs | 28 ++-- .../WithHttpCommandTests.cs | 2 +- 58 files changed, 935 insertions(+), 439 deletions(-) delete mode 100644 src/Aspire.Dashboard/Model/NotificationItem.cs diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs index 67e6865dd32..0588d6c69f4 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -77,7 +77,9 @@ public static async Task 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}"); } @@ -108,7 +110,9 @@ 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}"); } diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index 916c1d95dd1..bdbd9d49a4d 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -95,7 +95,9 @@ public override async ValueTask 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 { diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor index 98e333b5c45..3291e9341ae 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor @@ -1,20 +1,10 @@ -@using Aspire.Dashboard.Model -@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Utils @inject IStringLocalizer Loc -
- - - @if (_notifications.Count > 0) - { - - @Loc[nameof(Dialogs.NotificationCenterDismissAll)] - - } - -
- @if (_notifications.Count == 0) +
+ @if (!_hasNotifications) {
@@ -23,38 +13,14 @@ } else { - @foreach (var notification in _notifications) - { -
-
- @switch (notification.Intent) - { - case NotificationIntent.Success: - - break; - case NotificationIntent.Error: - - break; - case NotificationIntent.Progress: - - break; - default: - - break; - } -
-
-
@notification.Title
- @if (!string.IsNullOrEmpty(notification.Message)) - { -
@notification.Message
- } -
- @FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, notification.Timestamp.ToLocalTime()) -
-
-
- } +
+ + @Loc[nameof(Dialogs.NotificationCenterDismissAll)] + +
+
+ +
}
diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs index f13ce32095b..42f74c4442c 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; @@ -9,40 +10,40 @@ namespace Aspire.Dashboard.Components.Dialogs; public partial class NotificationsDialog : IDialogContentComponent, IDisposable { - private IReadOnlyList _notifications = []; + private bool _hasNotifications; [Inject] - public required INotificationService NotificationService { get; init; } + public required IMessageService MessageService { get; init; } [Inject] - public required BrowserTimeProvider TimeProvider { get; init; } + public required INotificationService NotificationService { get; init; } [CascadingParameter] public FluentDialog Dialog { get; set; } = default!; protected override void OnInitialized() { - NotificationService.MarkAllAsRead(); - _notifications = NotificationService.GetNotifications(); - NotificationService.OnChange += HandleNotificationsChanged; + _hasNotifications = MessageService.Count(DashboardUIHelpers.NotificationSection) > 0; + MessageService.OnMessageItemsUpdated += HandleNotificationsChanged; + NotificationService.ResetUnreadCount(); } private void HandleNotificationsChanged() { InvokeAsync(() => { - _notifications = NotificationService.GetNotifications(); + _hasNotifications = MessageService.Count(DashboardUIHelpers.NotificationSection) > 0; StateHasChanged(); }); } private void DismissAll() { - NotificationService.ClearAll(); + MessageService.Clear(DashboardUIHelpers.NotificationSection); } public void Dispose() { - NotificationService.OnChange -= HandleNotificationsChanged; + MessageService.OnMessageItemsUpdated -= HandleNotificationsChanged; } } diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css index 68469c2d7fa..430adca92d2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css @@ -1,3 +1,10 @@ +.notifications-container { + display: flex; + flex-direction: column; + height: 100%; + gap: 16px; +} + .notification-center-empty { display: flex; flex-direction: column; @@ -8,42 +15,33 @@ gap: 8px; } -.notification-item { +.notifications-dismiss { display: flex; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--neutral-stroke-divider-rest); + justify-content: flex-end; } -.notification-item:last-child { - border-bottom: none; +.notifications-scroll { + overflow-y: auto; + flex: 1; + min-height: 0; } -.notification-item-icon { - flex-shrink: 0; - padding-top: 2px; +.notifications-container ::deep .fluent-messagebar-notification { + grid-template-rows: unset; } -.notification-item-content { - flex: 1; - min-width: 0; +.notifications-container ::deep .fluent-messagebar-notification-message { + white-space: unset; } -.notification-item-title { - font-size: var(--type-ramp-base-font-size); - font-weight: 600; - line-height: var(--type-ramp-base-line-height); -} - -.notification-item-message { - font-size: var(--type-ramp-minus-1-font-size); - color: var(--neutral-foreground-hint); - margin-top: 4px; - word-break: break-word; +.notifications-container ::deep .fluent-messagebar-notification-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + font-size: 12px; } -.notification-item-timestamp { - font-size: var(--type-ramp-minus-1-font-size); - color: var(--neutral-foreground-hint); - margin-top: 4px; +.notifications-container ::deep .fluent-messagebar-notification-time { + color: var(--foreground-subtext-rest); } diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor index acbb290bc8f..e04bb5d1296 100644 --- a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor @@ -1,19 +1,19 @@ -@using Aspire.Dashboard.Model -@using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Resources -@if (NotificationService.UnreadCount > 0) +@if (UnreadCount > 0) { - + Appearance="Appearance.Accent" + Style="border: 0;"> - + @@ -21,7 +21,7 @@ else { diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs index 1c67678860d..20e0aac65b4 100644 --- a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs @@ -23,6 +23,14 @@ protected override void OnInitialized() NotificationService.OnChange += HandleNotificationsChanged; } + private int UnreadCount => NotificationService.UnreadCount; + + private async Task HandleClick() + { + NotificationService.ResetUnreadCount(); + await OnClick(); + } + private void HandleNotificationsChanged() { InvokeAsync(StateHasChanged); diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index dd6d15dc5ff..b43cea87c3d 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -271,7 +271,6 @@ public DashboardWebApplication( builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.TryAddScoped(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 8913ed6f381..aa67e19f539 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -16,6 +16,7 @@ public sealed class DashboardCommandExecutor( IDashboardClient dashboardClient, DashboardDialogService dialogService, IToastService toastService, + IMessageService messageService, IStringLocalizer loc, NavigationManager navigationManager, DashboardTelemetryService telemetryService, @@ -96,17 +97,20 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel } } - var messageResourceName = getResourceName(resource); + var messageBarStartingTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], command.GetDisplayName()); + var toastStartingTitle = $"{getResourceName(resource)} {messageBarStartingTitle}"; - // Add a progress notification to the notification center. - var notification = new NotificationItem + // Add a message bar to the notification center section for rendering via FluentMessageBarProvider. + var progressMessage = await messageService.ShowMessageBarAsync(options => { - Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], messageResourceName, command.GetDisplayName()), - Intent = NotificationIntent.Progress, - ResourceName = resource.Name, - CommandName = command.Name - }; - notificationService.AddNotification(notification); + options.Title = messageBarStartingTitle; + options.Intent = MessageIntent.Info; + options.Section = DashboardUIHelpers.NotificationSection; + options.AllowDismiss = true; + options.Timestamp = DateTime.Now; + }).ConfigureAwait(false); + + notificationService.IncrementUnreadCount(); // When a resource command starts a toast is immediately shown. // The toast is open for a certain amount of time and then automatically closed. @@ -117,7 +121,7 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel { Id = Guid.NewGuid().ToString(), Intent = ToastIntent.Progress, - Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], messageResourceName, command.GetDisplayName()), + Title = toastStartingTitle, Content = new CommunicationToastContent(), Timeout = 0 // App logic will handle closing the toast }; @@ -157,14 +161,32 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel // Update toast and notification with the result. if (response.Kind == ResourceCommandResponseKind.Succeeded) { - toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], messageResourceName, command.GetDisplayName()); + var successTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandSuccess)], command.GetDisplayName()); + toastParameters.Title = $"{getResourceName(resource)} {successTitle}"; toastParameters.Intent = ToastIntent.Success; toastParameters.Icon = GetIntentIcon(ToastIntent.Success); - notification.Title = toastParameters.Title; - notification.Intent = NotificationIntent.Success; - notification.Timestamp = DateTime.UtcNow; - notificationService.UpdateNotification(notification.Id); + if (response.Result is not null) + { + toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; + toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); + } + + progressMessage.Close(); + await messageService.ShowMessageBarAsync(options => + { + options.Title = successTitle; + options.Body = response.Message; + options.Intent = MessageIntent.Success; + options.Section = DashboardUIHelpers.NotificationSection; + options.AllowDismiss = true; + options.Timestamp = DateTime.Now; + + if (response.Result is not null) + { + options.PrimaryAction = CreateViewResponseAction(command, response); + } + }).ConfigureAwait(false); } else if (response.Kind == ResourceCommandResponseKind.Cancelled) { @@ -174,34 +196,38 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastService.CloseToast(toastParameters.Id); } - notificationService.RemoveNotification(notification.Id); + progressMessage.Close(); return; } else { - toastParameters.Title = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], messageResourceName, command.GetDisplayName()); + var failedTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandFailed)], command.GetDisplayName()); + toastParameters.Title = $"{getResourceName(resource)} {failedTitle}"; toastParameters.Intent = ToastIntent.Error; toastParameters.Icon = GetIntentIcon(ToastIntent.Error); - toastParameters.Content.Details = response.ErrorMessage; toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); - notification.Title = toastParameters.Title; - notification.Message = response.ErrorMessage; - notification.Intent = NotificationIntent.Error; - notification.Timestamp = DateTime.UtcNow; - notificationService.UpdateNotification(notification.Id); - } + if (response.Result is not null) + { + toastParameters.SecondaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; + toastParameters.OnSecondaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); + } - if (response.Result is not null) - { - var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; - await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions + progressMessage.Close(); + await messageService.ShowMessageBarAsync(options => { - DialogService = dialogService, - ValueDescription = command.GetDisplayName(), - Value = response.Result, - FixedFormat = fixedFormat + options.Title = failedTitle; + options.Body = response.Message; + options.Intent = MessageIntent.Error; + options.Section = DashboardUIHelpers.NotificationSection; + options.AllowDismiss = true; + options.Timestamp = DateTime.Now; + + if (response.Result is not null) + { + options.PrimaryAction = CreateViewResponseAction(command, response); + } }).ConfigureAwait(false); } @@ -240,4 +266,37 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) _ => throw new InvalidOperationException() }; } + + private ActionButton CreateViewResponseAction(CommandViewModel command, ResourceCommandResponseViewModel response) + { + var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + + return new ActionButton + { + Text = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)], + OnClick = async _ => + { + await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions + { + DialogService = dialogService, + ValueDescription = command.GetDisplayName(), + Value = response.Result!, + FixedFormat = fixedFormat + }).ConfigureAwait(false); + } + }; + } + + private async Task OpenViewResponseDialogAsync(CommandViewModel command, ResourceCommandResponseViewModel response) + { + var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + + await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions + { + DialogService = dialogService, + ValueDescription = command.GetDisplayName(), + Value = response.Result!, + FixedFormat = fixedFormat + }).ConfigureAwait(false); + } } diff --git a/src/Aspire.Dashboard/Model/INotificationService.cs b/src/Aspire.Dashboard/Model/INotificationService.cs index 22422069550..ff7a83db547 100644 --- a/src/Aspire.Dashboard/Model/INotificationService.cs +++ b/src/Aspire.Dashboard/Model/INotificationService.cs @@ -4,48 +4,29 @@ namespace Aspire.Dashboard.Model; /// -/// Service for managing dashboard notifications. Implementations must be thread-safe -/// since the service is registered as a singleton and accessed from multiple Blazor circuits. +/// Tracks the unread notification count for the dashboard notification center. +/// Implementations must be thread-safe as the service is registered as a singleton +/// and accessed from multiple Blazor circuits. /// public interface INotificationService { /// - /// Gets all notifications, newest first. - /// - IReadOnlyList GetNotifications(); - - /// - /// Gets the count of unread notifications (added since the panel was last opened). + /// Gets the number of notifications added since the dialog was last opened. /// int UnreadCount { get; } /// - /// Adds a new notification and raises . - /// - void AddNotification(NotificationItem item); - - /// - /// Signals that an existing notification was mutated (e.g., Progress → Success) and raises . - /// - void UpdateNotification(string id); - - /// - /// Removes a specific notification (e.g., for cancelled commands) and raises . - /// - void RemoveNotification(string id); - - /// - /// Marks all current notifications as read, resetting to zero. + /// Increments the unread count by one and raises . /// - void MarkAllAsRead(); + void IncrementUnreadCount(); /// - /// Clears all notifications and raises . + /// Resets the unread count to zero and raises . /// - void ClearAll(); + void ResetUnreadCount(); /// - /// Raised when notifications are added, updated, removed, or cleared. + /// Raised when the unread count changes. /// event Action? OnChange; } diff --git a/src/Aspire.Dashboard/Model/NotificationItem.cs b/src/Aspire.Dashboard/Model/NotificationItem.cs deleted file mode 100644 index 2b1ee65550a..00000000000 --- a/src/Aspire.Dashboard/Model/NotificationItem.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Model; - -/// -/// Represents the intent/severity of a notification. -/// -public enum NotificationIntent -{ - Progress, - Success, - Error, - Info -} - -/// -/// A mutable notification item that can transition from one state to another -/// (e.g., Progress to Success/Error when a command completes). -/// -public sealed class NotificationItem -{ - public string Id { get; } = Guid.NewGuid().ToString(); - - public string Title { get; set; } = string.Empty; - - public string? Message { get; set; } - - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - - public NotificationIntent Intent { get; set; } - - public string? ResourceName { get; set; } - - public string? CommandName { get; set; } -} diff --git a/src/Aspire.Dashboard/Model/NotificationService.cs b/src/Aspire.Dashboard/Model/NotificationService.cs index caf8ec62f18..eb8e0644bfa 100644 --- a/src/Aspire.Dashboard/Model/NotificationService.cs +++ b/src/Aspire.Dashboard/Model/NotificationService.cs @@ -5,11 +5,9 @@ namespace Aspire.Dashboard.Model; /// /// Thread-safe singleton implementation of . -/// Notifications persist across browser refreshes since this is registered as a singleton. /// -public sealed class NotificationService : INotificationService +internal sealed class NotificationService : INotificationService { - private readonly List _notifications = []; private readonly object _lock = new(); private int _unreadCount; @@ -26,79 +24,20 @@ public int UnreadCount public event Action? OnChange; - public IReadOnlyList GetNotifications() + public void IncrementUnreadCount() { lock (_lock) { - // Return a snapshot in reverse order (newest first). - var snapshot = new List(_notifications.Count); - for (var i = _notifications.Count - 1; i >= 0; i--) - { - snapshot.Add(_notifications[i]); - } - return snapshot; - } - } - - public void AddNotification(NotificationItem item) - { - lock (_lock) - { - _notifications.Add(item); _unreadCount++; } OnChange?.Invoke(); } - public void UpdateNotification(string id) - { - // The caller already mutated the item in-place. We just verify it exists and notify. - bool found; - lock (_lock) - { - found = _notifications.Exists(n => n.Id == id); - } - - if (found) - { - OnChange?.Invoke(); - } - } - - public void RemoveNotification(string id) - { - bool removed; - lock (_lock) - { - removed = _notifications.RemoveAll(n => n.Id == id) > 0; - if (removed && _unreadCount > 0) - { - _unreadCount--; - } - } - - if (removed) - { - OnChange?.Invoke(); - } - } - - public void MarkAllAsRead() - { - lock (_lock) - { - _unreadCount = 0; - } - - OnChange?.Invoke(); - } - - public void ClearAll() + public void ResetUnreadCount() { lock (_lock) { - _notifications.Clear(); _unreadCount = 0; } diff --git a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs index c13f6777a64..c211fa0debf 100644 --- a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs @@ -7,6 +7,7 @@ public class ResourceCommandResponseViewModel { public required ResourceCommandResponseKind Kind { get; init; } public string? ErrorMessage { get; init; } + public string? Message { get; init; } public string? Result { get; init; } public CommandResultFormat? ResultFormat { get; init; } } diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index 8bc44068006..8ef9fa6c1c3 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -124,7 +124,7 @@ public static string ResourceCollapseAllChildren { } /// - /// Looks up a localized string similar to {0} "{1}" failed. + /// Looks up a localized string similar to "{0}" failed. /// public static string ResourceCommandFailed { get { @@ -133,7 +133,7 @@ public static string ResourceCommandFailed { } /// - /// Looks up a localized string similar to {0} "{1}" starting. + /// Looks up a localized string similar to "{0}" starting. /// public static string ResourceCommandStarting { get { @@ -142,7 +142,7 @@ public static string ResourceCommandStarting { } /// - /// Looks up a localized string similar to {0} "{1}" succeeded. + /// Looks up a localized string similar to "{0}" succeeded. /// public static string ResourceCommandSuccess { get { @@ -159,6 +159,15 @@ public static string ResourceCommandToastViewLogs { } } + /// + /// Looks up a localized string similar to View response. + /// + public static string ResourceCommandViewResponse { + get { + return ResourceManager.GetString("ResourceCommandViewResponse", resourceCulture); + } + } + /// /// Looks up a localized string similar to View console logs. /// diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index 3825dd1a9c2..723933e4d49 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -209,16 +209,19 @@ State - {0} "{1}" failed - {0} is the resource. {1} is the display name of the command. + "{0}" failed + {0} is the display name of the command. - {0} "{1}" succeeded - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + {0} is the display name of the command. View console logs + + View response + Actions @@ -238,8 +241,8 @@ Stop time - {0} "{1}" starting - {0} is the resource. {1} is the display name of the command. + "{0}" starting + {0} is the display name of the command. Traces diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index 2564b659d52..2e5c37eab2c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - Selhalo: {0} ({1}) - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - Spouští se {0} ({1}) - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - Úspěšné: {0} ({1}) - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Zobrazit protokoly konzoly + + View response + View response + + View console logs Zobrazit protokoly konzoly diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index 752e0d1a0e9..82a3cb1c3f1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" fehlgeschlagen - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" wird gestartet - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" erfolgreich - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Konsolenprotokolle anzeigen + + View response + View response + + View console logs Konsolenprotokolle anzeigen diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index c0a78581ad1..623481c7dd6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" erróneo - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" iniciando - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" correcto - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Ver registros de consola + + View response + View response + + View console logs Ver registros de consola diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index 805a1ae46ec..c70490f81c6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} « {1} » a échoué - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - Démarrage de {0} « {1} » - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} « {1} » a réussi - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Afficher les journaux de console + + View response + View response + + View console logs Afficher les journaux de console diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index 6b1f7666d6c..8abba77b611 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" non riuscito - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - Avvio di{0} "{1}" in corso - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" completato - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Visualizza log della console + + View response + View response + + View console logs Visualizza log della console diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index 3456b81da1f..b89af93c589 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" が失敗しました - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" を開始しています - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" が成功しました - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs コンソール ログの表示 + + View response + View response + + View console logs コンソール ログの表示 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index f28c867a848..d13be6da0e6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" 실패함 - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" 시작 중 - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" 성공함 - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs 콘솔 로그 보기 + + View response + View response + + View console logs 콘솔 로그 보기 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index d91aa66f20a..e380d066687 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - Niepowodzenie {0} „{1}” - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - Uruchamianie {0} „{1}” - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - Powodzenie {0} „{1}” - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Wyświetl dzienniki konsoli + + View response + View response + + View console logs Wyświetl dzienniki konsoli diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index 77cf66f3c3e..01b3cb8502c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" falhou - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" iniciando - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" bem-sucedido - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Exibir registros do console + + View response + View response + + View console logs Exibir registros do console diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index 100eb0e9488..d73ffa63f05 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - Сбой {0} "{1}" - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - Запуск {0} "{1}" - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" успешно выполнено - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Просмотр журналов консоли + + View response + View response + + View console logs Просмотр журналов консоли diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 19ac2b6e988..3a196adc478 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" başarısız - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" başlatılıyor - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" başarılı - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs Konsol günlüklerini görüntüle + + View response + View response + + View console logs Konsol günlüklerini görüntüle diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index d4fdd83c936..6d574d8340e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0} "{1}" 失败 - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0} "{1}" 正在启动 - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0} "{1}" 成功 - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs 查看控制台日志 + + View response + View response + + View console logs 查看控制台日志 diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index 70541f8f160..28273b63b64 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -53,25 +53,30 @@ - {0} "{1}" failed - {0}「{1}」失敗 - {0} is the resource. {1} is the display name of the command. + "{0}" failed + "{0}" failed + {0} is the display name of the command. - {0} "{1}" starting - {0}「{1}」正在啟動 - {0} is the resource. {1} is the display name of the command. + "{0}" starting + "{0}" starting + {0} is the display name of the command. - {0} "{1}" succeeded - {0}「{1}」成功 - {0} is the resource. {1} is the display name of the command. + "{0}" succeeded + "{0}" succeeded + {0} is the display name of the command. View console logs 檢視主控台記錄 + + View response + View response + + View console logs 檢視主機記錄 diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 7c045b0f48f..fcb05e46355 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -780,7 +780,8 @@ public async Task ExecuteResourceCommandAsync( return new ResourceCommandResponseViewModel() { Kind = ResourceCommandResponseKind.Failed, - ErrorMessage = errorMessage + ErrorMessage = errorMessage, + Message = errorMessage }; } } diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index 88c8418a0f0..a295d0e0bfe 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -196,9 +196,15 @@ partial class ResourceCommandResponse { public ResourceCommandResponseViewModel ToViewModel() { + // Map deprecated error_message to message for backward compatibility. +#pragma warning disable CS0612 // Type or member is obsolete + var resolvedMessage = HasMessage ? Message : ErrorMessage; +#pragma warning restore CS0612 // Type or member is obsolete + return new ResourceCommandResponseViewModel() { - ErrorMessage = ErrorMessage, + ErrorMessage = resolvedMessage, + Message = resolvedMessage, Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind, Result = HasResult ? Result : null, ResultFormat = ResultFormat switch diff --git a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs index bb3852962ce..c68e2d503a7 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs @@ -14,6 +14,7 @@ namespace Aspire.Dashboard.Utils; internal static class DashboardUIHelpers { public const string MessageBarSection = "MessagesTop"; + public const string NotificationSection = "MessagesNotifications"; // these are language names supported by highlight.js public const string XmlFormat = "xml"; diff --git a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs index bb27fd0b8f7..d2ec49098b2 100644 --- a/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/CommandsConfigurationExtensions.cs @@ -27,7 +27,7 @@ internal static void AddLifeCycleCommands(this IResource resource) var orchestrator = context.ServiceProvider.GetRequiredService(); await orchestrator.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceStarted, context.ResourceName) }; }, updateState: context => { @@ -60,7 +60,7 @@ internal static void AddLifeCycleCommands(this IResource resource) var orchestrator = context.ServiceProvider.GetRequiredService(); await orchestrator.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceStopped, context.ResourceName) }; }, updateState: context => { @@ -100,7 +100,7 @@ internal static void AddLifeCycleCommands(this IResource resource) await orchestrator.StopResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); await orchestrator.StartResourceAsync(context.ResourceName, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceRestarted, context.ResourceName) }; }, updateState: context => { @@ -189,7 +189,7 @@ private static async Task ExecuteRebuildAsync(ExecuteComma var rebuilderResource = model.Resources.OfType().FirstOrDefault(r => r.Parent == projectResource); if (rebuilderResource is null) { - return new ExecuteCommandResult { Success = false, ErrorMessage = string.Format(CultureInfo.InvariantCulture, CommandStrings.RebuilderResourceNotFound, projectResource.Name) }; + return new ExecuteCommandResult { Success = false, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.RebuilderResourceNotFound, projectResource.Name) }; } var mainLogger = loggerService.GetLogger(projectResource); @@ -261,7 +261,7 @@ await resourceNotificationService.PublishUpdateAsync(projectResource, s => s wit { State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) }).ConfigureAwait(false); - return new ExecuteCommandResult { Success = false, ErrorMessage = "Build timed out." }; + return new ExecuteCommandResult { Success = false, Message = "Build timed out." }; } if (exitCode == 0) @@ -316,7 +316,7 @@ await resourceNotificationService.PublishUpdateAsync(projectResource, name, s => } } - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceRebuilt, projectResource.Name) }; } else { @@ -325,7 +325,7 @@ await resourceNotificationService.PublishUpdateAsync(projectResource, s => s wit { State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error) }).ConfigureAwait(false); - return new ExecuteCommandResult { Success = false, ErrorMessage = $"Build failed with exit code {exitCode}." }; + return new ExecuteCommandResult { Success = false, Message = $"Build failed with exit code {exitCode}." }; } } catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) @@ -337,7 +337,7 @@ await resourceNotificationService.PublishUpdateAsync(projectResource, s => s wit { State = new ResourceStateSnapshot(KnownResourceStates.Finished, KnownResourceStateStyles.Info) }).ConfigureAwait(false); - return new ExecuteCommandResult { Success = false, ErrorMessage = "Rebuild was cancelled." }; + return new ExecuteCommandResult { Success = false, Message = "Rebuild was cancelled." }; } finally { diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index 46513404b28..f758426b282 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -142,17 +142,18 @@ public static class CommandResults public static ExecuteCommandResult Success() => new() { Success = true }; /// - /// Produces a success result with result data. + /// Produces a success result with a message and result data. /// + /// The message associated with the result. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Success(string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Result = result, ResultFormat = resultFormat }; + public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Message = message, Result = result, ResultFormat = resultFormat }; /// /// Produces an unsuccessful result with an error message. /// /// An optional error message. - public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, ErrorMessage = errorMessage }; + public static ExecuteCommandResult Failure(string? errorMessage = null) => new() { Success = false, Message = errorMessage }; /// /// Produces an unsuccessful result with an error message and result data. @@ -160,7 +161,7 @@ public static class CommandResults /// The error message. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, ErrorMessage = errorMessage, Result = result, ResultFormat = resultFormat }; + public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, Message = errorMessage, Result = result, ResultFormat = resultFormat }; /// /// Produces a canceled result. @@ -193,8 +194,14 @@ public sealed class ExecuteCommandResult /// /// An optional error message that can be set when the command is unsuccessful. /// + [Obsolete("Use Message instead.")] public string? ErrorMessage { get; init; } + /// + /// An optional message associated with the command result. + /// + public string? Message { get; init; } + /// /// An optional result value produced by the command. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index 95a4043e980..edbc1be2741 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -56,7 +56,7 @@ public async Task ExecuteCommandAsync(string resourceId, s { if (!_resourceNotificationService.TryGetCurrentState(resourceId, out var resourceEvent)) { - return new ExecuteCommandResult { Success = false, ErrorMessage = $"Resource '{resourceId}' not found." }; + return new ExecuteCommandResult { Success = false, Message = $"Resource '{resourceId}' not found." }; } return await ExecuteCommandCoreAsync(resourceEvent.ResourceId, resourceEvent.Resource, commandName, cancellationToken).ConfigureAwait(false); @@ -123,12 +123,12 @@ public async Task ExecuteCommandAsync(IResource resource, { // There were actual failures (possibly with some cancellations) var errorMessage = $"{failures.Count} command executions failed."; - errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.ErrorMessage}")); + errorMessage += Environment.NewLine + string.Join(Environment.NewLine, failures.Select(f => $"Resource '{f.resourceId}' failed with error message: {f.result.Message}")); return new ExecuteCommandResult { Success = false, - ErrorMessage = errorMessage + Message = errorMessage }; } } @@ -178,7 +178,7 @@ internal async Task ExecuteCommandCoreAsync(string resourc } else { - logger.LogInformation("Failure executing command '{CommandName}'. Error message: {ErrorMessage}", commandName, result.ErrorMessage); + logger.LogInformation("Failure executing command '{CommandName}'. Error message: {ErrorMessage}", commandName, result.Message); return result; } } @@ -190,11 +190,11 @@ internal async Task ExecuteCommandCoreAsync(string resourc catch (Exception ex) { logger.LogError(ex, "Error executing command '{CommandName}'.", commandName); - return new ExecuteCommandResult { Success = false, ErrorMessage = "Unhandled exception thrown." }; + return new ExecuteCommandResult { Success = false, Message = "Unhandled exception thrown." }; } } logger.LogInformation("Command '{CommandName}' not available.", commandName); - return new ExecuteCommandResult { Success = false, ErrorMessage = $"Command '{commandName}' not available for resource '{resourceId}'." }; + return new ExecuteCommandResult { Success = false, Message = $"Command '{commandName}' not available for resource '{resourceId}'." }; } } diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index e833f6d8ec9..db820da783c 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -218,7 +218,10 @@ public async Task ExecuteResourceCommandAsync(Ex { Success = result.Success, Canceled = result.Canceled, - ErrorMessage = result.ErrorMessage, +#pragma warning disable CS0618 // Type or member is obsolete + ErrorMessage = result.Message, +#pragma warning restore CS0618 // Type or member is obsolete + Message = result.Message, Result = result.Result, ResultFormat = result.ResultFormat?.ToString().ToLowerInvariant() }; diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 99eb58bf134..027f2bdee71 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -284,8 +284,14 @@ internal sealed class ExecuteResourceCommandResponse /// /// Gets the error message if the command failed. /// + [Obsolete("Use Message instead.")] public string? ErrorMessage { get; init; } + /// + /// Gets the message associated with the command result. + /// + public string? Message { get; init; } + /// /// Gets the result data produced by the command. /// diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index b671c7a1890..0f4a2eeb2f3 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -360,7 +360,7 @@ async Task WatchResourceConsoleLogsInternal(bool suppressFollow, CancellationTok public override async Task ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context) { - var (result, errorMessage, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); + var (result, message, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); var responseKind = result switch { ExecuteCommandResultType.Success => ResourceCommandResponseKind.Succeeded, @@ -369,10 +369,12 @@ public override async Task ExecuteResourceCommand(Resou _ => ResourceCommandResponseKind.Undefined }; +#pragma warning disable CS0612 // Type or member is obsolete var response = new ResourceCommandResponse { Kind = responseKind, - ErrorMessage = errorMessage ?? string.Empty, + Message = message ?? string.Empty, + ErrorMessage = message ?? string.Empty, ResultFormat = resultFormat switch { ApplicationModel.CommandResultFormat.Text => Aspire.DashboardService.Proto.V1.CommandResultFormat.Text, @@ -380,6 +382,7 @@ public override async Task ExecuteResourceCommand(Resou _ => Aspire.DashboardService.Proto.V1.CommandResultFormat.None } }; +#pragma warning restore CS0612 // Type or member is obsolete if (commandResult is not null) { diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index dd887bb3bc5..adeaa89bcd2 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -94,16 +94,18 @@ public void Dispose() _cts.Dispose(); } - internal async Task<(ExecuteCommandResultType result, string? errorMessage, string? commandResult, ApplicationModel.CommandResultFormat? resultFormat)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken) + internal async Task<(ExecuteCommandResultType result, string? message, 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, null, null); + return (ExecuteCommandResultType.Canceled, result.Message, null, null); } - return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.ErrorMessage, result.Result, result.ResultFormat); +#pragma warning disable CS0618 // Type or member is obsolete + return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, string.IsNullOrEmpty(result.Message) ? result.ErrorMessage : result.Message, result.Result, result.ResultFormat); +#pragma warning restore CS0618 // Type or member is obsolete } catch { diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 5eb948c2fd2..2929694c42e 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -91,9 +91,10 @@ enum ResourceCommandResponseKind { message ResourceCommandResponse { ResourceCommandResponseKind kind = 1; - optional string error_message = 2; + optional string error_message = 2 [deprecated = true]; optional string result = 3; CommandResultFormat result_format = 4; + optional string message = 5; } enum CommandResultFormat { diff --git a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs index 07585658bcd..02eeda22989 100644 --- a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs +++ b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs @@ -200,7 +200,7 @@ private void AddSetParameterCommand(ParameterResource parameterResource) executeCommand: async context => { await SetParameterAsync(parameterResource, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceSetParameter, parameterResource.Name) }; }, updateState: _ => ResourceCommandState.Enabled, displayDescription: CommandStrings.SetParameterDescription, @@ -216,7 +216,7 @@ private void AddSetParameterCommand(ParameterResource parameterResource) executeCommand: async context => { await DeleteParameterAsync(parameterResource, context.CancellationToken).ConfigureAwait(false); - return CommandResults.Success(); + return new ExecuteCommandResult { Success = true, Message = string.Format(CultureInfo.InvariantCulture, CommandStrings.ResourceDeletedParameter, parameterResource.Name) }; }, updateState: _ => HasParameterValue(parameterResource) ? ResourceCommandState.Enabled : ResourceCommandState.Hidden, displayDescription: CommandStrings.DeleteParameterDescription, diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index d6d836910e2..90f41cdc6ca 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -2640,9 +2640,8 @@ public static IResourceBuilder WithHttpCommand( { if (!endpoint.IsAllocated) { - return new ExecuteCommandResult { Success = false, ErrorMessage = "Endpoints are not yet allocated." }; + return new ExecuteCommandResult { Success = false, Message = "Endpoints are not yet allocated." }; } - var uri = new UriBuilder(endpoint.Url) { Path = path }.Uri; var httpClient = context.ServiceProvider.GetRequiredService().CreateClient(commandOptions.HttpClientName ?? Options.DefaultName); var request = new HttpRequestMessage(commandOptions.Method, uri); diff --git a/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs b/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs index 88d74edfc8a..6135bb20f2a 100644 --- a/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/CommandStrings.Designer.cs @@ -132,6 +132,60 @@ internal static string RebuilderResourceNotFound { } } + /// + /// Looks up a localized string similar to Successfully deleted parameter '{0}'.. + /// + internal static string ResourceDeletedParameter { + get { + return ResourceManager.GetString("ResourceDeletedParameter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully rebuilt '{0}'.. + /// + internal static string ResourceRebuilt { + get { + return ResourceManager.GetString("ResourceRebuilt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully restarted '{0}'.. + /// + internal static string ResourceRestarted { + get { + return ResourceManager.GetString("ResourceRestarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully set parameter '{0}'.. + /// + internal static string ResourceSetParameter { + get { + return ResourceManager.GetString("ResourceSetParameter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully started '{0}'.. + /// + internal static string ResourceStarted { + get { + return ResourceManager.GetString("ResourceStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Successfully stopped '{0}'.. + /// + internal static string ResourceStopped { + get { + return ResourceManager.GetString("ResourceStopped", resourceCulture); + } + } + /// /// Looks up a localized string similar to Set parameter value. /// diff --git a/src/Aspire.Hosting/Resources/CommandStrings.resx b/src/Aspire.Hosting/Resources/CommandStrings.resx index d8d65915ec1..c955543bf60 100644 --- a/src/Aspire.Hosting/Resources/CommandStrings.resx +++ b/src/Aspire.Hosting/Resources/CommandStrings.resx @@ -159,4 +159,28 @@ Rebuilder resource for '{0}' not found. + + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + {0} is the resource name. + diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf index d4e380fc1c3..ebde0302553 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Restartovat prostředek @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf index 949950b0c3b..adbf6bbe364 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Ressource neu starten @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf index 90bb9e812a2..d2f67ba89e0 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Reiniciar recurso @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf index d398468211a..f69fe25f71a 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Redémarrer la ressource @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf index 09a36f5672c..783c0e16512 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Riavvia risorsa @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf index b97008f9ba1..4a3f1fa42d8 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource リソースの再起動 @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf index 459de777333..16ac639329f 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource 리소스 다시 시작 @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf index f79143807e8..f959a404a13 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Uruchom ponownie zasób @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf index f9a249651f6..c89ebb00271 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Reiniciar recurso @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf index 98e0deb2bc9..e590c123ad5 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Перезапустить ресурс @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf index 8e222250001..fb14686e425 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource Kaynağı yeniden başlat @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf index 667a0defe83..430e2a03c4d 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource 重启资源 @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf index 2f18001c65d..5b5ef0297a8 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf @@ -22,6 +22,36 @@ Rebuild + + Successfully deleted parameter '{0}'. + Successfully deleted parameter '{0}'. + {0} is the resource name. + + + Successfully rebuilt '{0}'. + Successfully rebuilt '{0}'. + {0} is the resource name. + + + Successfully restarted '{0}'. + Successfully restarted '{0}'. + {0} is the resource name. + + + Successfully set parameter '{0}'. + Successfully set parameter '{0}'. + {0} is the resource name. + + + Successfully started '{0}'. + Successfully started '{0}'. + {0} is the resource name. + + + Successfully stopped '{0}'. + Successfully stopped '{0}'. + {0} is the resource name. + Restart resource 重新啟動資源 @@ -74,4 +104,4 @@ - + \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index f3e1543841a..092ced85f86 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -26,7 +26,7 @@ public async Task ExecuteCommandAsync_NoMatchingResource_Failure() // Assert Assert.False(result.Success); - Assert.Equal("Resource 'NotFoundResourceId' not found.", result.ErrorMessage); + Assert.Equal("Resource 'NotFoundResourceId' not found.", result.Message); } [Fact] @@ -49,7 +49,7 @@ public async Task ExecuteCommandAsync_ResourceNameMultipleMatches_Failure() // Assert Assert.False(result.Success); - Assert.Equal("Resource 'myResource' not found.", result.ErrorMessage); + Assert.Equal("Resource 'myResource' not found.", result.Message); } [Fact] @@ -68,7 +68,7 @@ public async Task ExecuteCommandAsync_NoMatchingCommand_Failure() // Assert Assert.False(result.Success); - Assert.Equal("Command 'NotFound' not available for resource 'myResource'.", result.ErrorMessage); + Assert.Equal("Command 'NotFound' not available for resource 'myResource'.", result.Message); } [Fact] @@ -165,7 +165,7 @@ public async Task ExecuteCommandAsync_HasReplicas_Failure_CalledPerReplica() displayName: "My command", executeCommand: e => { - return Task.FromResult(new ExecuteCommandResult { Success = false, ErrorMessage = "Failure!" }); + return Task.FromResult(new ExecuteCommandResult { Success = false, Message = "Failure!" }); }); // Act @@ -186,7 +186,7 @@ public async Task ExecuteCommandAsync_HasReplicas_Failure_CalledPerReplica() 2 command executions failed. Resource '{resourceNames[0]}' failed with error message: Failure! Resource '{resourceNames[1]}' failed with error message: Failure! - """, result.ErrorMessage); + """, result.Message); } [Fact] @@ -212,7 +212,7 @@ public async Task ExecuteCommandAsync_Canceled_Success() // Assert Assert.False(result.Success); Assert.True(result.Canceled); - Assert.Null(result.ErrorMessage); + Assert.Null(result.Message); } [Fact] @@ -242,7 +242,7 @@ public async Task ExecuteCommandAsync_HasReplicas_Canceled_CalledPerReplica() // Assert Assert.False(result.Success); Assert.True(result.Canceled); - Assert.Null(result.ErrorMessage); + Assert.Null(result.Message); } [Fact] @@ -285,7 +285,7 @@ public async Task ExecuteCommandAsync_HasReplicas_MixedFailureAndCanceled_OnlyFa Assert.Equal($""" 1 command executions failed. Resource '{resourceNames[0]}' failed with error message: Failure! - """, result.ErrorMessage); + """, result.Message); } [Fact] @@ -297,7 +297,7 @@ public void CommandResults_Canceled_ProducesCorrectResult() // Assert Assert.False(result.Success); Assert.True(result.Canceled); - Assert.Null(result.ErrorMessage); + Assert.Null(result.Message); } [Fact] @@ -323,7 +323,7 @@ public async Task ExecuteCommandAsync_OperationCanceledException_Canceled() // Assert Assert.False(result.Success); Assert.True(result.Canceled); - Assert.Null(result.ErrorMessage); + Assert.Null(result.Message); } [Fact] @@ -376,7 +376,7 @@ public async Task ExecuteCommandAsync_SuccessWithResult_ReturnsResultData() var custom = builder.AddResource(new CustomResource("myResource")); custom.WithCommand(name: "generate-token", displayName: "Generate Token", - executeCommand: _ => Task.FromResult(CommandResults.Success("{\"token\": \"abc123\"}", CommandResultFormat.Json))); + executeCommand: _ => Task.FromResult(CommandResults.Success("Generated token.", "{\"token\": \"abc123\"}", CommandResultFormat.Json))); var app = builder.Build(); await app.StartAsync(); @@ -424,7 +424,7 @@ public async Task ExecuteCommandAsync_HasReplicas_SuccessWithResult_ReturnsFirst executeCommand: e => { var count = Interlocked.Increment(ref callCount); - return Task.FromResult(CommandResults.Success($"token-{count}", CommandResultFormat.Text)); + return Task.FromResult(CommandResults.Success("Generated token.", $"token-{count}", CommandResultFormat.Text)); }); var app = builder.Build(); @@ -441,7 +441,7 @@ public async Task ExecuteCommandAsync_HasReplicas_SuccessWithResult_ReturnsFirst [Fact] public void CommandResults_SuccessWithResult_ProducesCorrectResult() { - var result = CommandResults.Success("{\"key\": \"value\"}", CommandResultFormat.Json); + var result = CommandResults.Success("Success.", "{\"key\": \"value\"}", CommandResultFormat.Json); Assert.True(result.Success); Assert.Equal("{\"key\": \"value\"}", result.Result); @@ -451,7 +451,7 @@ public void CommandResults_SuccessWithResult_ProducesCorrectResult() [Fact] public void CommandResults_SuccessWithTextResult_DefaultsToText() { - var result = CommandResults.Success("hello world"); + var result = CommandResults.Success("Success.", "hello world"); Assert.True(result.Success); Assert.Equal("hello world", result.Result); diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index 7e56739ec96..c123b83bdfd 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -397,7 +397,7 @@ public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest() // Assert Assert.True(callbackCalled); Assert.False(result.Success); - Assert.Equal("A test error message", result.ErrorMessage); + Assert.Equal("A test error message", result.Message); } [Fact] From 9407edeaa356e3903979783d0a955241de9d597f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 16:13:34 +0800 Subject: [PATCH 03/16] Clean up --- src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs | 6 +++--- src/Aspire.Hosting/Dashboard/DashboardService.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 794be05e35a..17abc409ac6 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -30,7 +30,7 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable private IDisposable? _aiDisplayChangedSubscription; private const string SettingsDialogId = "SettingsDialog"; private const string HelpDialogId = "HelpDialog"; - private const string NotificationCenterDialogId = "NotificationCenterDialog"; + private const string NotificationsDialogId = "NotificationsDialog"; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -298,13 +298,13 @@ public async Task LaunchNotificationsAsync() Alignment = HorizontalAlignment.Right, Width = "350px", Height = "auto", - Id = NotificationCenterDialogId, + Id = NotificationsDialogId, OnDialogClosing = EventCallback.Factory.Create(this, HandleDialogClose) }; if (_openPageDialog is not null) { - if (Equals(_openPageDialog.Id, NotificationCenterDialogId) && !_openPageDialog.Result.IsCompleted) + if (Equals(_openPageDialog.Id, NotificationsDialogId) && !_openPageDialog.Result.IsCompleted) { return; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 0f4a2eeb2f3..cb89fe730ac 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -374,7 +374,7 @@ public override async Task ExecuteResourceCommand(Resou { Kind = responseKind, Message = message ?? string.Empty, - ErrorMessage = message ?? string.Empty, + ErrorMessage = result == ExecuteCommandResultType.Failure ? message ?? string.Empty : string.Empty, ResultFormat = resultFormat switch { ApplicationModel.CommandResultFormat.Text => Aspire.DashboardService.Proto.V1.CommandResultFormat.Text, From fb0895a73b1353b1d486bc9279acd01a14aa1a6e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 17:05:45 +0800 Subject: [PATCH 04/16] Update --- playground/Stress/Stress.AppHost/AppHost.cs | 4 +- .../Commands/ResourceCommandHelper.cs | 8 ++-- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 8 ++-- .../Model/DashboardCommandExecutor.cs | 26 +++++++---- .../Model/ResourceCommandResponseViewModel.cs | 13 +++++- .../ServiceClient/Partials.cs | 16 ++++--- .../ResourceCommandAnnotation.cs | 43 ++++++++++++++++--- .../ResourceCommandService.cs | 5 +-- .../AuxiliaryBackchannelRpcTarget.cs | 8 +++- .../Backchannel/BackchannelDataTypes.cs | 24 +++++++++-- .../Dashboard/DashboardService.cs | 27 +++++++----- .../Dashboard/DashboardServiceData.cs | 10 ++--- .../Dashboard/proto/dashboard_service.proto | 11 +++-- .../Commands/ResourceCommandHelperTests.cs | 21 ++++++--- .../Mcp/ExecuteResourceCommandToolTests.cs | 7 ++- .../ResourceCommandServiceTests.cs | 24 ++++++----- 16 files changed, 176 insertions(+), 79 deletions(-) diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index 7c621547f37..b165f3aae8d 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -140,7 +140,7 @@ issuedAt = DateTime.UtcNow }; var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true }); - return Task.FromResult(CommandResults.Success(json, CommandResultFormat.Json)); + return Task.FromResult(CommandResults.Success("Generated token.", new CommandResultData { Value = json, Format = CommandResultFormat.Json })); }, commandOptions: new() { IconName = "Key", Description = "Generate a temporary access token" }) .WithCommand( @@ -149,7 +149,7 @@ 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)); + return Task.FromResult(CommandResults.Success("Retrieved connection string.", connectionString)); }, commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" }) .WithCommand( diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs index 0588d6c69f4..c07095e879b 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -83,9 +83,9 @@ public static async Task ExecuteGenericCommandAsync( 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); + interactionService.DisplayRawText(response.Value.Value, ConsoleOutput.Standard); } return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; @@ -116,9 +116,9 @@ private static int HandleResponse( 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); + interactionService.DisplayRawText(response.Value.Value, ConsoleOutput.Standard); } return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index bdbd9d49a4d..50d255bd10f 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -79,9 +79,9 @@ public override async ValueTask 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 @@ -104,9 +104,9 @@ public override async ValueTask CallToolAsync(CallToolContext co 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 diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index aa67e19f539..b680fefefb4 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -166,7 +166,7 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.Intent = ToastIntent.Success; toastParameters.Icon = GetIntentIcon(ToastIntent.Success); - if (response.Result is not null) + if (response.Value is not null) { toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); @@ -182,11 +182,16 @@ await messageService.ShowMessageBarAsync(options => options.AllowDismiss = true; options.Timestamp = DateTime.Now; - if (response.Result is not null) + if (response.Value is not null) { options.PrimaryAction = CreateViewResponseAction(command, response); } }).ConfigureAwait(false); + + if (response.Value?.DisplayImmediately == true) + { + await OpenViewResponseDialogAsync(command, response).ConfigureAwait(false); + } } else if (response.Kind == ResourceCommandResponseKind.Cancelled) { @@ -208,7 +213,7 @@ await messageService.ShowMessageBarAsync(options => toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); - if (response.Result is not null) + if (response.Value is not null) { toastParameters.SecondaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; toastParameters.OnSecondaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); @@ -224,11 +229,16 @@ await messageService.ShowMessageBarAsync(options => options.AllowDismiss = true; options.Timestamp = DateTime.Now; - if (response.Result is not null) + if (response.Value is not null) { options.PrimaryAction = CreateViewResponseAction(command, response); } }).ConfigureAwait(false); + + if (response.Value?.DisplayImmediately == true) + { + await OpenViewResponseDialogAsync(command, response).ConfigureAwait(false); + } } if (!toastClosed) @@ -269,7 +279,7 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) private ActionButton CreateViewResponseAction(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Value!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; return new ActionButton { @@ -280,7 +290,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = dialogService, ValueDescription = command.GetDisplayName(), - Value = response.Result!, + Value = response.Value.Value, FixedFormat = fixedFormat }).ConfigureAwait(false); } @@ -289,13 +299,13 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions private async Task OpenViewResponseDialogAsync(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.ResultFormat == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Value!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = dialogService, ValueDescription = command.GetDisplayName(), - Value = response.Result!, + Value = response.Value.Value, FixedFormat = fixedFormat }).ConfigureAwait(false); } diff --git a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs index c211fa0debf..eb1a8d6defa 100644 --- a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs @@ -8,8 +8,17 @@ public class ResourceCommandResponseViewModel public required ResourceCommandResponseKind Kind { get; init; } public string? ErrorMessage { get; init; } public string? Message { get; init; } - public string? Result { get; init; } - public CommandResultFormat? ResultFormat { get; init; } + public ResourceCommandResultViewModel? Value { get; init; } +} + +/// +/// Represents a value produced by a command. +/// +public class ResourceCommandResultViewModel +{ + public required string Value { get; init; } + public CommandResultFormat Format { get; init; } + public bool DisplayImmediately { get; init; } } // Must be kept in sync with ResourceCommandResponseKind in the resource_service.proto file diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index a295d0e0bfe..6fd1bc7b002 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -206,13 +206,17 @@ public ResourceCommandResponseViewModel ToViewModel() ErrorMessage = resolvedMessage, Message = resolvedMessage, Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind, - Result = HasResult ? Result : null, - ResultFormat = ResultFormat switch + Value = value_ is not null ? new ResourceCommandResultViewModel { - CommandResultFormat.Text => Dashboard.Model.CommandResultFormat.Text, - CommandResultFormat.Json => Dashboard.Model.CommandResultFormat.Json, - _ => null - } + Value = value_.Value, + Format = value_.Format switch + { + CommandResultFormat.Text => Dashboard.Model.CommandResultFormat.Text, + CommandResultFormat.Json => Dashboard.Model.CommandResultFormat.Json, + _ => Dashboard.Model.CommandResultFormat.Text + }, + DisplayImmediately = value_.DisplayImmediately + } : null }; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index f758426b282..307de4b1ddc 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -147,7 +147,14 @@ public static class CommandResults /// The message associated with the result. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Message = message, Result = result, ResultFormat = resultFormat }; + public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Message = message, Value = new CommandResultData { Value = result, Format = resultFormat } }; + + /// + /// Produces a success result with a message and a value. + /// + /// The message associated with the result. + /// The value produced by the command. + public static ExecuteCommandResult Success(string message, CommandResultData value) => new() { Success = true, Message = message, Value = value }; /// /// Produces an unsuccessful result with an error message. @@ -161,7 +168,14 @@ public static class CommandResults /// The error message. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, Message = errorMessage, Result = result, ResultFormat = resultFormat }; + public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, Message = errorMessage, Value = new CommandResultData { Value = result, Format = resultFormat } }; + + /// + /// Produces an unsuccessful result with an error message and a value. + /// + /// The error message. + /// The value produced by the command. + public static ExecuteCommandResult Failure(string errorMessage, CommandResultData value) => new() { Success = false, Message = errorMessage, Value = value }; /// /// Produces a canceled result. @@ -203,14 +217,31 @@ public sealed class ExecuteCommandResult public string? Message { get; init; } /// - /// An optional result value produced by the command. + /// An optional value produced by the command. + /// + public CommandResultData? Value { get; init; } +} + +/// +/// Represents a value produced by a command. +/// +[AspireDto] +public sealed class CommandResultData +{ + /// + /// The value data. + /// + public required string Value { get; init; } + + /// + /// The format of the data. /// - public string? Result { get; init; } + public CommandResultFormat Format { get; init; } /// - /// The format of the value. + /// When , the dashboard will immediately display the value in a dialog when the command completes. /// - public CommandResultFormat? ResultFormat { get; init; } + public bool DisplayImmediately { get; init; } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index edbc1be2741..b696ccf4124 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -106,12 +106,11 @@ public async Task ExecuteCommandAsync(IResource resource, if (failures.Count == 0 && cancellations.Count == 0) { - var successWithResult = results.FirstOrDefault(r => r.Result is not null); + var successWithResult = results.FirstOrDefault(r => r.Value is not null); return new ExecuteCommandResult { Success = true, - Result = successWithResult?.Result, - ResultFormat = successWithResult?.ResultFormat + Value = successWithResult?.Value }; } else if (failures.Count == 0 && cancellations.Count > 0) diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index db820da783c..b43fa5783bb 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -222,8 +222,12 @@ public async Task ExecuteResourceCommandAsync(Ex ErrorMessage = result.Message, #pragma warning restore CS0618 // Type or member is obsolete Message = result.Message, - Result = result.Result, - ResultFormat = result.ResultFormat?.ToString().ToLowerInvariant() + Value = result.Value is { } v ? new ExecuteResourceCommandResult + { + Value = v.Value, + Format = v.Format.ToString().ToLowerInvariant(), + DisplayImmediately = v.DisplayImmediately + } : null }; } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 027f2bdee71..e24d320a9b5 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -293,14 +293,30 @@ internal sealed class ExecuteResourceCommandResponse public string? Message { get; init; } /// - /// Gets the result data produced by the command. + /// Gets the value produced by the command. /// - public string? Result { get; init; } + public ExecuteResourceCommandResult? Value { get; init; } +} + +/// +/// Value produced by a resource command. +/// +internal sealed class ExecuteResourceCommandResult +{ + /// + /// Gets the value data. + /// + public required string Value { get; init; } + + /// + /// Gets the format of the value data (e.g. "text", "json"). + /// + public string? Format { get; init; } /// - /// Gets the format of the result data (e.g. "none", "text", "json"). + /// Gets whether to immediately display the value in the dashboard. /// - public string? ResultFormat { get; init; } + public bool DisplayImmediately { get; init; } } #endregion diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index cb89fe730ac..be01eb1f8ec 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -360,7 +360,7 @@ async Task WatchResourceConsoleLogsInternal(bool suppressFollow, CancellationTok public override async Task ExecuteResourceCommand(ResourceCommandRequest request, ServerCallContext context) { - var (result, message, commandResult, resultFormat) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); + var (result, message, value) = await serviceData.ExecuteCommandAsync(request.ResourceName, request.CommandName, context.CancellationToken).ConfigureAwait(false); var responseKind = result switch { ExecuteCommandResultType.Success => ResourceCommandResponseKind.Succeeded, @@ -369,24 +369,31 @@ public override async Task ExecuteResourceCommand(Resou _ => ResourceCommandResponseKind.Undefined }; -#pragma warning disable CS0612 // Type or member is obsolete var response = new ResourceCommandResponse { Kind = responseKind, Message = message ?? string.Empty, - ErrorMessage = result == ExecuteCommandResultType.Failure ? message ?? string.Empty : string.Empty, - ResultFormat = resultFormat switch + }; + +#pragma warning disable CS0612 // Type or member is obsolete + response.ErrorMessage = message ?? string.Empty; +#pragma warning restore CS0612 // Type or member is obsolete + + if (value is not null) + { + static Aspire.DashboardService.Proto.V1.CommandResultFormat MapFormat(ApplicationModel.CommandResultFormat format) => format switch { ApplicationModel.CommandResultFormat.Text => Aspire.DashboardService.Proto.V1.CommandResultFormat.Text, ApplicationModel.CommandResultFormat.Json => Aspire.DashboardService.Proto.V1.CommandResultFormat.Json, _ => Aspire.DashboardService.Proto.V1.CommandResultFormat.None - } - }; -#pragma warning restore CS0612 // Type or member is obsolete + }; - if (commandResult is not null) - { - response.Result = commandResult; + response.Value = new ResourceCommandResult + { + Value = value.Value, + Format = MapFormat(value.Format), + DisplayImmediately = value.DisplayImmediately + }; } return response; diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index adeaa89bcd2..39077e29b3a 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -94,23 +94,21 @@ public void Dispose() _cts.Dispose(); } - internal async Task<(ExecuteCommandResultType result, string? message, string? commandResult, ApplicationModel.CommandResultFormat? resultFormat)> ExecuteCommandAsync(string resourceId, string type, CancellationToken cancellationToken) + internal async Task<(ExecuteCommandResultType result, string? message, ApplicationModel.CommandResultData? value)> 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.Message, null, null); + return (ExecuteCommandResultType.Canceled, result.Message, null); } -#pragma warning disable CS0618 // Type or member is obsolete - return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, string.IsNullOrEmpty(result.Message) ? result.ErrorMessage : result.Message, result.Result, result.ResultFormat); -#pragma warning restore CS0618 // Type or member is obsolete + return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.Message, result.Value); } catch { // Note: Exception is already logged in the command executor. - return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.", null, null); + return (ExecuteCommandResultType.Failure, "Unhandled exception thrown while executing command.", null); } } diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 2929694c42e..cd344efbe33 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -92,9 +92,14 @@ enum ResourceCommandResponseKind { message ResourceCommandResponse { ResourceCommandResponseKind kind = 1; optional string error_message = 2 [deprecated = true]; - optional string result = 3; - CommandResultFormat result_format = 4; - optional string message = 5; + optional string message = 3; + optional ResourceCommandResult value = 4; +} + +message ResourceCommandResult { + string value = 1; + CommandResultFormat format = 2; + bool display_immediately = 3; } enum CommandResultFormat { diff --git a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs index 9970af18a47..f6dd27b6aaa 100644 --- a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs @@ -20,8 +20,11 @@ public async Task ExecuteGenericCommandAsync_WithResult_OutputsRawText() ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true, - Result = "{\"items\": [\"a\", \"b\"]}", - ResultFormat = "json" + Value = new ExecuteResourceCommandResult + { + Value = "{\"items\": [\"a\", \"b\"]}", + Format = "json" + } } }; @@ -80,8 +83,11 @@ public async Task ExecuteGenericCommandAsync_ErrorWithResult_OutputsRawText() { Success = false, ErrorMessage = "Validation failed", - Result = "{\"errors\": [\"invalid host\"]}", - ResultFormat = "json" + Value = new ExecuteResourceCommandResult + { + Value = "{\"errors\": [\"invalid host\"]}", + Format = "json" + } } }; @@ -112,8 +118,11 @@ public async Task ExecuteGenericCommandAsync_RoutesStatusToStderr_ResultToStdout ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true, - Result = "some output", - ResultFormat = "text" + Value = new ExecuteResourceCommandResult + { + Value = "some output", + Format = "text" + } } }; diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index 8926e7a97d4..5bf0d66f414 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -160,8 +160,11 @@ public async Task ExecuteResourceCommandTool_ReturnsResult_WhenCommandReturnsRes ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true, - Result = "{\"token\": \"abc123\"}", - ResultFormat = "json" + Value = new ExecuteResourceCommandResult + { + Value = "{\"token\": \"abc123\"}", + Format = "json" + } } }; monitor.AddConnection("hash1", "socket.hash1", connection); diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 092ced85f86..462a537090e 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -384,8 +384,9 @@ public async Task ExecuteCommandAsync_SuccessWithResult_ReturnsResultData() var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); Assert.True(result.Success); - Assert.Equal("{\"token\": \"abc123\"}", result.Result); - Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + Assert.NotNull(result.Value); + Assert.Equal("{\"token\": \"abc123\"}", result.Value.Value); + Assert.Equal(CommandResultFormat.Json, result.Value.Format); } [Fact] @@ -404,8 +405,7 @@ public async Task ExecuteCommandAsync_SuccessWithoutResult_ReturnsNoResultData() var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand"); Assert.True(result.Success); - Assert.Null(result.Result); - Assert.Null(result.ResultFormat); + Assert.Null(result.Value); } [Fact] @@ -433,9 +433,9 @@ public async Task ExecuteCommandAsync_HasReplicas_SuccessWithResult_ReturnsFirst var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); Assert.True(result.Success); - Assert.NotNull(result.Result); - Assert.StartsWith("token-", result.Result); - Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + Assert.NotNull(result.Value); + Assert.StartsWith("token-", result.Value.Value); + Assert.Equal(CommandResultFormat.Text, result.Value.Format); } [Fact] @@ -444,8 +444,9 @@ public void CommandResults_SuccessWithResult_ProducesCorrectResult() var result = CommandResults.Success("Success.", "{\"key\": \"value\"}", CommandResultFormat.Json); Assert.True(result.Success); - Assert.Equal("{\"key\": \"value\"}", result.Result); - Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + Assert.NotNull(result.Value); + Assert.Equal("{\"key\": \"value\"}", result.Value.Value); + Assert.Equal(CommandResultFormat.Json, result.Value.Format); } [Fact] @@ -454,8 +455,9 @@ public void CommandResults_SuccessWithTextResult_DefaultsToText() var result = CommandResults.Success("Success.", "hello world"); Assert.True(result.Success); - Assert.Equal("hello world", result.Result); - Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + Assert.NotNull(result.Value); + Assert.Equal("hello world", result.Value.Value); + Assert.Equal(CommandResultFormat.Text, result.Value.Format); } private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport From fdb2ca049cc700663a4d303797000ac1e1a442d8 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 17:17:54 +0800 Subject: [PATCH 05/16] Update --- ...TwoPassScanningGeneratedAspire.verified.go | 24 +++++++-- ...oPassScanningGeneratedAspire.verified.java | 49 +++++++++++++++---- ...TwoPassScanningGeneratedAspire.verified.py | 9 +++- ...TwoPassScanningGeneratedAspire.verified.rs | 33 ++++++++++--- ...TwoPassScanningGeneratedAspire.verified.ts | 11 ++++- 5 files changed, 103 insertions(+), 23 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 7890993632f..c50fe811d7d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -243,8 +243,8 @@ type ExecuteCommandResult struct { Success bool `json:"Success,omitempty"` Canceled bool `json:"Canceled,omitempty"` ErrorMessage string `json:"ErrorMessage,omitempty"` - Result string `json:"Result,omitempty"` - ResultFormat CommandResultFormat `json:"ResultFormat,omitempty"` + Message string `json:"Message,omitempty"` + Value *CommandResultData `json:"Value,omitempty"` } // ToMap converts the DTO to a map for JSON serialization. @@ -253,8 +253,24 @@ func (d *ExecuteCommandResult) ToMap() map[string]any { "Success": SerializeValue(d.Success), "Canceled": SerializeValue(d.Canceled), "ErrorMessage": SerializeValue(d.ErrorMessage), - "Result": SerializeValue(d.Result), - "ResultFormat": SerializeValue(d.ResultFormat), + "Message": SerializeValue(d.Message), + "Value": SerializeValue(d.Value), + } +} + +// CommandResultData represents CommandResultData. +type CommandResultData struct { + Value string `json:"Value,omitempty"` + Format CommandResultFormat `json:"Format,omitempty"` + DisplayImmediately bool `json:"DisplayImmediately,omitempty"` +} + +// ToMap converts the DTO to a map for JSON serialization. +func (d *CommandResultData) ToMap() map[string]any { + return map[string]any{ + "Value": SerializeValue(d.Value), + "Format": SerializeValue(d.Format), + "DisplayImmediately": SerializeValue(d.DisplayImmediately), } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 9c19bdd9406..31ae2b9be8d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1,4 +1,4 @@ -// ===== AddDockerfileOptions.java ===== +// ===== AddDockerfileOptions.java ===== // AddDockerfileOptions.java - GENERATED CODE - DO NOT EDIT package aspire; @@ -2834,6 +2834,36 @@ public Map toMap() { } } +// ===== CommandResultData.java ===== +// CommandResultData.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** CommandResultData DTO. */ +public class CommandResultData { + private String value; + private CommandResultFormat format; + private boolean displayImmediately; + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + public CommandResultFormat getFormat() { return format; } + public void setFormat(CommandResultFormat value) { this.format = value; } + public boolean getDisplayImmediately() { return displayImmediately; } + public void setDisplayImmediately(boolean value) { this.displayImmediately = value; } + + public Map toMap() { + Map map = new HashMap<>(); + map.put("Value", AspireClient.serializeValue(value)); + map.put("Format", AspireClient.serializeValue(format)); + map.put("DisplayImmediately", AspireClient.serializeValue(displayImmediately)); + return map; + } +} + // ===== CommandResultFormat.java ===== // CommandResultFormat.java - GENERATED CODE - DO NOT EDIT @@ -9458,8 +9488,8 @@ public class ExecuteCommandResult { private boolean success; private boolean canceled; private String errorMessage; - private String result; - private CommandResultFormat resultFormat; + private String message; + private CommandResultData value; public boolean getSuccess() { return success; } public void setSuccess(boolean value) { this.success = value; } @@ -9467,18 +9497,18 @@ public class ExecuteCommandResult { public void setCanceled(boolean value) { this.canceled = value; } public String getErrorMessage() { return errorMessage; } public void setErrorMessage(String value) { this.errorMessage = value; } - public String getResult() { return result; } - public void setResult(String value) { this.result = value; } - public CommandResultFormat getResultFormat() { return resultFormat; } - public void setResultFormat(CommandResultFormat value) { this.resultFormat = value; } + public String getMessage() { return message; } + public void setMessage(String value) { this.message = value; } + public CommandResultData getValue() { return value; } + public void setValue(CommandResultData value) { this.value = value; } public Map toMap() { Map map = new HashMap<>(); map.put("Success", AspireClient.serializeValue(success)); map.put("Canceled", AspireClient.serializeValue(canceled)); map.put("ErrorMessage", AspireClient.serializeValue(errorMessage)); - map.put("Result", AspireClient.serializeValue(result)); - map.put("ResultFormat", AspireClient.serializeValue(resultFormat)); + map.put("Message", AspireClient.serializeValue(message)); + map.put("Value", AspireClient.serializeValue(value)); return map; } } @@ -20708,6 +20738,7 @@ public WithVolumeOptions isReadOnly(Boolean value) { .modules/CertificateTrustScope.java .modules/CommandLineArgsCallbackContext.java .modules/CommandOptions.java +.modules/CommandResultData.java .modules/CommandResultFormat.java .modules/CompleteStepMarkdownOptions.java .modules/CompleteStepOptions.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index fcc8f4e2a7a..560fc0851f7 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1671,6 +1671,11 @@ class CommandOptions(typing.TypedDict, total=False): IsHighlighted: bool UpdateState: typing.Any +class CommandResultData(typing.TypedDict, total=False): + Value: str + Format: CommandResultFormat + DisplayImmediately: bool + class CreateBuilderOptions(typing.TypedDict, total=False): Args: typing.Iterable[str] ProjectDirectory: str @@ -1685,8 +1690,8 @@ class ExecuteCommandResult(typing.TypedDict, total=False): Success: bool Canceled: bool ErrorMessage: str - Result: str - ResultFormat: CommandResultFormat + Message: str + Value: CommandResultData class ResourceEventDto(typing.TypedDict, total=False): ResourceName: str diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 8a31aa3be48..c870e982ea2 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -512,10 +512,10 @@ pub struct ExecuteCommandResult { pub canceled: bool, #[serde(rename = "ErrorMessage")] pub error_message: String, - #[serde(rename = "Result")] - pub result: String, - #[serde(rename = "ResultFormat")] - pub result_format: CommandResultFormat, + #[serde(rename = "Message")] + pub message: String, + #[serde(rename = "Value")] + pub value: CommandResultData, } impl ExecuteCommandResult { @@ -524,8 +524,29 @@ impl ExecuteCommandResult { map.insert("Success".to_string(), serde_json::to_value(&self.success).unwrap_or(Value::Null)); map.insert("Canceled".to_string(), serde_json::to_value(&self.canceled).unwrap_or(Value::Null)); map.insert("ErrorMessage".to_string(), serde_json::to_value(&self.error_message).unwrap_or(Value::Null)); - map.insert("Result".to_string(), serde_json::to_value(&self.result).unwrap_or(Value::Null)); - map.insert("ResultFormat".to_string(), serde_json::to_value(&self.result_format).unwrap_or(Value::Null)); + map.insert("Message".to_string(), serde_json::to_value(&self.message).unwrap_or(Value::Null)); + map.insert("Value".to_string(), serde_json::to_value(&self.value).unwrap_or(Value::Null)); + map + } +} + +/// CommandResultData +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CommandResultData { + #[serde(rename = "Value")] + pub value: String, + #[serde(rename = "Format")] + pub format: CommandResultFormat, + #[serde(rename = "DisplayImmediately")] + pub display_immediately: bool, +} + +impl CommandResultData { + pub fn to_map(&self) -> HashMap { + let mut map = HashMap::new(); + map.insert("Value".to_string(), serde_json::to_value(&self.value).unwrap_or(Value::Null)); + map.insert("Format".to_string(), serde_json::to_value(&self.format).unwrap_or(Value::Null)); + map.insert("DisplayImmediately".to_string(), serde_json::to_value(&self.display_immediately).unwrap_or(Value::Null)); map } } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 03c76b21116..f3b237c968b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -382,6 +382,13 @@ export interface CommandOptions { updateState?: any; } +/** DTO interface for CommandResultData */ +export interface CommandResultData { + value?: string; + format?: CommandResultFormat; + displayImmediately?: boolean; +} + /** DTO interface for CreateBuilderOptions */ export interface CreateBuilderOptions { args?: string[]; @@ -399,8 +406,8 @@ export interface ExecuteCommandResult { success?: boolean; canceled?: boolean; errorMessage?: string; - result?: string; - resultFormat?: CommandResultFormat; + message?: string; + value?: CommandResultData; } /** DTO interface for ResourceEventDto */ From 3d10a3f25fdf43144dfbe11b50afabda333678d4 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 17:26:23 +0800 Subject: [PATCH 06/16] PR feedback --- .../Model/DashboardCommandExecutor.cs | 1 + .../ApplicationModel/ResourceCommandAnnotation.cs | 14 ++++++++++++-- .../Backchannel/AuxiliaryBackchannelRpcTarget.cs | 8 ++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index b680fefefb4..7f520945697 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -212,6 +212,7 @@ await messageService.ShowMessageBarAsync(options => toastParameters.Icon = GetIntentIcon(ToastIntent.Error); toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandToastViewLogs)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); + toastParameters.Content.Details = response.Message; if (response.Value is not null) { diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index 307de4b1ddc..1dee58e6e9a 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -209,12 +209,22 @@ public sealed class ExecuteCommandResult /// An optional error message that can be set when the command is unsuccessful. /// [Obsolete("Use Message instead.")] - public string? ErrorMessage { get; init; } + public string? ErrorMessage + { + get => _message; + init => _message ??= value; + } /// /// An optional message associated with the command result. /// - public string? Message { get; init; } + public string? Message + { + get => _message; + init => _message = value; + } + + private string? _message; /// /// An optional value produced by the command. diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index b43fa5783bb..6b706291783 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -214,14 +214,18 @@ public async Task ExecuteResourceCommandAsync(Ex var resourceCommandService = serviceProvider.GetRequiredService(); var result = await resourceCommandService.ExecuteCommandAsync(request.ResourceName, request.CommandName, cancellationToken).ConfigureAwait(false); +#pragma warning disable CS0618 // Type or member is obsolete + var resolvedMessage = result.Message ?? result.ErrorMessage; +#pragma warning restore CS0618 // Type or member is obsolete + return new ExecuteResourceCommandResponse { Success = result.Success, Canceled = result.Canceled, #pragma warning disable CS0618 // Type or member is obsolete - ErrorMessage = result.Message, + ErrorMessage = resolvedMessage, #pragma warning restore CS0618 // Type or member is obsolete - Message = result.Message, + Message = resolvedMessage, Value = result.Value is { } v ? new ExecuteResourceCommandResult { Value = v.Value, From ff686cc28261bfe179860df733d0278a99957457 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 17:39:41 +0800 Subject: [PATCH 07/16] Clean up --- .../Model/DashboardCommandExecutor.cs | 20 ++++++------- .../Model/ResourceCommandResponseViewModel.cs | 2 +- .../ServiceClient/Partials.cs | 8 +++--- .../ResourceCommandAnnotation.cs | 10 +++---- .../ResourceCommandService.cs | 4 +-- .../AuxiliaryBackchannelRpcTarget.cs | 2 +- .../Dashboard/DashboardService.cs | 2 +- .../Dashboard/DashboardServiceData.cs | 2 +- .../Dashboard/proto/dashboard_service.proto | 2 +- .../Commands/ResourceCommandHelperTests.cs | 2 +- .../Mcp/ExecuteResourceCommandToolTests.cs | 2 +- .../ResourceCommandServiceTests.cs | 28 +++++++++---------- 12 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 7f520945697..0979cc71b0f 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -166,7 +166,7 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.Intent = ToastIntent.Success; toastParameters.Icon = GetIntentIcon(ToastIntent.Success); - if (response.Value is not null) + if (response.Result is not null) { toastParameters.PrimaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); @@ -182,13 +182,13 @@ await messageService.ShowMessageBarAsync(options => options.AllowDismiss = true; options.Timestamp = DateTime.Now; - if (response.Value is not null) + if (response.Result is not null) { options.PrimaryAction = CreateViewResponseAction(command, response); } }).ConfigureAwait(false); - if (response.Value?.DisplayImmediately == true) + if (response.Result?.DisplayImmediately == true) { await OpenViewResponseDialogAsync(command, response).ConfigureAwait(false); } @@ -214,7 +214,7 @@ await messageService.ShowMessageBarAsync(options => toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)))); toastParameters.Content.Details = response.Message; - if (response.Value is not null) + if (response.Result is not null) { toastParameters.SecondaryAction = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)]; toastParameters.OnSecondaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); @@ -230,13 +230,13 @@ await messageService.ShowMessageBarAsync(options => options.AllowDismiss = true; options.Timestamp = DateTime.Now; - if (response.Value is not null) + if (response.Result is not null) { options.PrimaryAction = CreateViewResponseAction(command, response); } }).ConfigureAwait(false); - if (response.Value?.DisplayImmediately == true) + if (response.Result?.DisplayImmediately == true) { await OpenViewResponseDialogAsync(command, response).ConfigureAwait(false); } @@ -280,7 +280,7 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) private ActionButton CreateViewResponseAction(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.Value!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Result!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; return new ActionButton { @@ -291,7 +291,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = dialogService, ValueDescription = command.GetDisplayName(), - Value = response.Value.Value, + Value = response.Result.Value, FixedFormat = fixedFormat }).ConfigureAwait(false); } @@ -300,13 +300,13 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions private async Task OpenViewResponseDialogAsync(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.Value!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Result!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = dialogService, ValueDescription = command.GetDisplayName(), - Value = response.Value.Value, + Value = response.Result.Value, FixedFormat = fixedFormat }).ConfigureAwait(false); } diff --git a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs index eb1a8d6defa..e4919adf1d7 100644 --- a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs @@ -8,7 +8,7 @@ public class ResourceCommandResponseViewModel public required ResourceCommandResponseKind Kind { get; init; } public string? ErrorMessage { get; init; } public string? Message { get; init; } - public ResourceCommandResultViewModel? Value { get; init; } + public ResourceCommandResultViewModel? Result { get; init; } } /// diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index 6fd1bc7b002..a06053decf9 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -206,16 +206,16 @@ public ResourceCommandResponseViewModel ToViewModel() ErrorMessage = resolvedMessage, Message = resolvedMessage, Kind = (Dashboard.Model.ResourceCommandResponseKind)Kind, - Value = value_ is not null ? new ResourceCommandResultViewModel + Result = Result is not null ? new ResourceCommandResultViewModel { - Value = value_.Value, - Format = value_.Format switch + Value = Result.Value, + Format = Result.Format switch { CommandResultFormat.Text => Dashboard.Model.CommandResultFormat.Text, CommandResultFormat.Json => Dashboard.Model.CommandResultFormat.Json, _ => Dashboard.Model.CommandResultFormat.Text }, - DisplayImmediately = value_.DisplayImmediately + DisplayImmediately = Result.DisplayImmediately } : null }; } diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index 1dee58e6e9a..2b7cc55d575 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -147,14 +147,14 @@ public static class CommandResults /// The message associated with the result. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Message = message, Value = new CommandResultData { Value = result, Format = resultFormat } }; + public static ExecuteCommandResult Success(string message, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = true, Message = message, Data = new CommandResultData { Value = result, Format = resultFormat } }; /// /// Produces a success result with a message and a value. /// /// The message associated with the result. /// The value produced by the command. - public static ExecuteCommandResult Success(string message, CommandResultData value) => new() { Success = true, Message = message, Value = value }; + public static ExecuteCommandResult Success(string message, CommandResultData value) => new() { Success = true, Message = message, Data = value }; /// /// Produces an unsuccessful result with an error message. @@ -168,14 +168,14 @@ public static class CommandResults /// The error message. /// The result data. /// The format of the result data. Defaults to . - public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, Message = errorMessage, Value = new CommandResultData { Value = result, Format = resultFormat } }; + public static ExecuteCommandResult Failure(string errorMessage, string result, CommandResultFormat resultFormat = CommandResultFormat.Text) => new() { Success = false, Message = errorMessage, Data = new CommandResultData { Value = result, Format = resultFormat } }; /// /// Produces an unsuccessful result with an error message and a value. /// /// The error message. /// The value produced by the command. - public static ExecuteCommandResult Failure(string errorMessage, CommandResultData value) => new() { Success = false, Message = errorMessage, Value = value }; + public static ExecuteCommandResult Failure(string errorMessage, CommandResultData value) => new() { Success = false, Message = errorMessage, Data = value }; /// /// Produces a canceled result. @@ -229,7 +229,7 @@ public string? Message /// /// An optional value produced by the command. /// - public CommandResultData? Value { get; init; } + public CommandResultData? Data { get; init; } } /// diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs index b696ccf4124..a1a6bd9f9ec 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandService.cs @@ -106,11 +106,11 @@ public async Task ExecuteCommandAsync(IResource resource, if (failures.Count == 0 && cancellations.Count == 0) { - var successWithResult = results.FirstOrDefault(r => r.Value is not null); + var successWithResult = results.FirstOrDefault(r => r.Data is not null); return new ExecuteCommandResult { Success = true, - Value = successWithResult?.Value + Data = successWithResult?.Data }; } else if (failures.Count == 0 && cancellations.Count > 0) diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 6b706291783..cad2b7820d3 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -226,7 +226,7 @@ public async Task ExecuteResourceCommandAsync(Ex ErrorMessage = resolvedMessage, #pragma warning restore CS0618 // Type or member is obsolete Message = resolvedMessage, - Value = result.Value is { } v ? new ExecuteResourceCommandResult + Value = result.Data is { } v ? new ExecuteResourceCommandResult { Value = v.Value, Format = v.Format.ToString().ToLowerInvariant(), diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index be01eb1f8ec..4bf83c33702 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -388,7 +388,7 @@ public override async Task ExecuteResourceCommand(Resou _ => Aspire.DashboardService.Proto.V1.CommandResultFormat.None }; - response.Value = new ResourceCommandResult + response.Result = new ResourceCommandResult { Value = value.Value, Format = MapFormat(value.Format), diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 39077e29b3a..b1f3a6802fd 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -103,7 +103,7 @@ public void Dispose() { return (ExecuteCommandResultType.Canceled, result.Message, null); } - return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.Message, result.Value); + return (result.Success ? ExecuteCommandResultType.Success : ExecuteCommandResultType.Failure, result.Message, result.Data); } catch { diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index cd344efbe33..803b8979c5f 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -93,7 +93,7 @@ message ResourceCommandResponse { ResourceCommandResponseKind kind = 1; optional string error_message = 2 [deprecated = true]; optional string message = 3; - optional ResourceCommandResult value = 4; + optional ResourceCommandResult result = 4; } message ResourceCommandResult { diff --git a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs index f6dd27b6aaa..08223271c9d 100644 --- a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs @@ -82,7 +82,7 @@ public async Task ExecuteGenericCommandAsync_ErrorWithResult_OutputsRawText() ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = false, - ErrorMessage = "Validation failed", + Message = "Validation failed", Value = new ExecuteResourceCommandResult { Value = "{\"errors\": [\"invalid host\"]}", diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index 5bf0d66f414..f987f4d6b3f 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -69,7 +69,7 @@ public async Task ExecuteResourceCommandTool_ReturnsError_WhenCommandFails() ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = false, - ErrorMessage = "Resource not found" + Message = "Resource not found" } }; monitor.AddConnection("hash1", "socket.hash1", connection); diff --git a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs index 462a537090e..f00e9499815 100644 --- a/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceCommandServiceTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; @@ -384,9 +384,9 @@ public async Task ExecuteCommandAsync_SuccessWithResult_ReturnsResultData() var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); Assert.True(result.Success); - Assert.NotNull(result.Value); - Assert.Equal("{\"token\": \"abc123\"}", result.Value.Value); - Assert.Equal(CommandResultFormat.Json, result.Value.Format); + Assert.NotNull(result.Data); + Assert.Equal("{\"token\": \"abc123\"}", result.Data.Value); + Assert.Equal(CommandResultFormat.Json, result.Data.Format); } [Fact] @@ -405,7 +405,7 @@ public async Task ExecuteCommandAsync_SuccessWithoutResult_ReturnsNoResultData() var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "mycommand"); Assert.True(result.Success); - Assert.Null(result.Value); + Assert.Null(result.Data); } [Fact] @@ -433,9 +433,9 @@ public async Task ExecuteCommandAsync_HasReplicas_SuccessWithResult_ReturnsFirst var result = await app.ResourceCommands.ExecuteCommandAsync(custom.Resource, "generate-token"); Assert.True(result.Success); - Assert.NotNull(result.Value); - Assert.StartsWith("token-", result.Value.Value); - Assert.Equal(CommandResultFormat.Text, result.Value.Format); + Assert.NotNull(result.Data); + Assert.StartsWith("token-", result.Data.Value); + Assert.Equal(CommandResultFormat.Text, result.Data.Format); } [Fact] @@ -444,9 +444,9 @@ public void CommandResults_SuccessWithResult_ProducesCorrectResult() var result = CommandResults.Success("Success.", "{\"key\": \"value\"}", CommandResultFormat.Json); Assert.True(result.Success); - Assert.NotNull(result.Value); - Assert.Equal("{\"key\": \"value\"}", result.Value.Value); - Assert.Equal(CommandResultFormat.Json, result.Value.Format); + Assert.NotNull(result.Data); + Assert.Equal("{\"key\": \"value\"}", result.Data.Value); + Assert.Equal(CommandResultFormat.Json, result.Data.Format); } [Fact] @@ -455,9 +455,9 @@ public void CommandResults_SuccessWithTextResult_DefaultsToText() var result = CommandResults.Success("Success.", "hello world"); Assert.True(result.Success); - Assert.NotNull(result.Value); - Assert.Equal("hello world", result.Value.Value); - Assert.Equal(CommandResultFormat.Text, result.Value.Format); + Assert.NotNull(result.Data); + Assert.Equal("hello world", result.Data.Value); + Assert.Equal(CommandResultFormat.Text, result.Data.Format); } private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport From 297f91ed5974123e4a0c06187ed73703547fa7e6 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 21:12:58 +0800 Subject: [PATCH 08/16] Update --- src/Aspire.Dashboard/DashboardWebApplication.cs | 2 +- src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs | 6 ++++-- .../Shared/FluentUISetupHelpers.cs | 2 +- .../Snapshots/TwoPassScanningGeneratedAspire.verified.go | 4 ++-- .../TwoPassScanningGeneratedAspire.verified.java | 8 ++++---- .../Snapshots/TwoPassScanningGeneratedAspire.verified.py | 2 +- .../Snapshots/TwoPassScanningGeneratedAspire.verified.rs | 6 +++--- .../Snapshots/TwoPassScanningGeneratedAspire.verified.ts | 2 +- 8 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index b43cea87c3d..240477f3b05 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -270,7 +270,7 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); + builder.Services.TryAddScoped(); builder.Services.TryAddScoped(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 0979cc71b0f..b0878c2f89f 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -137,14 +137,13 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel }; ResourceCommandResponseViewModel response; - CancellationTokenSource closeToastCts; + var closeToastCts = new CancellationTokenSource(); try { toastService.OnClose += closeCallback; // Show a toast immediately to indicate the command is starting. toastService.ShowCommunicationToast(toastParameters); - closeToastCts = new CancellationTokenSource(); closeToastCts.Token.Register(() => { toastService.CloseToast(toastParameters.Id); @@ -202,6 +201,7 @@ await messageService.ShowMessageBarAsync(options => } progressMessage.Close(); + closeToastCts.Dispose(); return; } else @@ -256,6 +256,8 @@ await messageService.ShowMessageBarAsync(options => // Show toast to display result. toastService.ShowCommunicationToast(toastParameters); + + closeToastCts.Dispose(); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 9e941fb0e95..fc8bdda1e75 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -148,7 +148,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(themeManager ?? new ThemeManager(new TestThemeResolver())); context.Services.AddSingleton(); context.Services.AddSingleton(); - context.Services.AddSingleton(); + context.Services.AddScoped(); context.Services.AddScoped(); context.Services.AddScoped(); context.Services.AddScoped(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index c50fe811d7d..7b340030abc 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -244,7 +244,7 @@ type ExecuteCommandResult struct { Canceled bool `json:"Canceled,omitempty"` ErrorMessage string `json:"ErrorMessage,omitempty"` Message string `json:"Message,omitempty"` - Value *CommandResultData `json:"Value,omitempty"` + Data *CommandResultData `json:"Data,omitempty"` } // ToMap converts the DTO to a map for JSON serialization. @@ -254,7 +254,7 @@ func (d *ExecuteCommandResult) ToMap() map[string]any { "Canceled": SerializeValue(d.Canceled), "ErrorMessage": SerializeValue(d.ErrorMessage), "Message": SerializeValue(d.Message), - "Value": SerializeValue(d.Value), + "Data": SerializeValue(d.Data), } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 31ae2b9be8d..fd6f08f0722 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -9489,7 +9489,7 @@ public class ExecuteCommandResult { private boolean canceled; private String errorMessage; private String message; - private CommandResultData value; + private CommandResultData data; public boolean getSuccess() { return success; } public void setSuccess(boolean value) { this.success = value; } @@ -9499,8 +9499,8 @@ public class ExecuteCommandResult { public void setErrorMessage(String value) { this.errorMessage = value; } public String getMessage() { return message; } public void setMessage(String value) { this.message = value; } - public CommandResultData getValue() { return value; } - public void setValue(CommandResultData value) { this.value = value; } + public CommandResultData getData() { return data; } + public void setData(CommandResultData value) { this.data = value; } public Map toMap() { Map map = new HashMap<>(); @@ -9508,7 +9508,7 @@ public Map toMap() { map.put("Canceled", AspireClient.serializeValue(canceled)); map.put("ErrorMessage", AspireClient.serializeValue(errorMessage)); map.put("Message", AspireClient.serializeValue(message)); - map.put("Value", AspireClient.serializeValue(value)); + map.put("Data", AspireClient.serializeValue(data)); return map; } } diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 560fc0851f7..3fd90aab29c 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1691,7 +1691,7 @@ class ExecuteCommandResult(typing.TypedDict, total=False): Canceled: bool ErrorMessage: str Message: str - Value: CommandResultData + Data: CommandResultData class ResourceEventDto(typing.TypedDict, total=False): ResourceName: str diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index c870e982ea2..1769d0d8909 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -514,8 +514,8 @@ pub struct ExecuteCommandResult { pub error_message: String, #[serde(rename = "Message")] pub message: String, - #[serde(rename = "Value")] - pub value: CommandResultData, + #[serde(rename = "Data")] + pub data: CommandResultData, } impl ExecuteCommandResult { @@ -525,7 +525,7 @@ impl ExecuteCommandResult { map.insert("Canceled".to_string(), serde_json::to_value(&self.canceled).unwrap_or(Value::Null)); map.insert("ErrorMessage".to_string(), serde_json::to_value(&self.error_message).unwrap_or(Value::Null)); map.insert("Message".to_string(), serde_json::to_value(&self.message).unwrap_or(Value::Null)); - map.insert("Value".to_string(), serde_json::to_value(&self.value).unwrap_or(Value::Null)); + map.insert("Data".to_string(), serde_json::to_value(&self.data).unwrap_or(Value::Null)); map } } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index f3b237c968b..deccc70184b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -407,7 +407,7 @@ export interface ExecuteCommandResult { canceled?: boolean; errorMessage?: string; message?: string; - value?: CommandResultData; + data?: CommandResultData; } /** DTO interface for ResourceEventDto */ From 79f478062ac95e603e8b206955f539ad9b54aa6d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 22:01:54 +0800 Subject: [PATCH 09/16] Store notifications in INotificationService and render with custom component - Make INotificationService a singleton that stores notification data (AddNotification, ReplaceNotification, RemoveNotification, ClearAll) - Remove dependency on IMessageService for notification center - Create NotificationEntryComponent styled like FluentMessageBar notification - Update NotificationsDialog to iterate over stored notifications - Update DashboardCommandExecutor to use notification service directly - Fix CancellationTokenSource disposal in toast auto-close logic --- .../Dialogs/NotificationEntryComponent.razor | 38 +++++++++ .../NotificationEntryComponent.razor.cs | 55 +++++++++++++ .../NotificationEntryComponent.razor.css | 81 +++++++++++++++++++ .../Dialogs/NotificationsDialog.razor | 11 ++- .../Dialogs/NotificationsDialog.razor.cs | 21 ++--- .../Dialogs/NotificationsDialog.razor.css | 22 +---- .../DashboardWebApplication.cs | 2 +- .../Model/DashboardCommandExecutor.cs | 80 +++++++----------- .../Model/INotificationService.cs | 61 +++++++++++++- .../Model/NotificationService.cs | 60 +++++++++++++- .../Utils/DashboardUIHelpers.cs | 1 - .../Shared/FluentUISetupHelpers.cs | 2 +- 12 files changed, 342 insertions(+), 92 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.cs create mode 100644 src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor new file mode 100644 index 00000000000..de3f681e441 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor @@ -0,0 +1,38 @@ +@using Aspire.Dashboard.Model + +
+ @* Header *@ +
+ +
+
+ @Entry.Title +
+
+ +
+ + @* Detailed content *@ +
+ @if (!string.IsNullOrEmpty(Entry.Body)) + { + @Entry.Body + } + @if (Entry.PrimaryAction is { } primaryAction) + { + + @primaryAction.Text + + } +
+ + @* Timestamp *@ +
+ @Entry.Timestamp.ToString("T") +
+
diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.cs new file mode 100644 index 00000000000..3ee4f90d268 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.cs @@ -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(); + } + } +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css new file mode 100644 index 00000000000..2bfc527776e --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css @@ -0,0 +1,81 @@ +.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: 8px; + border-top: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); +} + +.notification-entry.intent-info { + fill: #797775; +} + +.notification-entry.intent-warning { + fill: #d83b01; +} + +.notification-entry.intent-error { + fill: #a80000; +} + +.notification-entry.intent-success { + fill: #107c10; +} + +.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; +} + +.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; +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor index 3291e9341ae..74fbbe0895b 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor @@ -4,7 +4,7 @@
- @if (!_hasNotifications) + @if (_notifications.Count == 0) {
@@ -14,12 +14,15 @@ else {
- + @Loc[nameof(Dialogs.NotificationCenterDismissAll)] - +
- + @foreach (var notification in _notifications) + { + + }
}
diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs index 42f74c4442c..c6137f3a0ae 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; -using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; @@ -10,10 +9,7 @@ namespace Aspire.Dashboard.Components.Dialogs; public partial class NotificationsDialog : IDialogContentComponent, IDisposable { - private bool _hasNotifications; - - [Inject] - public required IMessageService MessageService { get; init; } + private IReadOnlyList _notifications = []; [Inject] public required INotificationService NotificationService { get; init; } @@ -23,8 +19,8 @@ public partial class NotificationsDialog : IDialogContentComponent, IDisposable protected override void OnInitialized() { - _hasNotifications = MessageService.Count(DashboardUIHelpers.NotificationSection) > 0; - MessageService.OnMessageItemsUpdated += HandleNotificationsChanged; + _notifications = NotificationService.GetNotifications(); + NotificationService.OnChange += HandleNotificationsChanged; NotificationService.ResetUnreadCount(); } @@ -32,18 +28,23 @@ private void HandleNotificationsChanged() { InvokeAsync(() => { - _hasNotifications = MessageService.Count(DashboardUIHelpers.NotificationSection) > 0; + _notifications = NotificationService.GetNotifications(); StateHasChanged(); }); } private void DismissAll() { - MessageService.Clear(DashboardUIHelpers.NotificationSection); + NotificationService.ClearAll(); + } + + private void Dismiss(string id) + { + NotificationService.RemoveNotification(id); } public void Dispose() { - MessageService.OnMessageItemsUpdated -= HandleNotificationsChanged; + NotificationService.OnChange -= HandleNotificationsChanged; } } diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css index 430adca92d2..1cc4a07cc58 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; height: 100%; - gap: 16px; + gap: 8px; } .notification-center-empty { @@ -26,22 +26,6 @@ min-height: 0; } -.notifications-container ::deep .fluent-messagebar-notification { - grid-template-rows: unset; -} - -.notifications-container ::deep .fluent-messagebar-notification-message { - white-space: unset; -} - -.notifications-container ::deep .fluent-messagebar-notification-content { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - font-size: 12px; -} - -.notifications-container ::deep .fluent-messagebar-notification-time { - color: var(--foreground-subtext-rest); +.notifications-scroll ::deep > :last-child { + border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); } diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 240477f3b05..b43cea87c3d 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -270,7 +270,7 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); - builder.Services.TryAddScoped(); + builder.Services.TryAddSingleton(); builder.Services.TryAddScoped(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index b0878c2f89f..def7645b335 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -16,7 +16,6 @@ public sealed class DashboardCommandExecutor( IDashboardClient dashboardClient, DashboardDialogService dialogService, IToastService toastService, - IMessageService messageService, IStringLocalizer loc, NavigationManager navigationManager, DashboardTelemetryService telemetryService, @@ -100,17 +99,13 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel var messageBarStartingTitle = string.Format(CultureInfo.InvariantCulture, loc[nameof(Dashboard.Resources.Resources.ResourceCommandStarting)], command.GetDisplayName()); var toastStartingTitle = $"{getResourceName(resource)} {messageBarStartingTitle}"; - // Add a message bar to the notification center section for rendering via FluentMessageBarProvider. - var progressMessage = await messageService.ShowMessageBarAsync(options => + // Add a notification to the notification center for the in-progress command. + var progressNotificationId = notificationService.AddNotification(new NotificationEntry { - options.Title = messageBarStartingTitle; - options.Intent = MessageIntent.Info; - options.Section = DashboardUIHelpers.NotificationSection; - options.AllowDismiss = true; - options.Timestamp = DateTime.Now; - }).ConfigureAwait(false); - - notificationService.IncrementUnreadCount(); + Title = messageBarStartingTitle, + Intent = MessageIntent.Info, + Timestamp = DateTime.Now + }); // When a resource command starts a toast is immediately shown. // The toast is open for a certain amount of time and then automatically closed. @@ -171,21 +166,14 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel toastParameters.OnPrimaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); } - progressMessage.Close(); - await messageService.ShowMessageBarAsync(options => + notificationService.ReplaceNotification(progressNotificationId, new NotificationEntry { - options.Title = successTitle; - options.Body = response.Message; - options.Intent = MessageIntent.Success; - options.Section = DashboardUIHelpers.NotificationSection; - options.AllowDismiss = true; - options.Timestamp = DateTime.Now; - - if (response.Result is not null) - { - options.PrimaryAction = CreateViewResponseAction(command, response); - } - }).ConfigureAwait(false); + Title = successTitle, + Body = response.Message, + Intent = MessageIntent.Success, + Timestamp = DateTime.Now, + PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(command, response) : null + }); if (response.Result?.DisplayImmediately == true) { @@ -200,7 +188,7 @@ await messageService.ShowMessageBarAsync(options => toastService.CloseToast(toastParameters.Id); } - progressMessage.Close(); + notificationService.RemoveNotification(progressNotificationId); closeToastCts.Dispose(); return; } @@ -220,21 +208,14 @@ await messageService.ShowMessageBarAsync(options => toastParameters.OnSecondaryAction = EventCallback.Factory.Create(this, () => OpenViewResponseDialogAsync(command, response)); } - progressMessage.Close(); - await messageService.ShowMessageBarAsync(options => + notificationService.ReplaceNotification(progressNotificationId, new NotificationEntry { - options.Title = failedTitle; - options.Body = response.Message; - options.Intent = MessageIntent.Error; - options.Section = DashboardUIHelpers.NotificationSection; - options.AllowDismiss = true; - options.Timestamp = DateTime.Now; - - if (response.Result is not null) - { - options.PrimaryAction = CreateViewResponseAction(command, response); - } - }).ConfigureAwait(false); + Title = failedTitle, + Body = response.Message, + Intent = MessageIntent.Error, + Timestamp = DateTime.Now, + PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(command, response) : null + }); if (response.Result?.DisplayImmediately == true) { @@ -280,23 +261,20 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) }; } - private ActionButton CreateViewResponseAction(CommandViewModel command, ResourceCommandResponseViewModel response) + private NotificationAction CreateViewResponseNotificationAction(CommandViewModel command, ResourceCommandResponseViewModel response) { var fixedFormat = response.Result!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; - return new ActionButton + return new NotificationAction { Text = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)], - OnClick = async _ => + OnClick = () => TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { - await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions - { - DialogService = dialogService, - ValueDescription = command.GetDisplayName(), - Value = response.Result.Value, - FixedFormat = fixedFormat - }).ConfigureAwait(false); - } + DialogService = dialogService, + ValueDescription = command.GetDisplayName(), + Value = response.Result.Value, + FixedFormat = fixedFormat + }) }; } diff --git a/src/Aspire.Dashboard/Model/INotificationService.cs b/src/Aspire.Dashboard/Model/INotificationService.cs index ff7a83db547..aa8793207d1 100644 --- a/src/Aspire.Dashboard/Model/INotificationService.cs +++ b/src/Aspire.Dashboard/Model/INotificationService.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.FluentUI.AspNetCore.Components; + namespace Aspire.Dashboard.Model; /// -/// Tracks the unread notification count for the dashboard notification center. +/// Stores notifications for the dashboard notification center. /// Implementations must be thread-safe as the service is registered as a singleton /// and accessed from multiple Blazor circuits. /// @@ -16,9 +18,30 @@ public interface INotificationService int UnreadCount { get; } /// - /// Increments the unread count by one and raises . + /// Gets a snapshot of the current notifications, most recent first. + /// + IReadOnlyList GetNotifications(); + + /// + /// Adds a notification and raises . + /// + /// The ID of the added notification, which can be used to replace it later. + string AddNotification(NotificationEntry notification); + + /// + /// Replaces an existing notification (matched by ) and raises . /// - void IncrementUnreadCount(); + void ReplaceNotification(string id, NotificationEntry notification); + + /// + /// Removes a notification by ID and raises . + /// + void RemoveNotification(string id); + + /// + /// Removes all notifications and raises . + /// + void ClearAll(); /// /// Resets the unread count to zero and raises . @@ -26,7 +49,37 @@ public interface INotificationService void ResetUnreadCount(); /// - /// Raised when the unread count changes. + /// Raised when notifications or the unread count change. /// event Action? OnChange; } + +/// +/// Represents a single notification in the notification center. +/// +public sealed class NotificationEntry +{ + public required string Title { get; init; } + public string? Body { get; init; } + public required MessageIntent Intent { get; init; } + public DateTime Timestamp { get; init; } = DateTime.Now; + public NotificationAction? PrimaryAction { get; init; } +} + +/// +/// An action button displayed on a notification. +/// +public sealed class NotificationAction +{ + public required string Text { get; init; } + public required Func OnClick { get; init; } +} + +/// +/// A notification with its service-assigned ID. +/// +public sealed class NotificationMessage +{ + public required string Id { get; init; } + public required NotificationEntry Entry { get; init; } +} diff --git a/src/Aspire.Dashboard/Model/NotificationService.cs b/src/Aspire.Dashboard/Model/NotificationService.cs index eb8e0644bfa..8198168845d 100644 --- a/src/Aspire.Dashboard/Model/NotificationService.cs +++ b/src/Aspire.Dashboard/Model/NotificationService.cs @@ -9,6 +9,7 @@ namespace Aspire.Dashboard.Model; internal sealed class NotificationService : INotificationService { private readonly object _lock = new(); + private readonly List<(string Id, NotificationEntry Entry)> _notifications = []; private int _unreadCount; public int UnreadCount @@ -24,13 +25,70 @@ public int UnreadCount public event Action? OnChange; - public void IncrementUnreadCount() + public IReadOnlyList GetNotifications() { lock (_lock) { + // Return a snapshot, most recent first. + var result = new NotificationMessage[_notifications.Count]; + for (var i = 0; i < _notifications.Count; i++) + { + var item = _notifications[_notifications.Count - 1 - i]; + result[i] = new NotificationMessage { Id = item.Id, Entry = item.Entry }; + } + + return result; + } + } + + public string AddNotification(NotificationEntry notification) + { + var id = Guid.NewGuid().ToString("N"); + lock (_lock) + { + _notifications.Add((id, notification)); _unreadCount++; } + OnChange?.Invoke(); + return id; + } + + public void ReplaceNotification(string id, NotificationEntry notification) + { + lock (_lock) + { + for (var i = 0; i < _notifications.Count; i++) + { + if (_notifications[i].Id == id) + { + _notifications[i] = (id, notification); + break; + } + } + } + + OnChange?.Invoke(); + } + + public void RemoveNotification(string id) + { + lock (_lock) + { + _notifications.RemoveAll(n => n.Id == id); + } + + OnChange?.Invoke(); + } + + public void ClearAll() + { + lock (_lock) + { + _notifications.Clear(); + _unreadCount = 0; + } + OnChange?.Invoke(); } diff --git a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs index c68e2d503a7..bb3852962ce 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs +++ b/src/Aspire.Dashboard/Utils/DashboardUIHelpers.cs @@ -14,7 +14,6 @@ namespace Aspire.Dashboard.Utils; internal static class DashboardUIHelpers { public const string MessageBarSection = "MessagesTop"; - public const string NotificationSection = "MessagesNotifications"; // these are language names supported by highlight.js public const string XmlFormat = "xml"; diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index fc8bdda1e75..9e941fb0e95 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -148,7 +148,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(themeManager ?? new ThemeManager(new TestThemeResolver())); context.Services.AddSingleton(); context.Services.AddSingleton(); - context.Services.AddScoped(); + context.Services.AddSingleton(); context.Services.AddScoped(); context.Services.AddScoped(); context.Services.AddScoped(); From a4d5287b74f8c3c4dc64010c3c97fccf30ca4d14 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 6 Apr 2026 23:08:07 +0800 Subject: [PATCH 10/16] Update --- playground/Stress/Stress.AppHost/AppHost.cs | 37 +++++++++++++++++-- .../Commands/ResourceCommandHelper.cs | 16 +++++++- .../Interaction/ConsoleInteractionService.cs | 6 ++- .../ExtensionInteractionService.cs | 4 +- .../Interaction/IInteractionService.cs | 2 +- .../Dialogs/TextVisualizerDialog.razor | 11 +++++- .../Dialogs/TextVisualizerDialog.razor.cs | 16 +++++++- .../Dialogs/TextVisualizerDialog.razor.css | 6 +++ .../Model/DashboardCommandExecutor.cs | 14 ++++++- .../Model/ResourceCommandResponseViewModel.cs | 7 +++- .../Model/TextVisualizerViewModel.cs | 4 ++ .../Resources/Dialogs.Designer.cs | 9 +++++ src/Aspire.Dashboard/Resources/Dialogs.resx | 3 ++ .../Resources/xlf/Dialogs.cs.xlf | 5 +++ .../Resources/xlf/Dialogs.de.xlf | 5 +++ .../Resources/xlf/Dialogs.es.xlf | 5 +++ .../Resources/xlf/Dialogs.fr.xlf | 5 +++ .../Resources/xlf/Dialogs.it.xlf | 5 +++ .../Resources/xlf/Dialogs.ja.xlf | 5 +++ .../Resources/xlf/Dialogs.ko.xlf | 5 +++ .../Resources/xlf/Dialogs.pl.xlf | 5 +++ .../Resources/xlf/Dialogs.pt-BR.xlf | 5 +++ .../Resources/xlf/Dialogs.ru.xlf | 5 +++ .../Resources/xlf/Dialogs.tr.xlf | 5 +++ .../Resources/xlf/Dialogs.zh-Hans.xlf | 5 +++ .../Resources/xlf/Dialogs.zh-Hant.xlf | 5 +++ .../ServiceClient/Partials.cs | 1 + .../ResourceCommandAnnotation.cs | 7 +++- .../Dashboard/DashboardService.cs | 1 + .../Dashboard/proto/dashboard_service.proto | 1 + .../Commands/NewCommandTests.cs | 2 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 2 +- .../Projects/ExtensionGuestLauncherTests.cs | 2 +- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- .../TestExtensionInteractionService.cs | 2 +- .../TestServices/TestInteractionService.cs | 2 +- 37 files changed, 201 insertions(+), 23 deletions(-) diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index b165f3aae8d..2caa4b6dc35 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -140,7 +140,12 @@ issuedAt = DateTime.UtcNow }; var json = JsonSerializer.Serialize(token, new JsonSerializerOptions { WriteIndented = true }); - return Task.FromResult(CommandResults.Success("Generated token.", new CommandResultData { Value = json, Format = 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( @@ -149,7 +154,7 @@ executeCommand: (c) => { var connectionString = $"Server=localhost,1433;Database=StressDb;User Id=sa;Password={Guid.NewGuid():N};TrustServerCertificate=true"; - return Task.FromResult(CommandResults.Success("Retrieved connection string.", connectionString)); + return Task.FromResult(CommandResults.Success("Retrieved connection string.", new CommandResultData { Value = connectionString, DisplayImmediately = true })); }, commandOptions: new() { IconName = "LinkMultiple", Description = "Get the connection string for this resource" }) .WithCommand( @@ -169,7 +174,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) => + { + 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 diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs index c07095e879b..7147177d86e 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -85,7 +85,7 @@ public static async Task ExecuteGenericCommandAsync( if (response.Value is not null) { - interactionService.DisplayRawText(response.Value.Value, ConsoleOutput.Standard); + DisplayCommandResult(interactionService, response.Value); } return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; @@ -118,12 +118,24 @@ private static int HandleResponse( if (response.Value is not null) { - interactionService.DisplayRawText(response.Value.Value, ConsoleOutput.Standard); + DisplayCommandResult(interactionService, response.Value); } return response.Success ? ExitCodeConstants.Success : ExitCodeConstants.FailedToExecuteResourceCommand; } + private static void DisplayCommandResult(IInteractionService interactionService, ExecuteResourceCommandResult result) + { + if (string.Equals(result.Format, "markdown", StringComparison.OrdinalIgnoreCase)) + { + 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; diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 8b39ccbc961..52eb221ae8e 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -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) diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index c6060ec1496..de655d5e75d 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -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) diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 0b2711e4adb..a851a2a5bea 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -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); diff --git a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor index a62c4fdde0f..458820cbc36 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor @@ -35,7 +35,16 @@
@if (IsTextContentDisplayed) { - + if (IsMarkdownFormat) + { +
+ +
+ } + else + { + + } } else if (ShowSecretsWarning is true) { diff --git a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs index e90dd7743d4..5df43a8c0ac 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Markdown; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; @@ -18,10 +19,12 @@ public partial class TextVisualizerDialog : ComponentBase private List> _options = null!; private string? _selectedFormat; private bool _isLoading = true; + private MarkdownProcessor? _markdownProcessor; internal TextVisualizerViewModel TextVisualizerViewModel { get; set; } = default!; public HashSet EnabledOptions { get; } = []; internal bool? ShowSecretsWarning { get; private set; } + internal bool IsMarkdownFormat => TextVisualizerViewModel.FormatKind == DashboardUIHelpers.MarkdownFormat; /// /// Returns true if the dialog has a fixed format that cannot be changed by the user. @@ -60,13 +63,19 @@ protected override void OnParametersSet() _options = [ new SelectViewModel { Id = DashboardUIHelpers.PlaintextFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogPlaintextFormat)] }, new SelectViewModel { Id = DashboardUIHelpers.JsonFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogJsonFormat)] }, - new SelectViewModel { Id = DashboardUIHelpers.XmlFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogXmlFormat)] } + new SelectViewModel { Id = DashboardUIHelpers.XmlFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogXmlFormat)] }, + new SelectViewModel { Id = DashboardUIHelpers.MarkdownFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogMarkdownFormat)] } ]; // If a fixed format is specified, use it directly without auto-detection. if (Content.FixedFormat is not null) { TextVisualizerViewModel = new TextVisualizerViewModel(Content.Text, indentText: true, Content.FixedFormat); + + if (Content.FixedFormat == DashboardUIHelpers.MarkdownFormat) + { + EnabledOptions.Add(DashboardUIHelpers.MarkdownFormat); + } } else { @@ -104,6 +113,11 @@ public void ChangeFormat(string? newFormat, string? text) TextVisualizerViewModel.UpdateFormat(newFormat ?? DashboardUIHelpers.PlaintextFormat); } + internal MarkdownProcessor GetMarkdownProcessor() + { + return _markdownProcessor ??= new MarkdownProcessor(ControlsStringsLoc, safeUrlSchemes: MarkdownHelpers.SafeUrlSchemes, extensions: []); + } + public static async Task OpenDialogAsync(OpenTextVisualizerDialogOptions options) { var width = options.DialogService.IsDesktop ? "75vw" : "100vw"; diff --git a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.css index d2928542350..ffdb9e26894 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.css @@ -34,3 +34,9 @@ max-height: 60vh; margin-bottom: 16px; } + +.markdown-content { + max-height: 60vh; + overflow-y: auto; + margin-bottom: 16px; +} diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index def7645b335..25c8f900e9e 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -263,7 +263,12 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) private NotificationAction CreateViewResponseNotificationAction(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.Result!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Result!.Format switch + { + CommandResultFormat.Json => DashboardUIHelpers.JsonFormat, + CommandResultFormat.Markdown => DashboardUIHelpers.MarkdownFormat, + _ => null + }; return new NotificationAction { @@ -280,7 +285,12 @@ private NotificationAction CreateViewResponseNotificationAction(CommandViewModel private async Task OpenViewResponseDialogAsync(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.Result!.Format == CommandResultFormat.Json ? DashboardUIHelpers.JsonFormat : null; + var fixedFormat = response.Result!.Format switch + { + CommandResultFormat.Json => DashboardUIHelpers.JsonFormat, + CommandResultFormat.Markdown => DashboardUIHelpers.MarkdownFormat, + _ => null + }; await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { diff --git a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs index e4919adf1d7..fa7be2f159a 100644 --- a/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceCommandResponseViewModel.cs @@ -43,5 +43,10 @@ public enum CommandResultFormat /// /// JSON result. /// - Json + Json, + + /// + /// Markdown result. + /// + Markdown } diff --git a/src/Aspire.Dashboard/Model/TextVisualizerViewModel.cs b/src/Aspire.Dashboard/Model/TextVisualizerViewModel.cs index 7b54614af07..884b835a9a4 100644 --- a/src/Aspire.Dashboard/Model/TextVisualizerViewModel.cs +++ b/src/Aspire.Dashboard/Model/TextVisualizerViewModel.cs @@ -271,6 +271,10 @@ internal void UpdateFormat(string newFormat) ChangeFormattedText(newFormat, formattedJson); } } + else if (newFormat == DashboardUIHelpers.MarkdownFormat) + { + ChangeFormattedText(newFormat, Text); + } else { ChangeFormattedText(DashboardUIHelpers.PlaintextFormat, Text); diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index d1f70772c96..4adf69bd60c 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1068,6 +1068,15 @@ public static string TextVisualizerDialogJsonFormat { } } + /// + /// Looks up a localized string similar to Markdown. + /// + public static string TextVisualizerDialogMarkdownFormat { + get { + return ResourceManager.GetString("TextVisualizerDialogMarkdownFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unformatted. /// diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index a40b3fb6856..7e4b23c2bfc 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -251,6 +251,9 @@ Format XML + + Markdown + Select format diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index ce144174389..4ec970c424c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -592,6 +592,11 @@ Formátovat JSON + + Markdown + Markdown + + Unformatted Neformátováno diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 364a974a73f..11c1d2f2e0f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -592,6 +592,11 @@ JSON formatieren + + Markdown + Markdown + + Unformatted Unformatiert diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 511844ee29f..e1db819a8b0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -592,6 +592,11 @@ Formato JSON + + Markdown + Markdown + + Unformatted Sin formato diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index 5c3d2124f2b..efedff78dd6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -592,6 +592,11 @@ Format JSON + + Markdown + Markdown + + Unformatted Non formaté diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index 2cbbee0bfa1..c6f1b0745b6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -592,6 +592,11 @@ Formato JSON + + Markdown + Markdown + + Unformatted Non formattato diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index 5171b6e9d7b..9eaa8ca61ef 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -592,6 +592,11 @@ JSON の書式設定 + + Markdown + Markdown + + Unformatted 書式設定なし diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index fc0b9e6fefe..ac7e298569a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -592,6 +592,11 @@ JSON 형식 지정 + + Markdown + Markdown + + Unformatted 형식 없음 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index e6bd47f785b..0308f9a3b8c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -592,6 +592,11 @@ Formatuj kod JSON + + Markdown + Markdown + + Unformatted Niesformatowano diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index aa1f3d99f87..ae1198e203e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -592,6 +592,11 @@ Formatar JSON + + Markdown + Markdown + + Unformatted Não formatado diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index fc433589ea3..ea7c5ff1b49 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -592,6 +592,11 @@ Формат JSON + + Markdown + Markdown + + Unformatted Без форматирования diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 8ad8b9c7cf1..37771e7ebcc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -592,6 +592,11 @@ JSON dosyasını biçimlendir + + Markdown + Markdown + + Unformatted Biçimlendirilmemiş diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index 2e028ecd46f..ab372e255fa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -592,6 +592,11 @@ 格式化 JSON + + Markdown + Markdown + + Unformatted 未设置格式 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 5a56ffbff24..bfc886ac20e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -592,6 +592,11 @@ 格式化 JSON + + Markdown + Markdown + + Unformatted 未格式化 diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index a06053decf9..94dade57cc9 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -213,6 +213,7 @@ public ResourceCommandResponseViewModel ToViewModel() { CommandResultFormat.Text => Dashboard.Model.CommandResultFormat.Text, CommandResultFormat.Json => Dashboard.Model.CommandResultFormat.Json, + CommandResultFormat.Markdown => Dashboard.Model.CommandResultFormat.Markdown, _ => Dashboard.Model.CommandResultFormat.Text }, DisplayImmediately = Result.DisplayImmediately diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs index 2b7cc55d575..90e2f5bac0f 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceCommandAnnotation.cs @@ -128,7 +128,12 @@ public enum CommandResultFormat /// /// JSON result. /// - Json + Json, + + /// + /// Markdown result. + /// + Markdown } /// diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index 4bf83c33702..54f5b1591e2 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -385,6 +385,7 @@ public override async Task ExecuteResourceCommand(Resou { ApplicationModel.CommandResultFormat.Text => Aspire.DashboardService.Proto.V1.CommandResultFormat.Text, ApplicationModel.CommandResultFormat.Json => Aspire.DashboardService.Proto.V1.CommandResultFormat.Json, + ApplicationModel.CommandResultFormat.Markdown => Aspire.DashboardService.Proto.V1.CommandResultFormat.Markdown, _ => Aspire.DashboardService.Proto.V1.CommandResultFormat.None }; diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index 803b8979c5f..23ceff6e0b4 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -106,6 +106,7 @@ enum CommandResultFormat { COMMAND_RESULT_FORMAT_NONE = 0; COMMAND_RESULT_FORMAT_TEXT = 1; COMMAND_RESULT_FORMAT_JSON = 2; + COMMAND_RESULT_FORMAT_MARKDOWN = 3; } //////////////////////////////////////////// diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index cccc8c50408..78fdd454e5c 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -1776,7 +1776,7 @@ public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkupLine(string markup) { } public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { } public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 032e55cece1..9be6f55cfdf 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -958,7 +958,7 @@ public void DisplayCancellationMessage() { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkupLine(string markup) { } public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) { } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 8872ea4d474..6f622c9edc4 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -1040,7 +1040,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) => _innerService.DisplayMessage(emoji, message, allowMarkup); public void DisplayPlainText(string text) => _innerService.DisplayPlainText(text); public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) => _innerService.DisplayRawText(text, consoleOverride); - public void DisplayMarkdown(string markdown) => _innerService.DisplayMarkdown(markdown); + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) => _innerService.DisplayMarkdown(markdown, consoleOverride); public void DisplayMarkupLine(string markup) => _innerService.DisplayMarkupLine(markup); public void DisplaySuccess(string message, bool allowMarkup = false) => _innerService.DisplaySuccess(message, allowMarkup); public void DisplaySubtleMessage(string message, bool allowMarkup = false) => _innerService.DisplaySubtleMessage(message, allowMarkup); diff --git a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs index 52670fb8571..851e04daec2 100644 --- a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs @@ -173,7 +173,7 @@ public Task LaunchAppHostAsync(string projectFile, List arguments, List< public void DisplayEmptyLine() => throw new NotImplementedException(); public void DisplayPlainText(string text) => throw new NotImplementedException(); public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) => throw new NotImplementedException(); - public void DisplayMarkdown(string markdown) => throw new NotImplementedException(); + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) => throw new NotImplementedException(); public void DisplayMarkupLine(string markup) => throw new NotImplementedException(); public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) => throw new NotImplementedException(); public void DisplayRenderable(Spectre.Console.Rendering.IRenderable renderable) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 45e050dafb2..62c91aa0b90 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -486,7 +486,7 @@ public void DisplayCancellationMessage() { } public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; public void DisplayPlainText(string text) { } public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) { } + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkupLine(string markup) { } public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } public void DisplayEmptyLine() { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index a27e9ae7c6c..0fd0a1221dd 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -143,7 +143,7 @@ public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } - public void DisplayMarkdown(string markdown) + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index 7b3849549f2..4071aa826b4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -217,7 +217,7 @@ public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) DisplayRawTextCallback?.Invoke(text); } - public void DisplayMarkdown(string markdown) + public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null) { } From 2407c85e035da5ea15daab90688527a327f6f529 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 7 Apr 2026 08:34:58 +0800 Subject: [PATCH 11/16] Improve notification center: use DateTimeOffset/TimeProvider, add size limit, accessibility, and CSS variables --- .../Dialogs/NotificationEntryComponent.razor | 15 ++++++++++----- .../Dialogs/NotificationEntryComponent.razor.css | 10 +++++----- src/Aspire.Dashboard/DashboardWebApplication.cs | 1 + .../Model/DashboardCommandExecutor.cs | 6 +++--- .../Model/INotificationService.cs | 2 +- src/Aspire.Dashboard/Model/NotificationService.cs | 12 +++++++++++- .../Resources/Dialogs.Designer.cs | 9 +++++++++ src/Aspire.Dashboard/Resources/Dialogs.resx | 3 +++ src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf | 5 +++++ .../Resources/xlf/Dialogs.pt-BR.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf | 5 +++++ src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf | 5 +++++ .../Resources/xlf/Dialogs.zh-Hans.xlf | 5 +++++ .../Resources/xlf/Dialogs.zh-Hant.xlf | 5 +++++ 21 files changed, 108 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor index de3f681e441..6f4a3e5e655 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor @@ -1,4 +1,8 @@ @using Aspire.Dashboard.Model +@using Aspire.Dashboard.Resources +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inject TimeProvider TimeProvider +@inject IStringLocalizer Loc
@* Header *@ @@ -9,10 +13,11 @@ @Entry.Title
- + + +
@* Detailed content *@ @@ -33,6 +38,6 @@ @* Timestamp *@
- @Entry.Timestamp.ToString("T") + @((TimeProvider.GetUtcNow() - Entry.Timestamp).ToTimeAgo())
diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css index 2bfc527776e..74398598365 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css @@ -6,24 +6,24 @@ width: 100%; min-height: 36px; padding: 0 8px; - column-gap: 8px; + column-gap: 4px; border-top: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); } .notification-entry.intent-info { - fill: #797775; + fill: var(--info); } .notification-entry.intent-warning { - fill: #d83b01; + fill: var(--warning); } .notification-entry.intent-error { - fill: #a80000; + fill: var(--error); } .notification-entry.intent-success { - fill: #107c10; + fill: var(--success); } .notification-entry-icon { diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index b43cea87c3d..d80e031cd98 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -271,6 +271,7 @@ public DashboardWebApplication( builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.TryAddScoped(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index 25c8f900e9e..bd53e05563e 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -104,7 +104,6 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel { Title = messageBarStartingTitle, Intent = MessageIntent.Info, - Timestamp = DateTime.Now }); // When a resource command starts a toast is immediately shown. @@ -132,6 +131,9 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel }; ResourceCommandResponseViewModel response; + // The CTS intentionally outlives the command execution to ensure we can close the toast in all scenarios + // e.g., even if the command execution fails or the toast is still open when the command finishes. + // It's ok to let it be cleaned up by GC when the short CancelAfter completes. var closeToastCts = new CancellationTokenSource(); try { @@ -171,7 +173,6 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel Title = successTitle, Body = response.Message, Intent = MessageIntent.Success, - Timestamp = DateTime.Now, PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(command, response) : null }); @@ -213,7 +214,6 @@ public async Task ExecuteAsyncCore(ResourceViewModel resource, CommandViewModel Title = failedTitle, Body = response.Message, Intent = MessageIntent.Error, - Timestamp = DateTime.Now, PrimaryAction = response.Result is not null ? CreateViewResponseNotificationAction(command, response) : null }); diff --git a/src/Aspire.Dashboard/Model/INotificationService.cs b/src/Aspire.Dashboard/Model/INotificationService.cs index aa8793207d1..408b7d589df 100644 --- a/src/Aspire.Dashboard/Model/INotificationService.cs +++ b/src/Aspire.Dashboard/Model/INotificationService.cs @@ -62,7 +62,7 @@ public sealed class NotificationEntry public required string Title { get; init; } public string? Body { get; init; } public required MessageIntent Intent { get; init; } - public DateTime Timestamp { get; init; } = DateTime.Now; + public DateTimeOffset Timestamp { get; set; } public NotificationAction? PrimaryAction { get; init; } } diff --git a/src/Aspire.Dashboard/Model/NotificationService.cs b/src/Aspire.Dashboard/Model/NotificationService.cs index 8198168845d..0c96c4ff00d 100644 --- a/src/Aspire.Dashboard/Model/NotificationService.cs +++ b/src/Aspire.Dashboard/Model/NotificationService.cs @@ -6,8 +6,10 @@ namespace Aspire.Dashboard.Model; /// /// Thread-safe singleton implementation of . /// -internal sealed class NotificationService : INotificationService +internal sealed class NotificationService(TimeProvider timeProvider) : INotificationService { + private const int MaxNotifications = 100; + private readonly object _lock = new(); private readonly List<(string Id, NotificationEntry Entry)> _notifications = []; private int _unreadCount; @@ -43,11 +45,18 @@ public IReadOnlyList GetNotifications() public string AddNotification(NotificationEntry notification) { + notification.Timestamp = timeProvider.GetUtcNow(); var id = Guid.NewGuid().ToString("N"); lock (_lock) { _notifications.Add((id, notification)); _unreadCount++; + + // Remove oldest notifications when the limit is exceeded. + while (_notifications.Count > MaxNotifications) + { + _notifications.RemoveAt(0); + } } OnChange?.Invoke(); @@ -56,6 +65,7 @@ public string AddNotification(NotificationEntry notification) public void ReplaceNotification(string id, NotificationEntry notification) { + notification.Timestamp = timeProvider.GetUtcNow(); lock (_lock) { for (var i = 0; i < _notifications.Count; i++) diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index 4adf69bd60c..f02606d08d9 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1184,5 +1184,14 @@ public static string NotificationCenterEmpty { return ResourceManager.GetString("NotificationCenterEmpty", resourceCulture); } } + + /// + /// Looks up a localized string similar to Dismiss notification. + /// + public static string NotificationEntryDismiss { + get { + return ResourceManager.GetString("NotificationEntryDismiss", resourceCulture); + } + } } } diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 7e4b23c2bfc..4658c1b73eb 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -501,4 +501,7 @@ No notifications + + Dismiss notification + diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 4ec970c424c..8dd41a7467a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Otevřít ve vizualizéru textu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 11c1d2f2e0f..94d3b89b542 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer In Textschnellansicht öffnen diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index e1db819a8b0..62eb65861e8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Abrir en visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index efedff78dd6..22fef00b397 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Ouvrir dans le visualiseur de texte diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index c6f1b0745b6..2ce25fb058d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Apri nel visualizzatore di testo diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index 9eaa8ca61ef..6e667dffe6d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer テキスト ビジュアライザーで開く diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index ac7e298569a..37c9e9e7494 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer 텍스트 시각화 도우미에서 열기 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 0308f9a3b8c..9e3eddf57e9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Otwórz w wizualizatorze tekstu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index ae1198e203e..30ece21ea33 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Abrir no visualizador de texto diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index ea7c5ff1b49..f8a34f1dd1d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Открыть в визуализаторе текста diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 37771e7ebcc..7043a0dd6b5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer Metin görselleştiricide aç diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index ab372e255fa..8edb590a2a9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer 在文本可视化工具中打开 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index bfc886ac20e..69b5ccc16c7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -467,6 +467,11 @@ No notifications + + Dismiss notification + Dismiss notification + + Open in text visualizer 在文字視覺化工具中開啟 From f35039868b13156c06f499b01940e60092f1e399 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 7 Apr 2026 09:14:38 +0800 Subject: [PATCH 12/16] Deduplicate format-mapping in DashboardCommandExecutor, fix async void handlers, fix test DI --- .../Dialogs/NotificationsDialog.razor.cs | 2 +- .../Layout/NotificationsHeaderButton.razor.cs | 2 +- .../Model/DashboardCommandExecutor.cs | 15 +-------------- .../Shared/FluentUISetupHelpers.cs | 1 + 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs index c6137f3a0ae..d766b7fcf02 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationsDialog.razor.cs @@ -26,7 +26,7 @@ protected override void OnInitialized() private void HandleNotificationsChanged() { - InvokeAsync(() => + _ = InvokeAsync(() => { _notifications = NotificationService.GetNotifications(); StateHasChanged(); diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs index 20e0aac65b4..2b47c715745 100644 --- a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs @@ -33,7 +33,7 @@ private async Task HandleClick() private void HandleNotificationsChanged() { - InvokeAsync(StateHasChanged); + _ = InvokeAsync(StateHasChanged); } public void Dispose() diff --git a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs index bd53e05563e..1273e86f56f 100644 --- a/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs +++ b/src/Aspire.Dashboard/Model/DashboardCommandExecutor.cs @@ -263,23 +263,10 @@ private static (Icon Icon, Color Color)? GetIntentIcon(ToastIntent intent) private NotificationAction CreateViewResponseNotificationAction(CommandViewModel command, ResourceCommandResponseViewModel response) { - var fixedFormat = response.Result!.Format switch - { - CommandResultFormat.Json => DashboardUIHelpers.JsonFormat, - CommandResultFormat.Markdown => DashboardUIHelpers.MarkdownFormat, - _ => null - }; - return new NotificationAction { Text = loc[nameof(Dashboard.Resources.Resources.ResourceCommandViewResponse)], - OnClick = () => TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions - { - DialogService = dialogService, - ValueDescription = command.GetDisplayName(), - Value = response.Result.Value, - FixedFormat = fixedFormat - }) + OnClick = () => OpenViewResponseDialogAsync(command, response) }; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 9e941fb0e95..5a8bee75f9f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -148,6 +148,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(themeManager ?? new ThemeManager(new TestThemeResolver())); context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(TimeProvider.System); context.Services.AddSingleton(); context.Services.AddScoped(); context.Services.AddScoped(); From 356a6397c4a1cba9f1b601d1e145de685b0b0496 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 7 Apr 2026 09:34:53 +0800 Subject: [PATCH 13/16] Accept updated CodeGeneration verify snapshots --- .../Snapshots/TwoPassScanningGeneratedAspire.verified.go | 1 + .../Snapshots/TwoPassScanningGeneratedAspire.verified.java | 3 ++- .../Snapshots/TwoPassScanningGeneratedAspire.verified.py | 2 +- .../Snapshots/TwoPassScanningGeneratedAspire.verified.rs | 3 +++ .../Snapshots/TwoPassScanningGeneratedAspire.verified.ts | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 7b340030abc..e2c4d4873aa 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -133,6 +133,7 @@ type CommandResultFormat string const ( CommandResultFormatText CommandResultFormat = "Text" CommandResultFormatJson CommandResultFormat = "Json" + CommandResultFormatMarkdown CommandResultFormat = "Markdown" ) // UrlDisplayLocation represents UrlDisplayLocation. diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index fd6f08f0722..6a100c68e05 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -2875,7 +2875,8 @@ public Map toMap() { /** CommandResultFormat enum. */ public enum CommandResultFormat implements WireValueEnum { TEXT("Text"), - JSON("Json"); + JSON("Json"), + MARKDOWN("Markdown"); private final String value; diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 3fd90aab29c..6b7c8614357 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1496,7 +1496,7 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: CertificateTrustScope = typing.Literal["None", "Append", "Override", "System"] -CommandResultFormat = typing.Literal["Text", "Json"] +CommandResultFormat = typing.Literal["Text", "Json", "Markdown"] ContainerLifetime = typing.Literal["Session", "Persistent"] diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 1769d0d8909..e25cea84082 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -323,6 +323,8 @@ pub enum CommandResultFormat { Text, #[serde(rename = "Json")] Json, + #[serde(rename = "Markdown")] + Markdown, } impl std::fmt::Display for CommandResultFormat { @@ -330,6 +332,7 @@ impl std::fmt::Display for CommandResultFormat { match self { Self::Text => write!(f, "Text"), Self::Json => write!(f, "Json"), + Self::Markdown => write!(f, "Markdown"), } } } diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index deccc70184b..198f302fe4d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -257,6 +257,7 @@ export enum CertificateTrustScope { export enum CommandResultFormat { Text = "Text", Json = "Json", + Markdown = "Markdown", } /** Enum type for ContainerLifetime */ From 0a23e40ddc4586723002373ada90ed0b24660598 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 8 Apr 2026 11:49:51 +0800 Subject: [PATCH 14/16] Change backchannel CommandResultFormat from string to enum and add stress test commands --- playground/Stress/Stress.AppHost/AppHost.cs | 13 +++++++- .../Commands/ResourceCommandHelper.cs | 2 +- .../NotificationEntryComponent.razor.css | 1 + .../AuxiliaryBackchannelRpcTarget.cs | 7 ++++- .../Backchannel/BackchannelDataTypes.cs | 30 +++++++++++++++++-- .../Commands/ResourceCommandHelperTests.cs | 6 ++-- .../Mcp/ExecuteResourceCommandToolTests.cs | 2 +- 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index 2caa4b6dc35..40da074fa3b 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -154,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("Retrieved connection string.", new CommandResultData { Value = connectionString, DisplayImmediately = true })); + 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( diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs index 7147177d86e..66b019ec436 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -126,7 +126,7 @@ private static int HandleResponse( private static void DisplayCommandResult(IInteractionService interactionService, ExecuteResourceCommandResult result) { - if (string.Equals(result.Format, "markdown", StringComparison.OrdinalIgnoreCase)) + if (result.Format is CommandResultFormat.Markdown) { interactionService.DisplayMarkdown(result.Value, ConsoleOutput.Standard); } diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css index 74398598365..57e1eec5b1c 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor.css @@ -64,6 +64,7 @@ align-items: flex-start; gap: 8px; font-size: 12px; + word-break: break-word; } .notification-entry-time { diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index cad2b7820d3..9b642300c6c 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -229,7 +229,12 @@ public async Task ExecuteResourceCommandAsync(Ex Value = result.Data is { } v ? new ExecuteResourceCommandResult { Value = v.Value, - Format = v.Format.ToString().ToLowerInvariant(), + Format = v.Format switch + { + ApplicationModel.CommandResultFormat.Json => CommandResultFormat.Json, + ApplicationModel.CommandResultFormat.Markdown => CommandResultFormat.Markdown, + _ => CommandResultFormat.Text + }, DisplayImmediately = v.DisplayImmediately } : null }; diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index e24d320a9b5..350906a0988 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -11,6 +11,7 @@ namespace Aspire.Hosting.Backchannel; using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -309,9 +310,9 @@ internal sealed class ExecuteResourceCommandResult public required string Value { get; init; } /// - /// Gets the format of the value data (e.g. "text", "json"). + /// Gets the format of the value data. /// - public string? Format { get; init; } + public CommandResultFormat Format { get; init; } /// /// Gets whether to immediately display the value in the dashboard. @@ -319,6 +320,31 @@ internal sealed class ExecuteResourceCommandResult public bool DisplayImmediately { get; init; } } +/// +/// Specifies the format of a command result. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum CommandResultFormat +{ + /// + /// Plain text result. + /// + [JsonStringEnumMemberName("text")] + Text, + + /// + /// JSON result. + /// + [JsonStringEnumMemberName("json")] + Json, + + /// + /// Markdown result. + /// + [JsonStringEnumMemberName("markdown")] + Markdown +} + #endregion #region Wait For Resource diff --git a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs index 08223271c9d..ed852d4f43e 100644 --- a/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ResourceCommandHelperTests.cs @@ -23,7 +23,7 @@ public async Task ExecuteGenericCommandAsync_WithResult_OutputsRawText() Value = new ExecuteResourceCommandResult { Value = "{\"items\": [\"a\", \"b\"]}", - Format = "json" + Format = CommandResultFormat.Json } } }; @@ -86,7 +86,7 @@ public async Task ExecuteGenericCommandAsync_ErrorWithResult_OutputsRawText() Value = new ExecuteResourceCommandResult { Value = "{\"errors\": [\"invalid host\"]}", - Format = "json" + Format = CommandResultFormat.Json } } }; @@ -121,7 +121,7 @@ public async Task ExecuteGenericCommandAsync_RoutesStatusToStderr_ResultToStdout Value = new ExecuteResourceCommandResult { Value = "some output", - Format = "text" + Format = CommandResultFormat.Text } } }; diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index f987f4d6b3f..c26f5ee4e92 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -163,7 +163,7 @@ public async Task ExecuteResourceCommandTool_ReturnsResult_WhenCommandReturnsRes Value = new ExecuteResourceCommandResult { Value = "{\"token\": \"abc123\"}", - Format = "json" + Format = CommandResultFormat.Json } } }; From 8a746544690b5b3ff4a49d19878639332edbc816 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 8 Apr 2026 12:20:10 +0800 Subject: [PATCH 15/16] Rename format labels, truncate notification body, reorder dropdown options --- .../Dialogs/NotificationEntryComponent.razor | 3 ++- .../Components/Dialogs/TextVisualizerDialog.razor.cs | 10 +++------- .../Layout/NotificationsHeaderButton.razor.cs | 1 - src/Aspire.Dashboard/Resources/Dialogs.Designer.cs | 4 ++-- src/Aspire.Dashboard/Resources/Dialogs.resx | 4 ++-- src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf | 8 ++++---- src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf | 8 ++++---- 18 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor index 6f4a3e5e655..a6eec31b250 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/NotificationEntryComponent.razor @@ -1,4 +1,5 @@ @using Aspire.Dashboard.Model +@using Aspire.Dashboard.Otlp.Model @using Aspire.Dashboard.Resources @using Microsoft.FluentUI.AspNetCore.Components.Extensions @inject TimeProvider TimeProvider @@ -24,7 +25,7 @@
@if (!string.IsNullOrEmpty(Entry.Body)) { - @Entry.Body + @OtlpHelpers.TruncateString(Entry.Body, 500) } @if (Entry.PrimaryAction is { } primaryAction) { diff --git a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs index 5df43a8c0ac..b4a563cc018 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/TextVisualizerDialog.razor.cs @@ -59,23 +59,19 @@ protected override void OnParametersSet() { EnabledOptions.Clear(); EnabledOptions.Add(DashboardUIHelpers.PlaintextFormat); + EnabledOptions.Add(DashboardUIHelpers.MarkdownFormat); _options = [ new SelectViewModel { Id = DashboardUIHelpers.PlaintextFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogPlaintextFormat)] }, + new SelectViewModel { Id = DashboardUIHelpers.MarkdownFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogMarkdownFormat)] }, new SelectViewModel { Id = DashboardUIHelpers.JsonFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogJsonFormat)] }, - new SelectViewModel { Id = DashboardUIHelpers.XmlFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogXmlFormat)] }, - new SelectViewModel { Id = DashboardUIHelpers.MarkdownFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogMarkdownFormat)] } + new SelectViewModel { Id = DashboardUIHelpers.XmlFormat, Name = Loc[nameof(Resources.Dialogs.TextVisualizerDialogXmlFormat)] } ]; // If a fixed format is specified, use it directly without auto-detection. if (Content.FixedFormat is not null) { TextVisualizerViewModel = new TextVisualizerViewModel(Content.Text, indentText: true, Content.FixedFormat); - - if (Content.FixedFormat == DashboardUIHelpers.MarkdownFormat) - { - EnabledOptions.Add(DashboardUIHelpers.MarkdownFormat); - } } else { diff --git a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs index 2b47c715745..c44f82955b3 100644 --- a/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/NotificationsHeaderButton.razor.cs @@ -27,7 +27,6 @@ protected override void OnInitialized() private async Task HandleClick() { - NotificationService.ResetUnreadCount(); await OnClick(); } diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index f02606d08d9..5ce37e103e9 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1060,7 +1060,7 @@ public static string SettingsRemoveAllButtonText { } /// - /// Looks up a localized string similar to Format JSON. + /// Looks up a localized string similar to JSON. /// public static string TextVisualizerDialogJsonFormat { get { @@ -1087,7 +1087,7 @@ public static string TextVisualizerDialogPlaintextFormat { } /// - /// Looks up a localized string similar to Format XML. + /// Looks up a localized string similar to XML. /// public static string TextVisualizerDialogXmlFormat { get { diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 4658c1b73eb..7ab69db6224 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -246,10 +246,10 @@ Unformatted - Format JSON + JSON - Format XML + XML Markdown diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 8dd41a7467a..d7a7aff41b1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -593,8 +593,8 @@ - Format JSON - Formátovat JSON + JSON + Formátovat JSON @@ -608,8 +608,8 @@ - Format XML - Formátovat XML + XML + Formátovat XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 94d3b89b542..dba95e3abdc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -593,8 +593,8 @@ - Format JSON - JSON formatieren + JSON + JSON formatieren @@ -608,8 +608,8 @@ - Format XML - XML formatieren + XML + XML formatieren diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 62eb65861e8..f3cedddf742 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -593,8 +593,8 @@ - Format JSON - Formato JSON + JSON + Formato JSON @@ -608,8 +608,8 @@ - Format XML - Formato XML + XML + Formato XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index 22fef00b397..8b87eaaad42 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -593,8 +593,8 @@ - Format JSON - Format JSON + JSON + Format JSON @@ -608,8 +608,8 @@ - Format XML - Format XML + XML + Format XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index 2ce25fb058d..0768ed925d6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -593,8 +593,8 @@ - Format JSON - Formato JSON + JSON + Formato JSON @@ -608,8 +608,8 @@ - Format XML - Formatta XML + XML + Formatta XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index 6e667dffe6d..5be49390e46 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -593,8 +593,8 @@ - Format JSON - JSON の書式設定 + JSON + JSON の書式設定 @@ -608,8 +608,8 @@ - Format XML - XML の書式設定 + XML + XML の書式設定 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index 37c9e9e7494..db374a04fdf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -593,8 +593,8 @@ - Format JSON - JSON 형식 지정 + JSON + JSON 형식 지정 @@ -608,8 +608,8 @@ - Format XML - XML 형식 지정 + XML + XML 형식 지정 diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 9e3eddf57e9..f46dea5443f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -593,8 +593,8 @@ - Format JSON - Formatuj kod JSON + JSON + Formatuj kod JSON @@ -608,8 +608,8 @@ - Format XML - Formatuj kod XML + XML + Formatuj kod XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index 30ece21ea33..6813bd6d45c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -593,8 +593,8 @@ - Format JSON - Formatar JSON + JSON + Formatar JSON @@ -608,8 +608,8 @@ - Format XML - Formatar XML + XML + Formatar XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index f8a34f1dd1d..664204a1d21 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -593,8 +593,8 @@ - Format JSON - Формат JSON + JSON + Формат JSON @@ -608,8 +608,8 @@ - Format XML - Формат XML + XML + Формат XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 7043a0dd6b5..db5fec1cdcd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -593,8 +593,8 @@ - Format JSON - JSON dosyasını biçimlendir + JSON + JSON dosyasını biçimlendir @@ -608,8 +608,8 @@ - Format XML - XML dosyasını biçimlendir + XML + XML dosyasını biçimlendir diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index 8edb590a2a9..eef859d147a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -593,8 +593,8 @@ - Format JSON - 格式化 JSON + JSON + 格式化 JSON @@ -608,8 +608,8 @@ - Format XML - 格式化 XML + XML + 格式化 XML diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 69b5ccc16c7..f8426dd5996 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -593,8 +593,8 @@ - Format JSON - 格式化 JSON + JSON + 格式化 JSON @@ -608,8 +608,8 @@ - Format XML - 格式化 XML + XML + 格式化 XML From 3416da540de7fd98a2472b92db9ab508cad74b6c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 8 Apr 2026 12:28:56 +0800 Subject: [PATCH 16/16] Fix TextVisualizerDialog tests to include Markdown in EnabledOptions --- .../Controls/TextVisualizerDialogTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs index 87859dd3312..f8d480d8e6d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs @@ -48,7 +48,7 @@ public async Task Render_TextVisualizerDialog_WithValidJson_FormatsJsonAsync() Assert.Equal(expectedJson, instance.TextVisualizerViewModel.FormattedText); Assert.Equal(DashboardUIHelpers.JsonFormat, instance.TextVisualizerViewModel.FormatKind); - Assert.Equal([DashboardUIHelpers.JsonFormat, DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); + Assert.Equal([DashboardUIHelpers.JsonFormat, DashboardUIHelpers.MarkdownFormat, DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); } [Fact] @@ -70,7 +70,7 @@ public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXml_CanChangeF Assert.Equal(DashboardUIHelpers.XmlFormat, instance.TextVisualizerViewModel.FormatKind); Assert.Equal(expectedXml, instance.TextVisualizerViewModel.FormattedText); - Assert.Equal([DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); + Assert.Equal([DashboardUIHelpers.MarkdownFormat, DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); // changing format works instance.ChangeFormat(DashboardUIHelpers.PlaintextFormat, rawXml); @@ -97,7 +97,7 @@ public async Task Render_TextVisualizerDialog_WithValidXml_FormatsXmlWithDoctype Assert.Equal(DashboardUIHelpers.XmlFormat, instance.TextVisualizerViewModel.FormatKind); Assert.Equal(expectedXml, instance.TextVisualizerViewModel.FormattedText); - Assert.Equal([DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); + Assert.Equal([DashboardUIHelpers.MarkdownFormat, DashboardUIHelpers.PlaintextFormat, DashboardUIHelpers.XmlFormat], instance.EnabledOptions.ToImmutableSortedSet()); } [Fact] @@ -113,7 +113,7 @@ public async Task Render_TextVisualizerDialog_WithInvalidJson_FormatsPlaintextAs Assert.Equal(DashboardUIHelpers.PlaintextFormat, instance.TextVisualizerViewModel.FormatKind); Assert.Equal(rawText, instance.TextVisualizerViewModel.FormattedText); - Assert.Equal([DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); + Assert.Equal([DashboardUIHelpers.MarkdownFormat, DashboardUIHelpers.PlaintextFormat], instance.EnabledOptions.ToImmutableSortedSet()); } [Fact]