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!;
- @WriteUrl(d)
+ @WriteUrl(d, displayLabel: true)
}
@@ -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,