diff --git a/playground/TestShop/TestShop.AppHost/Program.cs b/playground/TestShop/TestShop.AppHost/Program.cs index 0a86d3c5bd1..260d472e7f2 100644 --- a/playground/TestShop/TestShop.AppHost/Program.cs +++ b/playground/TestShop/TestShop.AppHost/Program.cs @@ -8,7 +8,12 @@ .WithDataVolume() .WithPgAdmin(resource => { - resource.WithUrlForEndpoint("http", u => u.DisplayText = "PG Admin"); + resource.WithUrlForEndpoint("http", u => + { + u.DisplayText = "PG Admin"; + u.IconName = "DatabaseStack"; + u.IconVariant = IconVariant.Filled; + }); }) .AddDatabase("catalogdb"); diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor index fdefafe451b..8871339deb4 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor @@ -45,7 +45,7 @@ else if (DisplayedUrls.Count > 1) { var d = (DisplayedUrl)item.Data!; } @@ -62,10 +62,18 @@ else if (DisplayedUrls.Count > 1) } @code { - RenderFragment WriteUrl(DisplayedUrl displayedUrl) + RenderFragment WriteUrl(DisplayedUrl displayedUrl, bool displayLabel = false) { if (displayedUrl.Url != null) { + if (string.IsNullOrWhiteSpace(displayedUrl.IconName) == false && IconResolver.ResolveIconName(displayedUrl.IconName, IconSize.Size16, iconVariant: displayedUrl.IconVariant) is { } icon) + { + return @ + + ; + } + + return @@displayedUrl.Text; } else diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.cs index 39e57a3d35e..30c80fd6f0c 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.cs +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; namespace Aspire.Dashboard.Components; @@ -25,5 +26,14 @@ public partial class UrlsColumnDisplay [Inject] public required IStringLocalizer Loc { get; init; } + [Inject] + public required IconResolver IconResolver { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required IJSRuntime JSRuntime { get; init; } + private bool _popoverVisible; } diff --git a/src/Aspire.Dashboard/Model/ResourceUrlHelpers.cs b/src/Aspire.Dashboard/Model/ResourceUrlHelpers.cs index 09d1045e1ff..554cff5e317 100644 --- a/src/Aspire.Dashboard/Model/ResourceUrlHelpers.cs +++ b/src/Aspire.Dashboard/Model/ResourceUrlHelpers.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Aspire.Dashboard.Components.Controls; +using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Model; @@ -51,7 +52,9 @@ public static List GetUrls(ResourceViewModel resource, bool includ SortOrder = url.DisplayProperties.SortOrder, DisplayName = string.IsNullOrEmpty(url.DisplayProperties.DisplayName) ? null : url.DisplayProperties.DisplayName, OriginalUrlString = url.Url.OriginalString, - Text = string.IsNullOrEmpty(url.DisplayProperties.DisplayName) ? url.Url.OriginalString : url.DisplayProperties.DisplayName + Text = string.IsNullOrEmpty(url.DisplayProperties.DisplayName) ? url.Url.OriginalString : url.DisplayProperties.DisplayName, + IconName = url.DisplayProperties.IconName, + IconVariant = url.DisplayProperties.IconVariant, }); index++; } @@ -86,6 +89,9 @@ public sealed class DisplayedUrl : IPropertyGridItem public string? DisplayName { get; set; } public required string OriginalUrlString { get; set; } + public string? IconName { get; set; } + public IconVariant? IconVariant { get; set; } + /// /// Don't display a plain string value here. The URL will be displayed as a hyperlink /// in instead. diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index c973ab333e4..e428a98ccc9 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -428,9 +428,9 @@ public UrlViewModel(string? endpointName, Uri url, bool isInternal, bool isInact } } -public record UrlDisplayPropertiesViewModel(string DisplayName, int SortOrder) +public record UrlDisplayPropertiesViewModel(string DisplayName, int SortOrder, string? IconName, IconVariant? IconVariant) { - public static readonly UrlDisplayPropertiesViewModel Empty = new(string.Empty, 0); + public static readonly UrlDisplayPropertiesViewModel Empty = new(string.Empty, 0, string.Empty, null); } public sealed record class VolumeViewModel(int index, string Source, string Target, string MountType, bool IsReadOnly) : IPropertyGridItem diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs index 2eac601d320..f9a8dc85f57 100644 --- a/src/Aspire.Dashboard/ServiceClient/Partials.cs +++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs @@ -98,11 +98,21 @@ static string TranslateKnownUrlName(Url url) }; } + static FluentUIIconVariant MapIconVariant(IconVariant iconVariant) + { + return iconVariant switch + { + IconVariant.Regular => FluentUIIconVariant.Regular, + IconVariant.Filled => FluentUIIconVariant.Filled, + _ => throw new InvalidOperationException("Unknown icon variant: " + iconVariant), + }; + } + // Filter out bad urls return (from u in Urls let parsedUri = Uri.TryCreate(u.FullUrl, UriKind.Absolute, out var uri) ? uri : null where parsedUri != null - select new UrlViewModel(u.EndpointName, parsedUri, u.IsInternal, u.IsInactive, new UrlDisplayPropertiesViewModel(TranslateKnownUrlName(u), u.DisplayProperties.SortOrder))) + select new UrlViewModel(u.EndpointName, parsedUri, u.IsInternal, u.IsInactive, new UrlDisplayPropertiesViewModel(TranslateKnownUrlName(u), u.DisplayProperties.SortOrder, u.DisplayProperties.IconName, MapIconVariant(u.DisplayProperties.IconVariant)))) .ToImmutableArray(); } diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs index 8b5ed22cd3c..db1ee8c0d31 100644 --- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs @@ -228,7 +228,10 @@ internal void Deconstruct(out string? name, out string url, out bool isInternal, /// /// The display name of the url. /// The order of the url in UI. Higher numbers are displayed first in the UI. -public sealed record UrlDisplayPropertiesSnapshot(string DisplayName = "", int SortOrder = 0); +/// The icon name for the Url. The name should be a valid FluentUI icon name. https://aka.ms/fluentui-system-icons +/// The icon variant. + +public sealed record UrlDisplayPropertiesSnapshot(string DisplayName = "", int SortOrder = 0, string? IconName = null, IconVariant? IconVariant = null); /// /// A snapshot of a volume, mounted to a container. diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs index e2b8f01e90c..1811e46c123 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceUrlAnnotation.cs @@ -36,6 +36,16 @@ public sealed class ResourceUrlAnnotation : IResourceAnnotation /// public UrlDisplayLocation DisplayLocation { get; set; } = UrlDisplayLocation.SummaryAndDetails; + /// + /// The name of the icon to display with this URL. + /// + public string? IconName { get; set; } + + /// + /// The variant of the icon to display with this URL. + /// + public IconVariant? IconVariant { get; set; } + internal bool IsInternal => DisplayLocation == UrlDisplayLocation.DetailsOnly; internal ResourceUrlAnnotation WithEndpoint(EndpointReference endpoint) @@ -46,7 +56,7 @@ internal ResourceUrlAnnotation WithEndpoint(EndpointReference endpoint) DisplayText = DisplayText, Endpoint = endpoint, DisplayOrder = DisplayOrder, - DisplayLocation = DisplayLocation + DisplayLocation = DisplayLocation, }; } } diff --git a/src/Aspire.Hosting/Dashboard/proto/Partials.cs b/src/Aspire.Hosting/Dashboard/proto/Partials.cs index ab54a5d3863..381c75c3089 100644 --- a/src/Aspire.Hosting/Dashboard/proto/Partials.cs +++ b/src/Aspire.Hosting/Dashboard/proto/Partials.cs @@ -66,6 +66,16 @@ public static Resource FromSnapshot(ResourceSnapshot snapshot) displayProperties.SortOrder = urlSnapshot.DisplayProperties.SortOrder; } + if (urlSnapshot.DisplayProperties?.IconName is not null) + { + displayProperties.IconName = urlSnapshot.DisplayProperties.IconName; + } + + if (urlSnapshot.DisplayProperties?.IconVariant is not null) + { + displayProperties.IconVariant = MapIconVariant(urlSnapshot.DisplayProperties.IconVariant); + } + url.DisplayProperties = displayProperties; resource.Urls.Add(url); } diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index a7f7fe37998..1e9992eaa73 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -143,6 +143,11 @@ message UrlDisplayProperties { int32 sort_order = 1; // The display name of the url, to appear in the UI. string display_name = 2; + // Optional icon name. This name should be a valid FluentUI icon name. + // https://aka.ms/fluentui-system-icons + optional string icon_name = 3; + // Optional icon variant. + optional IconVariant icon_variant = 4; } // Data about a volume mounted to a container. diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 14d9097a83c..86da3aa9791 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -255,7 +255,7 @@ private ImmutableArray GetUrls(CustomResource resource, string? res var activeEndpoint = _resourceState.EndpointsMap.SingleOrDefault(e => e.Value.Spec.ServiceName == serviceName && e.Value.Metadata.OwnerReferences?.Any(or => or.Kind == resource.Kind && or.Name == name) == true).Value; var isInactive = activeEndpoint is null; - urls.Add(new(Name: endpointUrl.Endpoint!.EndpointName, Url: endpointUrl.Url, IsInternal: endpointUrl.IsInternal) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0) }); + urls.Add(new(Name: endpointUrl.Endpoint!.EndpointName, Url: endpointUrl.Url, IsInternal: endpointUrl.IsInternal) { IsInactive = isInactive, DisplayProperties = new(endpointUrl.DisplayText ?? "", endpointUrl.DisplayOrder ?? 0, endpointUrl.IconName, endpointUrl.IconVariant) }); } } @@ -263,7 +263,7 @@ private ImmutableArray GetUrls(CustomResource resource, string? res var resourceRunning = string.Equals(resourceState, KnownResourceStates.Running, StringComparisons.ResourceState); foreach (var url in nonEndpointUrls) { - urls.Add(new(Name: null, Url: url.Url, IsInternal: url.IsInternal) { IsInactive = !resourceRunning, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) }); + urls.Add(new(Name: null, Url: url.Url, IsInternal: url.IsInternal) { IsInactive = !resourceRunning, DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0, url.IconName, url.IconVariant) }); } } diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index a8246d5c35b..f9859a50772 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -147,7 +147,7 @@ private static IEnumerable GetResourceUrls(IResource resource) // Endpoint URLs are inactive (hidden in the dashboard) when published here. It is assumed they will get activated later when the endpoint is considered active // by whatever allocated the endpoint in the first place, e.g. for resources controlled by DCP, when DCP detects the endpoint is listening. IsInactive = url.Endpoint is not null, - DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0) + DisplayProperties = new(url.DisplayText ?? "", url.DisplayOrder ?? 0, url.IconName, url.IconVariant) }); } return urls; diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceUrlHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceUrlHelpersTests.cs index c351f1dbf2d..659419dbc29 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceUrlHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceUrlHelpersTests.cs @@ -244,10 +244,10 @@ public void GetUrls_OrderByName() public void GetUrls_SortOrder_Combinations() { var endpoints = GetUrls(ModelTestHelpers.CreateResource(urls: [ - new("Zero-Https", new("https://localhost:8079"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 0)), - new("Zero-Http", new("http://localhost:8080"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 0)), - new("Positive", new("http://localhost:8082"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 1)), - new("Negative", new("http://localhost:8083"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, -1)) + new("Zero-Https", new("https://localhost:8079"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 0, null, null)), + new("Zero-Http", new("http://localhost:8080"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 0 null, null)), + new("Positive", new("http://localhost:8082"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, 1 null, null)), + new("Negative", new("http://localhost:8083"), isInternal: false, isInactive: false, displayProperties: new UrlDisplayPropertiesViewModel(string.Empty, -1 null, null)) ])); Assert.Collection(endpoints,