Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/Aspire.Dashboard/Model/ResourceIconHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,31 @@ namespace Aspire.Dashboard.Model;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;

/// <summary>
/// Represents a custom resource icon using SVG content or data URI.
/// </summary>
internal sealed class CustomResourceIcon : Icon
{
public CustomResourceIcon(IconSize size, string iconContent)
: base(string.Empty, IconVariant.Regular, size, iconContent)
{
}
}

internal static class ResourceIconHelpers
{
/// <summary>
/// Maps a resource to an icon, checking for custom icons first, then falling back to default icons.
/// </summary>
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 CustomResourceIcon(desiredSize, resource.CustomIconData);
}

// 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);
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/ResourceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// Gets the cached addresses for this resource that can be used for peer matching.
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Dashboard/ServiceClient/Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ internal init
/// </summary>
public IconVariant? IconVariant { get; init; }

/// <summary>
/// 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.
/// </summary>
public string? CustomIconData { get; init; }

internal static HealthStatus? ComputeHealthStatus(ImmutableArray<HealthReportSnapshot> healthReports, string? state)
{
if (state != KnownResourceStates.Running)
Expand Down
52 changes: 45 additions & 7 deletions src/Aspire.Hosting/ApplicationModel/ResourceIconAnnotation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,60 @@ namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// Specifies the icon to use when displaying a resource in the dashboard.
/// </summary>
/// <param name="iconName">The name of the FluentUI icon to use.</param>
/// <param name="iconVariant">The variant of the icon (Regular or Filled).</param>
[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
{
/// <summary>
/// Initializes a new instance of the <see cref="ResourceIconAnnotation"/> class with a FluentUI icon name.
/// </summary>
/// <param name="iconName">The name of the FluentUI icon to use.</param>
/// <param name="iconVariant">The variant of the icon (Regular or Filled).</param>
public ResourceIconAnnotation(string iconName, IconVariant iconVariant = IconVariant.Filled)
{
ArgumentException.ThrowIfNullOrWhiteSpace(iconName);
IconName = iconName;
IconVariant = iconVariant;
}

/// <summary>
/// Initializes a new instance of the <see cref="ResourceIconAnnotation"/> class with custom icon data.
/// </summary>
/// <param name="customIconData">The custom icon data (SVG content or data URI).</param>
/// <param name="iconName">Optional icon name for reference.</param>
public ResourceIconAnnotation(string customIconData, string? iconName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(customIconData);
CustomIconData = customIconData;
IconName = iconName ?? string.Empty;
IconVariant = IconVariant.Regular;
}

/// <summary>
/// Gets the name of the FluentUI icon to use for the resource.
/// </summary>
/// <remarks>
/// The icon name should be a valid FluentUI icon name.
/// The icon name should be a valid FluentUI icon name when <see cref="CustomIconData"/> is null.
/// See https://aka.ms/fluentui-system-icons for available icons.
/// When <see cref="CustomIconData"/> is specified, this serves as a reference name.
/// </remarks>
public string IconName { get; } = iconName ?? throw new ArgumentNullException(nameof(iconName));
public string IconName { get; }

/// <summary>
/// Gets the variant of the icon (Regular or Filled).
/// </summary>
public IconVariant IconVariant { get; } = iconVariant;
/// <remarks>
/// This property is only used when <see cref="CustomIconData"/> is null and a FluentUI icon name is specified.
/// </remarks>
public IconVariant IconVariant { get; }

/// <summary>
/// Gets the custom icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).
/// </summary>
/// <remarks>
/// When this property is set, it takes precedence over <see cref="IconName"/> for icon display.
/// The data should be either:
/// - Raw SVG content (e.g., "&lt;svg width='24' height='24'&gt;&lt;circle cx='12' cy='12' r='10'/&gt;&lt;/svg&gt;")
/// - A data URI (e.g., "data:image/png;base64,iVBORw0KGgo...")
/// </remarks>
public string? CustomIconData { get; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure this supports svgs, and update to include an svg example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated documentation with SVG example in commit 6ce6e93.

}
Original file line number Diff line number Diff line change
Expand Up @@ -761,17 +761,21 @@ 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;
}

return previousState with
{
IconName = newIconName,
IconVariant = newIconVariant
IconVariant = newIconVariant,
CustomIconData = newCustomIconData
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Dashboard/ResourceSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Hosting/Dashboard/proto/Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

////////////////////////////////////////////
Expand Down
114 changes: 114 additions & 0 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2339,6 +2339,45 @@ public static IResourceBuilder<T> WithIconName<T>(this IResourceBuilder<T> build
return builder.WithAnnotation(new ResourceIconAnnotation(iconName, iconVariant));
}

/// <summary>
/// Specifies a custom icon to display for the resource in the dashboard using custom icon data.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="iconData">The icon data, which can be SVG content or a data URI (e.g., data:image/png;base64,...).</param>
/// <param name="iconName">Optional name for the icon for reference purposes.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <example>
/// Use a custom SVG icon from an embedded resource:
/// <code lang="C#">
/// var svgContent = "&lt;svg width='24' height='24'&gt;&lt;circle cx='12' cy='12' r='10'/&gt;&lt;/svg&gt;";
/// var myService = builder.AddContainer("myservice", "myimage")
/// .WithIconName(svgContent, "MyIcon");
/// </code>
/// </example>
/// <example>
/// Use a custom PNG icon from an embedded resource:
/// <code lang="C#">
/// 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");
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<T> WithIconName<T>(this IResourceBuilder<T> builder, string iconData, string? iconName) where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(iconData);

return builder.WithAnnotation(new ResourceIconAnnotation(iconData, iconName));
}

/// <summary>
/// Configures the compute environment for the compute resource.
/// </summary>
Expand Down Expand Up @@ -2513,4 +2552,79 @@ private static IResourceBuilder<T> WithProbe<T>(this IResourceBuilder<T> builder

return builder.WithAnnotation(probeAnnotation);
}

/// <summary>
/// Specifies a custom icon to display for the resource in the dashboard using an icon file.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="iconPath">The path to the icon file (SVG, PNG, ICO, JPG, etc.). Relative paths are resolved relative to the app host project directory.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <example>
/// Use a custom SVG icon from a file:
/// <code lang="C#">
/// var builder = DistributedApplication.CreateBuilder(args);
/// var myService = builder.AddContainer("myservice", "myimage")
/// .WithResourceIcon("./icons/myicon.svg");
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<T> WithResourceIcon<T>(this IResourceBuilder<T> builder, string iconPath) where T : IResource
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a function WithIconName already exists, don't duplicate

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you must get rid of this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the duplicate method in commit 6ce6e93.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in commit 6ce6e93.

{
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));
}

}
46 changes: 46 additions & 0 deletions tests/Aspire.Dashboard.Tests/Model/ResourceIconHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,50 @@ public void GetIconForResource_WithDatabaseInResourceType_ReturnsDatabaseIcon()
// Should match the database special case
}

