From b615de76c7db76e7f67fe68c5052f15593a72ccb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 18:21:01 +0000
Subject: [PATCH 1/4] Initial plan
From d3a73fc6a56c52faf2f795f0e3fced3b59cfad76 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 18:43:12 +0000
Subject: [PATCH 2/4] Add custom icon data support infrastructure
Co-authored-by: adamint <20359921+adamint@users.noreply.github.com>
---
.../Model/ResourceIconHelpers.cs | 19 ++-
.../Model/ResourceViewModel.cs | 1 +
.../ServiceClient/Partials.cs | 3 +-
.../CustomResourceSnapshot.cs | 6 +
.../ResourceIconAnnotation.cs | 52 ++++++-
.../ResourceNotificationService.cs | 8 +-
.../Dashboard/DashboardServiceData.cs | 3 +-
.../Dashboard/ResourceSnapshot.cs | 1 +
.../Dashboard/proto/Partials.cs | 5 +
.../Dashboard/proto/dashboard_service.proto | 4 +
.../ResourceBuilderExtensions.cs | 140 ++++++++++++++++++
11 files changed, 230 insertions(+), 12 deletions(-)
diff --git a/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs b/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
index 17433ccd841..4d57fe05432 100644
--- a/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
+++ b/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
@@ -7,6 +7,17 @@ namespace Aspire.Dashboard.Model;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
+///
+/// Represents a custom icon using SVG content or data URI.
+///
+internal sealed class CustomDataIcon : Icon
+{
+ public CustomDataIcon(string iconData, IconSize size)
+ : base("CustomData", IconVariant.Regular, size, iconData)
+ {
+ }
+}
+
internal static class ResourceIconHelpers
{
///
@@ -14,7 +25,13 @@ internal static class ResourceIconHelpers
///
public static Icon GetIconForResource(IconResolver iconResolver, ResourceViewModel resource, IconSize desiredSize, IconVariant desiredVariant = IconVariant.Filled)
{
- // Check if the resource has a custom icon specified
+ // Check if the resource has custom icon data (takes highest precedence)
+ if (!string.IsNullOrWhiteSpace(resource.CustomIconData))
+ {
+ return new CustomDataIcon(resource.CustomIconData, desiredSize);
+ }
+
+ // Check if the resource has a custom icon name specified
if (!string.IsNullOrWhiteSpace(resource.IconName))
{
var customIcon = iconResolver.ResolveIconName(resource.IconName, desiredSize, resource.IconVariant ?? IconVariant.Filled);
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
index c973ab333e4..0acc61404f5 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
@@ -44,6 +44,7 @@ public sealed class ResourceViewModel
public bool SupportsDetailedTelemetry { get; init; }
public string? IconName { get; init; }
public IconVariant? IconVariant { get; init; }
+ public string? CustomIconData { get; init; }
///
/// Gets the cached addresses for this resource that can be used for peer matching.
diff --git a/src/Aspire.Dashboard/ServiceClient/Partials.cs b/src/Aspire.Dashboard/ServiceClient/Partials.cs
index 2eac601d320..7055ca0f4ba 100644
--- a/src/Aspire.Dashboard/ServiceClient/Partials.cs
+++ b/src/Aspire.Dashboard/ServiceClient/Partials.cs
@@ -42,7 +42,8 @@ public ResourceViewModel ToViewModel(IKnownPropertyLookup knownPropertyLookup, I
IsHidden = IsHidden,
SupportsDetailedTelemetry = SupportsDetailedTelemetry,
IconName = HasIconName ? IconName : null,
- IconVariant = HasIconVariant ? MapResourceIconVariant(IconVariant) : null
+ IconVariant = HasIconVariant ? MapResourceIconVariant(IconVariant) : null,
+ CustomIconData = HasCustomIconData ? CustomIconData : null
};
}
catch (Exception ex)
diff --git a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
index 8b5ed22cd3c..be5cb7a2b3f 100644
--- a/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
+++ b/src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
@@ -144,6 +144,12 @@ internal init
///
public IconVariant? IconVariant { get; init; }
+ ///
+ /// Custom icon data for the resource, which can be SVG content or a data URI.
+ /// When specified, this takes precedence over IconName for displaying the icon.
+ ///
+ public string? CustomIconData { get; init; }
+
internal static HealthStatus? ComputeHealthStatus(ImmutableArray healthReports, string? state)
{
if (state != KnownResourceStates.Running)
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
index d08eaf5540c..af7d4f0d346 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
@@ -8,22 +8,60 @@ namespace Aspire.Hosting.ApplicationModel;
///
/// Specifies the icon to use when displaying a resource in the dashboard.
///
-/// The name of the FluentUI icon to use.
-/// The variant of the icon (Regular or Filled).
-[DebuggerDisplay("Type = {GetType().Name,nq}, IconName = {IconName}, IconVariant = {IconVariant}")]
-public sealed class ResourceIconAnnotation(string iconName, IconVariant iconVariant = IconVariant.Filled) : IResourceAnnotation
+[DebuggerDisplay("Type = {GetType().Name,nq}, IconName = {IconName}, IconVariant = {IconVariant}, HasCustomIconData = {CustomIconData != null}")]
+public sealed class ResourceIconAnnotation : IResourceAnnotation
{
+ ///
+ /// Initializes a new instance of the class with a FluentUI icon name.
+ ///
+ /// The name of the FluentUI icon to use.
+ /// The variant of the icon (Regular or Filled).
+ public ResourceIconAnnotation(string iconName, IconVariant iconVariant = IconVariant.Filled)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(iconName);
+ IconName = iconName;
+ IconVariant = iconVariant;
+ }
+
+ ///
+ /// Initializes a new instance of the class with custom icon data.
+ ///
+ /// The custom icon data (SVG content or data URI).
+ /// Optional icon name for reference.
+ public ResourceIconAnnotation(string customIconData, string? iconName = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(customIconData);
+ CustomIconData = customIconData;
+ IconName = iconName ?? "CustomIcon";
+ IconVariant = IconVariant.Regular;
+ }
+
///
/// Gets the name of the FluentUI icon to use for the resource.
///
///
- /// The icon name should be a valid FluentUI icon name.
+ /// The icon name should be a valid FluentUI icon name when is null.
/// See https://aka.ms/fluentui-system-icons for available icons.
+ /// When is specified, this serves as a reference name.
///
- public string IconName { get; } = iconName ?? throw new ArgumentNullException(nameof(iconName));
+ public string IconName { get; }
///
/// Gets the variant of the icon (Regular or Filled).
///
- public IconVariant IconVariant { get; } = iconVariant;
+ ///
+ /// This property is only used when is null and a FluentUI icon name is specified.
+ ///
+ public IconVariant IconVariant { get; }
+
+ ///
+ /// Gets the custom icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).
+ ///
+ ///
+ /// When this property is set, it takes precedence over for icon display.
+ /// The data should be either:
+ /// - Raw SVG content (e.g., "<svg...>...</svg>")
+ /// - A data URI (e.g., "data:image/png;base64,...")
+ ///
+ public string? CustomIconData { get; }
}
\ No newline at end of file
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
index 07cf8d70df8..d3a7d69f6eb 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs
@@ -761,9 +761,12 @@ private static CustomResourceSnapshot UpdateIcons(IResource resource, CustomReso
// Only update icon information if not already set
var newIconName = string.IsNullOrEmpty(previousState.IconName) ? iconAnnotation.IconName : previousState.IconName;
var newIconVariant = previousState.IconVariant ?? iconAnnotation.IconVariant;
+ var newCustomIconData = string.IsNullOrEmpty(previousState.CustomIconData) ? iconAnnotation.CustomIconData : previousState.CustomIconData;
// Only create new snapshot if there are changes
- if (previousState.IconName == newIconName && previousState.IconVariant == newIconVariant)
+ if (previousState.IconName == newIconName &&
+ previousState.IconVariant == newIconVariant &&
+ previousState.CustomIconData == newCustomIconData)
{
return previousState;
}
@@ -771,7 +774,8 @@ private static CustomResourceSnapshot UpdateIcons(IResource resource, CustomReso
return previousState with
{
IconName = newIconName,
- IconVariant = newIconVariant
+ IconVariant = newIconVariant,
+ CustomIconData = newCustomIconData
};
}
diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
index 82721e80162..33adf36ef5d 100644
--- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
+++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
@@ -59,7 +59,8 @@ static GenericResourceSnapshot CreateResourceSnapshot(IResource resource, string
IsHidden = snapshot.IsHidden,
SupportsDetailedTelemetry = snapshot.SupportsDetailedTelemetry,
IconName = snapshot.IconName,
- IconVariant = snapshot.IconVariant
+ IconVariant = snapshot.IconVariant,
+ CustomIconData = snapshot.CustomIconData
};
}
diff --git a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs
index ef841022d2c..5c49bab067c 100644
--- a/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs
+++ b/src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs
@@ -32,6 +32,7 @@ internal abstract class ResourceSnapshot
public required bool SupportsDetailedTelemetry { get; init; }
public required string? IconName { get; init; }
public required IconVariant? IconVariant { get; init; }
+ public required string? CustomIconData { get; init; }
protected abstract IEnumerable<(string Key, Value Value, bool IsSensitive)> GetProperties();
diff --git a/src/Aspire.Hosting/Dashboard/proto/Partials.cs b/src/Aspire.Hosting/Dashboard/proto/Partials.cs
index ab54a5d3863..6ddf33a0db6 100644
--- a/src/Aspire.Hosting/Dashboard/proto/Partials.cs
+++ b/src/Aspire.Hosting/Dashboard/proto/Partials.cs
@@ -32,6 +32,11 @@ public static Resource FromSnapshot(ResourceSnapshot snapshot)
resource.IconVariant = MapIconVariant(snapshot.IconVariant);
}
+ if (snapshot.CustomIconData is not null)
+ {
+ resource.CustomIconData = snapshot.CustomIconData;
+ }
+
if (snapshot.CreationTimeStamp.HasValue)
{
resource.CreatedAt = Timestamp.FromDateTime(snapshot.CreationTimeStamp.Value.ToUniversalTime());
diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto
index 1704d5da34a..b67653b81bd 100644
--- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto
+++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto
@@ -252,6 +252,10 @@ message Resource {
// Optional icon variant for the custom icon.
optional IconVariant icon_variant = 24;
+
+ // Optional custom icon data. This can be SVG content or a data URI (e.g., data:image/png;base64,...).
+ // When specified, this takes precedence over icon_name for rendering the icon.
+ optional string custom_icon_data = 25;
}
////////////////////////////////////////////
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index 689eb2a9e46..c3d019b46e9 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -2513,4 +2513,144 @@ private static IResourceBuilder WithProbe(this IResourceBuilder builder
return builder.WithAnnotation(probeAnnotation);
}
+
+ ///
+ /// Specifies a custom icon to display for the resource in the dashboard using a FluentUI icon name.
+ ///
+ /// The resource type.
+ /// The resource builder.
+ /// The name of the FluentUI icon. See https://aka.ms/fluentui-system-icons for available icons.
+ /// The variant of the icon (Regular or Filled). Defaults to Filled.
+ /// The .
+ ///
+ ///
+ /// This method specifies which FluentUI icon to use when displaying the resource in the dashboard.
+ /// If not specified, the dashboard will use default icons based on the resource type.
+ ///
+ ///
+ /// Set a Redis resource to use the "Database" icon:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ /// var redis = builder.AddRedis("cache")
+ /// .WithResourceIcon("Database", IconVariant.Filled);
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithResourceIcon(this IResourceBuilder builder, string iconName, IconVariant iconVariant = IconVariant.Filled) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(iconName);
+
+ return builder.WithAnnotation(new ResourceIconAnnotation(iconName, iconVariant));
+ }
+
+ ///
+ /// Specifies a custom icon to display for the resource in the dashboard using an icon file.
+ ///
+ /// The resource type.
+ /// The resource builder.
+ /// The path to the icon file (SVG, PNG, ICO, JPG, etc.). Relative paths are resolved relative to the app host project directory.
+ /// The .
+ ///
+ ///
+ /// This method loads an icon file and configures it to be displayed for the resource in the dashboard.
+ /// The icon file can be in SVG format (preferred) or bitmap formats (PNG, ICO, JPG).
+ /// Bitmap formats are automatically encoded as data URIs.
+ ///
+ ///
+ /// Use a custom SVG icon from a file:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ /// var myService = builder.AddContainer("myservice", "myimage")
+ /// .WithResourceIcon("./icons/myicon.svg");
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithResourceIcon(this IResourceBuilder builder, string iconPath) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(iconPath);
+
+ string fullPath;
+ if (Path.IsPathRooted(iconPath))
+ {
+ fullPath = iconPath;
+ }
+ else
+ {
+ // Resolve relative paths against the app host directory
+ var appHostDirectory = builder.ApplicationBuilder.AppHostDirectory;
+ fullPath = Path.GetFullPath(Path.Combine(appHostDirectory, iconPath));
+ }
+
+ if (!File.Exists(fullPath))
+ {
+ throw new FileNotFoundException($"Icon file not found: {fullPath}", fullPath);
+ }
+
+ var extension = Path.GetExtension(fullPath).ToLowerInvariant();
+ string iconData;
+
+ if (extension == ".svg")
+ {
+ // Read SVG content directly
+ iconData = File.ReadAllText(fullPath);
+ }
+ else
+ {
+ // For bitmap formats, encode as data URI
+ var bytes = File.ReadAllBytes(fullPath);
+ var mimeType = extension switch
+ {
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".ico" => "image/x-icon",
+ ".gif" => "image/gif",
+ ".webp" => "image/webp",
+ _ => throw new NotSupportedException($"Unsupported icon file format: {extension}")
+ };
+
+ var base64 = Convert.ToBase64String(bytes);
+ iconData = $"data:{mimeType};base64,{base64}";
+ }
+
+ var iconName = Path.GetFileNameWithoutExtension(iconPath);
+ return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
+ }
+
+ ///
+ /// Specifies a custom icon to display for the resource in the dashboard using custom icon data.
+ ///
+ /// The resource type.
+ /// The resource builder.
+ /// The icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).
+ /// Optional name for the icon for reference purposes.
+ /// The .
+ ///
+ ///
+ /// This method allows specifying custom icon data directly, useful for embedding resources or dynamic icon generation.
+ /// The icon data should be either raw SVG content or a data URI for bitmap formats.
+ ///
+ ///
+ /// Use a custom icon from an embedded resource:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// // For PNG from embedded resource
+ /// var iconBytes = Resources.MyIcon; // Embedded resource bytes
+ /// var base64 = Convert.ToBase64String(iconBytes);
+ /// var dataUri = $"data:image/png;base64,{base64}";
+ ///
+ /// var myService = builder.AddContainer("myservice", "myimage")
+ /// .WithResourceIcon(dataUri, "MyIcon");
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithResourceIcon(this IResourceBuilder builder, string iconData, string? iconName) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(iconData);
+
+ return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
+ }
}
From 47eab1a669bb19f7c39bc310308e20fcbf7a01c0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 18:52:31 +0000
Subject: [PATCH 3/4] Add tests for custom icon functionality
Co-authored-by: adamint <20359921+adamint@users.noreply.github.com>
---
.../Model/ResourceIconHelpersTests.cs | 46 +++++
.../Dashboard/ResourcePublisherTests.cs | 3 +-
.../Aspire.Hosting.Tests/WithIconNameTests.cs | 165 +++++++++++++++++-
.../Shared/DashboardModel/ModelTestHelpers.cs | 6 +-
4 files changed, 216 insertions(+), 4 deletions(-)
diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
index bd105bf2757..bcfb4adf46b 100644
--- a/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
@@ -102,4 +102,50 @@ public void GetIconForResource_WithDatabaseInResourceType_ReturnsDatabaseIcon()
// Should match the database special case
}
+ [Fact]
+ public void GetIconForResource_WithCustomIconData_ReturnsCustomDataIcon()
+ {
+ // Arrange
+ var svgContent = "";
+ var resource = ModelTestHelpers.CreateResource(customIconData: svgContent);
+
+ // Act
+ var icon = ResourceIconHelpers.GetIconForResource(_iconResolver, resource, IconSize.Size20);
+
+ // Assert
+ Assert.NotNull(icon);
+ Assert.IsType(icon);
+ }
+
+ [Fact]
+ public void GetIconForResource_WithCustomIconDataAndIconName_PrioritizesCustomData()
+ {
+ // Arrange
+ var svgContent = "";
+ var resource = ModelTestHelpers.CreateResource(iconName: "Database", customIconData: svgContent);
+
+ // Act
+ var icon = ResourceIconHelpers.GetIconForResource(_iconResolver, resource, IconSize.Size20);
+
+ // Assert
+ Assert.NotNull(icon);
+ Assert.IsType(icon);
+ // Custom data takes precedence over icon name
+ }
+
+ [Fact]
+ public void GetIconForResource_WithDataUriCustomIconData_ReturnsCustomDataIcon()
+ {
+ // Arrange
+ var dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA";
+ var resource = ModelTestHelpers.CreateResource(customIconData: dataUri);
+
+ // Act
+ var icon = ResourceIconHelpers.GetIconForResource(_iconResolver, resource, IconSize.Size16);
+
+ // Assert
+ Assert.NotNull(icon);
+ Assert.IsType(icon);
+ }
+
}
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs
index a75ece18e6d..8d3c95c3fe8 100644
--- a/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dashboard/ResourcePublisherTests.cs
@@ -205,7 +205,8 @@ private static GenericResourceSnapshot CreateResourceSnapshot(string name)
IsHidden = false,
SupportsDetailedTelemetry = false,
IconName = null,
- IconVariant = null
+ IconVariant = null,
+ CustomIconData = null
};
}
diff --git a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
index e7f49c84d95..52b6f634bd4 100644
--- a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
+++ b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
@@ -5,7 +5,7 @@
namespace Aspire.Hosting.Tests;
-public class WithIconNameTests
+public class WithResourceIconTests
{
[Fact]
public void WithIconName_SetsIconNameAndDefaultVariant()
@@ -101,6 +101,169 @@ public void WithIconName_OverridesExistingIconAnnotation()
Assert.Equal(IconVariant.Regular, lastAnnotation.IconVariant);
}
+ [Fact]
+ public void WithResourceIcon_SetsCustomIconDataWithSvgContent()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var svgContent = "";
+ var container = builder.AddContainer("mycontainer", "myimage")
+ .WithResourceIcon(svgContent, "MySvgIcon");
+
+ // Verify the annotation was added with custom icon data
+ var iconAnnotation = container.Resource.Annotations.OfType().Single();
+ Assert.Equal(svgContent, iconAnnotation.CustomIconData);
+ Assert.Equal("MySvgIcon", iconAnnotation.IconName);
+ }
+
+ [Fact]
+ public void WithResourceIcon_SetsCustomIconDataWithDataUri()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA";
+ var container = builder.AddContainer("mycontainer", "myimage")
+ .WithResourceIcon(dataUri, "MyPngIcon");
+
+ // Verify the annotation was added with custom icon data
+ var iconAnnotation = container.Resource.Annotations.OfType().Single();
+ Assert.Equal(dataUri, iconAnnotation.CustomIconData);
+ Assert.Equal("MyPngIcon", iconAnnotation.IconName);
+ }
+
+ [Fact]
+ public void WithResourceIcon_FromFile_LoadsSvgContent()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ // Create a temporary SVG file
+ var tempFile = Path.Combine(Path.GetTempPath(), $"test-icon-{Guid.NewGuid()}.svg");
+ var svgContent = "";
+ File.WriteAllText(tempFile, svgContent);
+
+ try
+ {
+ var container = builder.AddContainer("mycontainer", "myimage")
+ .WithResourceIcon(tempFile);
+
+ // Verify the annotation was added with the SVG content
+ var iconAnnotation = container.Resource.Annotations.OfType().Single();
+ Assert.Equal(svgContent, iconAnnotation.CustomIconData);
+ Assert.Equal("test-icon-" + Path.GetFileNameWithoutExtension(tempFile).Split('-').Last(), iconAnnotation.IconName);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void WithResourceIcon_FromFile_LoadsPngAsDataUri()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ // Create a temporary PNG file
+ var tempFile = Path.Combine(Path.GetTempPath(), $"test-icon-{Guid.NewGuid()}.png");
+ var pngBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; // PNG magic bytes
+ File.WriteAllBytes(tempFile, pngBytes);
+
+ try
+ {
+ var container = builder.AddContainer("mycontainer", "myimage")
+ .WithResourceIcon(tempFile);
+
+ // Verify the annotation was added with a data URI
+ var iconAnnotation = container.Resource.Annotations.OfType().Single();
+ Assert.NotNull(iconAnnotation.CustomIconData);
+ Assert.StartsWith("data:image/png;base64,", iconAnnotation.CustomIconData);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void WithResourceIcon_FromFile_ThrowsIfFileNotFound()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var container = builder.AddContainer("mycontainer", "myimage");
+
+ var nonExistentFile = Path.Combine(Path.GetTempPath(), "nonexistent-icon.svg");
+ Assert.Throws(() => container.WithResourceIcon(nonExistentFile));
+ }
+
+ [Fact]
+ public void WithResourceIcon_FromFile_ThrowsOnUnsupportedFormat()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ // Create a temporary file with unsupported extension
+ var tempFile = Path.Combine(Path.GetTempPath(), $"test-icon-{Guid.NewGuid()}.txt");
+ File.WriteAllText(tempFile, "not an icon");
+
+ try
+ {
+ var container = builder.AddContainer("mycontainer", "myimage");
+ Assert.Throws(() => container.WithResourceIcon(tempFile));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Fact]
+ public void WithResourceIcon_ThrowsOnNullIconData()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var container = builder.AddContainer("mycontainer", "myimage");
+
+ Assert.Throws(() => container.WithResourceIcon(null!, "test"));
+ }
+
+ [Fact]
+ public void WithResourceIcon_ThrowsOnEmptyIconData()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var container = builder.AddContainer("mycontainer", "myimage");
+
+ Assert.Throws(() => container.WithResourceIcon("", "test"));
+ Assert.Throws(() => container.WithResourceIcon(" ", "test"));
+ }
+
+ [Fact]
+ public void WithResourceIcon_CustomDataTakesPrecedenceOverIconName()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var svgContent = "";
+ var container = builder.AddContainer("mycontainer", "myimage")
+ .WithIconName("Database")
+ .WithResourceIcon(svgContent, "CustomIcon");
+
+ // Should have both annotations
+ var iconAnnotations = container.Resource.Annotations.OfType().ToList();
+ Assert.Equal(2, iconAnnotations.Count);
+
+ // The latest annotation should have custom icon data
+ var latestAnnotation = iconAnnotations.Last();
+ Assert.Equal(svgContent, latestAnnotation.CustomIconData);
+ Assert.Equal("CustomIcon", latestAnnotation.IconName);
+ }
+
private sealed class TestProject : IProjectMetadata
{
public string ProjectPath => "TestProject.csproj";
diff --git a/tests/Shared/DashboardModel/ModelTestHelpers.cs b/tests/Shared/DashboardModel/ModelTestHelpers.cs
index 982e6a1ea77..81b969c8d64 100644
--- a/tests/Shared/DashboardModel/ModelTestHelpers.cs
+++ b/tests/Shared/DashboardModel/ModelTestHelpers.cs
@@ -25,7 +25,8 @@ public static ResourceViewModel CreateResource(
ImmutableArray? relationships = null,
bool hidden = false,
string? iconName = null,
- IconVariant? iconVariant = null)
+ IconVariant? iconVariant = null,
+ string? customIconData = null)
{
return new ResourceViewModel
{
@@ -48,7 +49,8 @@ public static ResourceViewModel CreateResource(
Relationships = relationships ?? [],
IsHidden = hidden,
IconName = iconName,
- IconVariant = iconVariant
+ IconVariant = iconVariant,
+ CustomIconData = customIconData
};
}
}
From f56b9eea064751b3b1474660640548fb54d6c2bd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 6 Oct 2025 19:28:37 +0000
Subject: [PATCH 4/4] Address PR feedback: fix API and use existing Icon base
class
Co-authored-by: adamint <20359921+adamint@users.noreply.github.com>
---
.../Model/ResourceIconHelpers.cs | 10 +-
.../ResourceIconAnnotation.cs | 6 +-
.../ResourceBuilderExtensions.cs | 104 +++++++-----------
.../Model/ResourceIconHelpersTests.cs | 10 +-
.../Aspire.Hosting.Tests/WithIconNameTests.cs | 22 ++--
5 files changed, 63 insertions(+), 89 deletions(-)
diff --git a/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs b/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
index 4d57fe05432..8a89ff6f2af 100644
--- a/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
+++ b/src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
@@ -8,12 +8,12 @@ namespace Aspire.Dashboard.Model;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
///
-/// Represents a custom icon using SVG content or data URI.
+/// Represents a custom resource icon using SVG content or data URI.
///
-internal sealed class CustomDataIcon : Icon
+internal sealed class CustomResourceIcon : Icon
{
- public CustomDataIcon(string iconData, IconSize size)
- : base("CustomData", IconVariant.Regular, size, iconData)
+ public CustomResourceIcon(IconSize size, string iconContent)
+ : base(string.Empty, IconVariant.Regular, size, iconContent)
{
}
}
@@ -28,7 +28,7 @@ public static Icon GetIconForResource(IconResolver iconResolver, ResourceViewMod
// Check if the resource has custom icon data (takes highest precedence)
if (!string.IsNullOrWhiteSpace(resource.CustomIconData))
{
- return new CustomDataIcon(resource.CustomIconData, desiredSize);
+ return new CustomResourceIcon(desiredSize, resource.CustomIconData);
}
// Check if the resource has a custom icon name specified
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
index af7d4f0d346..bdb565f1bb8 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
@@ -32,7 +32,7 @@ public ResourceIconAnnotation(string customIconData, string? iconName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(customIconData);
CustomIconData = customIconData;
- IconName = iconName ?? "CustomIcon";
+ IconName = iconName ?? string.Empty;
IconVariant = IconVariant.Regular;
}
@@ -60,8 +60,8 @@ public ResourceIconAnnotation(string customIconData, string? iconName = null)
///
/// When this property is set, it takes precedence over for icon display.
/// The data should be either:
- /// - Raw SVG content (e.g., "<svg...>...</svg>")
- /// - A data URI (e.g., "data:image/png;base64,...")
+ /// - Raw SVG content (e.g., "<svg width='24' height='24'><circle cx='12' cy='12' r='10'/></svg>")
+ /// - A data URI (e.g., "data:image/png;base64,iVBORw0KGgo...")
///
public string? CustomIconData { get; }
}
\ No newline at end of file
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index c3d019b46e9..25568c7aabf 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -2339,6 +2339,45 @@ public static IResourceBuilder WithIconName(this IResourceBuilder build
return builder.WithAnnotation(new ResourceIconAnnotation(iconName, iconVariant));
}
+ ///
+ /// Specifies a custom icon to display for the resource in the dashboard using custom icon data.
+ ///
+ /// The resource type.
+ /// The resource builder.
+ /// The icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).
+ /// Optional name for the icon for reference purposes.
+ /// The .
+ ///
+ ///
+ /// This method allows specifying custom icon data directly, useful for embedding resources or dynamic icon generation.
+ /// The icon data should be either raw SVG content or a data URI for bitmap formats.
+ ///
+ ///
+ /// Use a custom SVG icon from an embedded resource:
+ ///
+ /// var svgContent = "<svg width='24' height='24'><circle cx='12' cy='12' r='10'/></svg>";
+ /// var myService = builder.AddContainer("myservice", "myimage")
+ /// .WithIconName(svgContent, "MyIcon");
+ ///
+ ///
+ ///
+ /// Use a custom PNG icon from an embedded resource:
+ ///
+ /// var iconBytes = Resources.MyIcon; // Embedded resource bytes
+ /// var dataUri = $"data:image/png;base64,{Convert.ToBase64String(iconBytes)}";
+ /// var myService = builder.AddContainer("myservice", "myimage")
+ /// .WithIconName(dataUri, "MyIcon");
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithIconName(this IResourceBuilder builder, string iconData, string? iconName) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(iconData);
+
+ return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
+ }
+
///
/// Configures the compute environment for the compute resource.
///
@@ -2514,36 +2553,6 @@ private static IResourceBuilder WithProbe(this IResourceBuilder builder
return builder.WithAnnotation(probeAnnotation);
}
- ///
- /// Specifies a custom icon to display for the resource in the dashboard using a FluentUI icon name.
- ///
- /// The resource type.
- /// The resource builder.
- /// The name of the FluentUI icon. See https://aka.ms/fluentui-system-icons for available icons.
- /// The variant of the icon (Regular or Filled). Defaults to Filled.
- /// The .
- ///
- ///
- /// This method specifies which FluentUI icon to use when displaying the resource in the dashboard.
- /// If not specified, the dashboard will use default icons based on the resource type.
- ///
- ///
- /// Set a Redis resource to use the "Database" icon:
- ///
- /// var builder = DistributedApplication.CreateBuilder(args);
- /// var redis = builder.AddRedis("cache")
- /// .WithResourceIcon("Database", IconVariant.Filled);
- ///
- ///
- ///
- public static IResourceBuilder WithResourceIcon(this IResourceBuilder builder, string iconName, IconVariant iconVariant = IconVariant.Filled) where T : IResource
- {
- ArgumentNullException.ThrowIfNull(builder);
- ArgumentException.ThrowIfNullOrWhiteSpace(iconName);
-
- return builder.WithAnnotation(new ResourceIconAnnotation(iconName, iconVariant));
- }
-
///
/// Specifies a custom icon to display for the resource in the dashboard using an icon file.
///
@@ -2618,39 +2627,4 @@ public static IResourceBuilder WithResourceIcon(this IResourceBuilder b
return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
}
- ///
- /// Specifies a custom icon to display for the resource in the dashboard using custom icon data.
- ///
- /// The resource type.
- /// The resource builder.
- /// The icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).
- /// Optional name for the icon for reference purposes.
- /// The .
- ///
- ///
- /// This method allows specifying custom icon data directly, useful for embedding resources or dynamic icon generation.
- /// The icon data should be either raw SVG content or a data URI for bitmap formats.
- ///
- ///
- /// Use a custom icon from an embedded resource:
- ///
- /// var builder = DistributedApplication.CreateBuilder(args);
- ///
- /// // For PNG from embedded resource
- /// var iconBytes = Resources.MyIcon; // Embedded resource bytes
- /// var base64 = Convert.ToBase64String(iconBytes);
- /// var dataUri = $"data:image/png;base64,{base64}";
- ///
- /// var myService = builder.AddContainer("myservice", "myimage")
- /// .WithResourceIcon(dataUri, "MyIcon");
- ///
- ///
- ///
- public static IResourceBuilder WithResourceIcon(this IResourceBuilder builder, string iconData, string? iconName) where T : IResource
- {
- ArgumentNullException.ThrowIfNull(builder);
- ArgumentException.ThrowIfNullOrWhiteSpace(iconData);
-
- return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
- }
}
diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
index bcfb4adf46b..ccc5d653a27 100644
--- a/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
@@ -103,7 +103,7 @@ public void GetIconForResource_WithDatabaseInResourceType_ReturnsDatabaseIcon()
}
[Fact]
- public void GetIconForResource_WithCustomIconData_ReturnsCustomDataIcon()
+ public void GetIconForResource_WithCustomIconData_ReturnsCustomResourceIcon()
{
// Arrange
var svgContent = "";
@@ -114,7 +114,7 @@ public void GetIconForResource_WithCustomIconData_ReturnsCustomDataIcon()
// Assert
Assert.NotNull(icon);
- Assert.IsType(icon);
+ Assert.IsType(icon);
}
[Fact]
@@ -129,12 +129,12 @@ public void GetIconForResource_WithCustomIconDataAndIconName_PrioritizesCustomDa
// Assert
Assert.NotNull(icon);
- Assert.IsType(icon);
+ Assert.IsType(icon);
// Custom data takes precedence over icon name
}
[Fact]
- public void GetIconForResource_WithDataUriCustomIconData_ReturnsCustomDataIcon()
+ public void GetIconForResource_WithDataUriCustomIconData_ReturnsCustomResourceIcon()
{
// Arrange
var dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA";
@@ -145,7 +145,7 @@ public void GetIconForResource_WithDataUriCustomIconData_ReturnsCustomDataIcon()
// Assert
Assert.NotNull(icon);
- Assert.IsType(icon);
+ Assert.IsType(icon);
}
}
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
index 52b6f634bd4..c36cfea0aa4 100644
--- a/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
+++ b/tests/Aspire.Hosting.Tests/WithIconNameTests.cs
@@ -102,13 +102,13 @@ public void WithIconName_OverridesExistingIconAnnotation()
}
[Fact]
- public void WithResourceIcon_SetsCustomIconDataWithSvgContent()
+ public void WithIconName_SetsCustomIconDataWithSvgContent()
{
using var builder = TestDistributedApplicationBuilder.Create();
var svgContent = "";
var container = builder.AddContainer("mycontainer", "myimage")
- .WithResourceIcon(svgContent, "MySvgIcon");
+ .WithIconName(svgContent, "MySvgIcon");
// Verify the annotation was added with custom icon data
var iconAnnotation = container.Resource.Annotations.OfType().Single();
@@ -117,13 +117,13 @@ public void WithResourceIcon_SetsCustomIconDataWithSvgContent()
}
[Fact]
- public void WithResourceIcon_SetsCustomIconDataWithDataUri()
+ public void WithIconName_SetsCustomIconDataWithDataUri()
{
using var builder = TestDistributedApplicationBuilder.Create();
var dataUri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA";
var container = builder.AddContainer("mycontainer", "myimage")
- .WithResourceIcon(dataUri, "MyPngIcon");
+ .WithIconName(dataUri, "MyPngIcon");
// Verify the annotation was added with custom icon data
var iconAnnotation = container.Resource.Annotations.OfType().Single();
@@ -224,35 +224,35 @@ public void WithResourceIcon_FromFile_ThrowsOnUnsupportedFormat()
}
[Fact]
- public void WithResourceIcon_ThrowsOnNullIconData()
+ public void WithIconName_ThrowsOnNullIconData()
{
using var builder = TestDistributedApplicationBuilder.Create();
var container = builder.AddContainer("mycontainer", "myimage");
- Assert.Throws(() => container.WithResourceIcon(null!, "test"));
+ Assert.Throws(() => container.WithIconName(null!, "test"));
}
[Fact]
- public void WithResourceIcon_ThrowsOnEmptyIconData()
+ public void WithIconName_ThrowsOnEmptyIconData()
{
using var builder = TestDistributedApplicationBuilder.Create();
var container = builder.AddContainer("mycontainer", "myimage");
- Assert.Throws(() => container.WithResourceIcon("", "test"));
- Assert.Throws(() => container.WithResourceIcon(" ", "test"));
+ Assert.Throws(() => container.WithIconName("", "test"));
+ Assert.Throws(() => container.WithIconName(" ", "test"));
}
[Fact]
- public void WithResourceIcon_CustomDataTakesPrecedenceOverIconName()
+ public void WithIconName_CustomDataTakesPrecedenceOverIconName()
{
using var builder = TestDistributedApplicationBuilder.Create();
var svgContent = "";
var container = builder.AddContainer("mycontainer", "myimage")
.WithIconName("Database")
- .WithResourceIcon(svgContent, "CustomIcon");
+ .WithIconName(svgContent, "CustomIcon");
// Should have both annotations
var iconAnnotations = container.Resource.Annotations.OfType().ToList();