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();