[Fact]
public void GetIconForResource_WithCustomIconData_ReturnsCustomResourceIcon()
{
// Arrange
var svgContent = "<svg><circle cx='50' cy='50' r='40'/></svg>";
var resource = ModelTestHelpers.CreateResource(customIconData: svgContent);

// Act
var icon = ResourceIconHelpers.GetIconForResource(_iconResolver, resource, IconSize.Size20);

// Assert
Assert.NotNull(icon);
Assert.IsType<CustomResourceIcon>(icon);
}

[Fact]
public void GetIconForResource_WithCustomIconDataAndIconName_PrioritizesCustomData()
{
// Arrange
var svgContent = "<svg><rect width='24' height='24'/></svg>";
var resource = ModelTestHelpers.CreateResource(iconName: "Database", customIconData: svgContent);

// Act
var icon = ResourceIconHelpers.GetIconForResource(_iconResolver, resource, IconSize.Size20);

// Assert
Assert.NotNull(icon);
Assert.IsType<CustomResourceIcon>(icon);
// Custom data takes precedence over icon name
}

[Fact]
public void GetIconForResource_WithDataUriCustomIconData_ReturnsCustomResourceIcon()
{
// 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<CustomResourceIcon>(icon);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ private static GenericResourceSnapshot CreateResourceSnapshot(string name)
IsHidden = false,
SupportsDetailedTelemetry = false,
IconName = null,
IconVariant = null
IconVariant = null,
CustomIconData = null
};
}

Expand Down
Loading