From 6bcb5b1e0c0e013e1fe49778a47bb038cc7a300b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Fri, 6 Nov 2026 15:10:21 -0800 Subject: [PATCH] Rename the WithTags trait from Aspire_Dashboard_Tag to Aspire_Tag (#21881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Renames the reserved trait used by `WithTags` from `Aspire_Dashboard_Tag` to `Aspire_Tag`. AppHost tags aren't dashboard-only. The CLI backchannel adds traits to resource snapshots the same way the dashboard does, so `aspire describe --format json` already includes them: ```json "properties": { "resource.trait.Aspire_Tag": ["frontend", "critical"] } ``` With the old name, tools reading that output had to know a dashboard-scoped name for something that isn't dashboard-specific, and the method name (`WithTags`) and the trait name didn't match in scope. ### What changed - `KnownResourceTraits.DashboardTag = "Aspire_Dashboard_Tag"` → `KnownResourceTraits.Tag = "Aspire_Tag"`, with a comment explaining the naming. - The `Aspire_` prefix stays so the reserved name can't collide with user traits added through `WithTrait` (a bare `tag` would pick up tag validation and dashboard tag behavior). - `Aspire_Dashboard_Position` / `WithDashboardPosition` are unchanged: only the dashboard uses a position, so the name matches the scope. - The `TraitAnnotation` validation message now says "Tags must…" instead of "Dashboard tags must…". - `docs/specs/dashboard-persistence.md` uses the new name, says that the tags appear in `aspire describe`, and no longer says that user-added tags live in the settings database (they're in browser local storage since #21840). - The resource string key `ResourceTraitDashboardTags` keeps its name. Renaming it would mean editing the resx and Designer files and regenerating the `.xlf` files, and users never see the key. ### Reviewer notes - **No compatibility shim.** The trait was added in #21412 and isn't in any release tag yet, so nothing shipped reads the old name. ### Validation - `Aspire.Hosting.Tests` (`ResourceTraitBuilderTests`, `DashboardServiceDataTerminalTests`): 16 passed. - `Aspire.Dashboard.Tests` (`ResourceTagStoreTests`): 20 passed. - `Aspire.Dashboard.Components.Tests` (`ResourcesLayoutTests`, `ResourceDetailsTests`): 168 passed. - `Aspire.Dashboard.Playwright.Tests` builds. Its tests weren't run locally. ## Checklist - Is this feature complete? - [x] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [x] Yes - [ ] No - Did you add public API? - [ ] Yes - [x] No - Does the change make any security assumptions or guarantees? - [ ] Yes - [x] No Co-authored-by: Sébastien Ros <1165805+sebastienros@users.noreply.github.com> (cherry picked from commit 09f1abab775574a20bc5ac31e03675a255c13a86) --- docs/specs/dashboard-persistence.md | 10 ++++++---- src/Aspire.Dashboard/Model/ResourceViewModel.cs | 6 +++--- .../ApplicationModel/TraitAnnotation.cs | 4 ++-- src/Aspire.Hosting/ResourceTraitBuilderExtensions.cs | 2 +- src/Shared/Model/KnownProperties.cs | 4 +++- .../Controls/ResourceDetailsTests.cs | 2 +- .../Layout/ResourcesLayoutTests.cs | 6 +++--- .../Playwright/ResourcePaneResponsiveTests.cs | 2 +- .../Model/ResourceTagStoreTests.cs | 2 +- .../Dashboard/DashboardServiceDataTerminalTests.cs | 4 ++-- .../ResourceTraitBuilderTests.cs | 12 ++++++------ 11 files changed, 29 insertions(+), 25 deletions(-) diff --git a/docs/specs/dashboard-persistence.md b/docs/specs/dashboard-persistence.md index 77c76341a96..8f5282a4a63 100644 --- a/docs/specs/dashboard-persistence.md +++ b/docs/specs/dashboard-persistence.md @@ -74,10 +74,12 @@ builder.AddProject("api") `WithTags` accepts a single tag or multiple tags using `params ReadOnlySpan`, avoiding a temporary argument array in C#. Polyglot SDKs accept an array of tags. -It uses the reserved trait `Aspire_Dashboard_Tag`. These tags participate in -dashboard tag grouping but cannot be removed in the dashboard. User-added tags are -stored separately in the application settings database, so restarting an AppHost -does not lose customizations or copy declared tags from a different run. +It uses the reserved trait `Aspire_Tag`, which is not dashboard-specific: it also +appears as the `resource.trait.Aspire_Tag` property in `aspire describe --format json`. +These tags participate in dashboard tag grouping but cannot be removed in the +dashboard. User-added tags are stored separately in browser local storage, so +restarting an AppHost does not lose customizations or copy declared tags from a +different run. `WithDashboardPosition` uses `Aspire_Dashboard_Position`; repeated calls replace the previous value. Lower positions appear first; defaults can change between runs until diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index 53f7ab22a6d..b65990eb019 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -60,7 +60,7 @@ public IReadOnlyList GetTraitValues(string name) => ? property.Value.ListValue.Values.Where(v => v.HasStringValue).Select(v => v.StringValue).ToArray() : []; - internal IReadOnlyList AppHostTags => GetTraitValues(KnownResourceTraits.DashboardTag); + internal IReadOnlyList AppHostTags => GetTraitValues(KnownResourceTraits.Tag); internal int? DefaultDashboardPosition { @@ -407,11 +407,11 @@ public sealed class DisplayedResourcePropertyViewModel : IPropertyGridItem public int SortOrder => _propertyViewModel.SortOrder; public bool IsTrait => _propertyViewModel.IsTrait; - // The AppHost sends the raw trait name as the display name, e.g. "Aspire_Dashboard_Tag". Reserved dashboard + // The AppHost sends the raw trait name as the display name, e.g. "Aspire_Tag". Reserved Aspire // traits get a readable, localized name instead. public string DisplayName => _propertyViewModel.Name switch { - KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag => _loc[nameof(Resources.Resources.ResourceTraitDashboardTags)], + KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag => _loc[nameof(Resources.Resources.ResourceTraitDashboardTags)], KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardPosition => _loc[nameof(Resources.Resources.ResourceTraitDashboardPosition)], _ => _propertyViewModel.DisplayName ?? _propertyViewModel.KnownProperty?.GetDisplayName(_loc) ?? _propertyViewModel.Name }; diff --git a/src/Aspire.Hosting/ApplicationModel/TraitAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/TraitAnnotation.cs index 701845e4581..50699a909b2 100644 --- a/src/Aspire.Hosting/ApplicationModel/TraitAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/TraitAnnotation.cs @@ -21,10 +21,10 @@ public TraitAnnotation(string name, string value) ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrWhiteSpace(value); - if (name == KnownResourceTraits.DashboardTag && + if (name == KnownResourceTraits.Tag && (value.Length > 50 || value != string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)))) { - throw new ArgumentException("Dashboard tags must be at most 50 characters and use single spaces between words, without leading or trailing whitespace.", nameof(value)); + throw new ArgumentException("Tags must be at most 50 characters and use single spaces between words, without leading or trailing whitespace.", nameof(value)); } if (name == KnownResourceTraits.DashboardPosition && !int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) { diff --git a/src/Aspire.Hosting/ResourceTraitBuilderExtensions.cs b/src/Aspire.Hosting/ResourceTraitBuilderExtensions.cs index b643a9d33bc..2e19bc60315 100644 --- a/src/Aspire.Hosting/ResourceTraitBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceTraitBuilderExtensions.cs @@ -46,7 +46,7 @@ public static IResourceBuilder WithTags(this IResourceBuilder builder, foreach (var tag in tags) { - builder.WithTrait(KnownResourceTraits.DashboardTag, tag); + builder.WithTrait(KnownResourceTraits.Tag, tag); } return builder; diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 08602f3ce81..3b6c9ac3dc5 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -111,6 +111,8 @@ internal static class KnownResourcePropertySortOrder internal static class KnownResourceTraits { - public const string DashboardTag = "Aspire_Dashboard_Tag"; + // Tags are not dashboard-only: they also appear in `aspire describe` output and other tooling, so the + // name has no "Dashboard" scope. The "Aspire_" prefix keeps it apart from user traits added with WithTrait. + public const string Tag = "Aspire_Tag"; public const string DashboardPosition = "Aspire_Dashboard_Position"; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index aaa91c2389e..047efba17be 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -659,7 +659,7 @@ public void Render_AppHostTraits_ShowInDefaultResourceDetailsWithReadableNamesAn var properties = new[] { Trait("Owner", "Finance", "Platform"), - Trait(KnownResourceTraits.DashboardTag, "Backend", "API"), + Trait(KnownResourceTraits.Tag, "Backend", "API"), Trait(KnownResourceTraits.DashboardPosition, "10"), new ResourcePropertyViewModel("provider.diagnostic", Value.ForString("hidden"), false, null, int.MaxValue, null, false) }.ToDictionary(p => p.Name); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs index c896991bd5e..418dd99affc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs @@ -739,7 +739,7 @@ public void AppHostTagsParticipateInGroupingWithoutUserTags() { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var channel = Channel.CreateUnbounded>(); - var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag; + var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag; ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, initialResources: [ @@ -767,7 +767,7 @@ public void Grouping_ParametersJoinTagGroupsAndKeepTheirSectionOtherwise(string { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var channel = Channel.CreateUnbounded>(); - var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag; + var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag; Dictionary Tags(params string[] tags) => new() { [name] = new(name, Value.ForList([.. tags.Select(Value.ForString)]), false, null, 0, null, false) @@ -1364,7 +1364,7 @@ static CommandViewModel Command(string name, CommandViewModelState state) => public void Relationships_KeepParentWaitAndReferenceMeaningInRepeatedTagMemberships() { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - var tagKey = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag; + var tagKey = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag; var resource = ModelTestHelpers.CreateResource("catalogdb", properties: new() { [KnownProperties.Resource.ParentName] = new(KnownProperties.Resource.ParentName, Value.ForString("postgres"), false, null, 0, null, false), diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 3e24863419d..cc2f176c06a 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -2248,7 +2248,7 @@ public static IReadOnlyList CreateResources() => private static ResourceViewModel Create(string name, string type, KnownResourceState state, HealthStatus? health, DateTime? startTime, ImmutableArray urls, string[] tags) { - var tagKey = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag; + var tagKey = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag; var properties = ImmutableDictionary.Empty.Add(tagKey, new(tagKey, Value.ForList([.. tags.Select(Value.ForString)]), false, null, 0, null, false)); if (type != KnownResourceTypes.Parameter) diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceTagStoreTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceTagStoreTests.cs index 5a4138b3b81..ff76197ce0e 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceTagStoreTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceTagStoreTests.cs @@ -30,7 +30,7 @@ public void AppHostTagsAreMergedWithoutPersistingDeclaredTags() var store = CreateStore(); store.AddTag("api", "backend"); store.AddTag("api", "Personal"); - var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.DashboardTag; + var name = KnownProperties.Resource.TraitPrefix + KnownResourceTraits.Tag; var resource = ModelTestHelpers.CreateResource("api", properties: new() { [name] = new(name, Value.ForList(Value.ForString("Backend"), Value.ForString("API")), false, null, 0, null, false) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs index 242d85c6b9d..8c3db3c2f23 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceDataTerminalTests.cs @@ -33,13 +33,13 @@ public async Task Snapshot_WithRepeatedTraits_PreservesAllValuesAlongsideTermina using var _ = data; resource.Annotations.Add(new TraitAnnotation("Owner", "Finance, Inc.")); resource.Annotations.Add(new TraitAnnotation("Owner", "Platform")); - resource.Annotations.Add(new TraitAnnotation("Aspire_Dashboard_Tag", "Backend")); + resource.Annotations.Add(new TraitAnnotation("Aspire_Tag", "Backend")); await notifications.PublishUpdateAsync(resource, s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout(); var snapshot = await data.WaitForResourceAsync(resource.Name, r => r.Properties.Any(p => p.Name == "resource.trait.Owner")).DefaultTimeout(); var owner = Assert.Single(snapshot.Properties, p => p.Name == "resource.trait.Owner"); Assert.Equal(["Finance, Inc.", "Platform"], owner.Value.ListValue.Values.Select(v => v.StringValue)); - Assert.Equal(["Backend"], Assert.Single(snapshot.Properties, p => p.Name == "resource.trait.Aspire_Dashboard_Tag").Value.ListValue.Values.Select(v => v.StringValue)); + Assert.Equal(["Backend"], Assert.Single(snapshot.Properties, p => p.Name == "resource.trait.Aspire_Tag").Value.ListValue.Values.Select(v => v.StringValue)); Assert.Equal("true", GetTerminalProperty(snapshot, "terminal.enabled").Value.StringValue); } diff --git a/tests/Aspire.Hosting.Tests/ResourceTraitBuilderTests.cs b/tests/Aspire.Hosting.Tests/ResourceTraitBuilderTests.cs index 9d72754af9e..06b3be329c0 100644 --- a/tests/Aspire.Hosting.Tests/ResourceTraitBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceTraitBuilderTests.cs @@ -20,8 +20,8 @@ public void RepeatedTraitsAndTagsPreserveAllValues() Assert.Equal( [ - (KnownResourceTraits.DashboardTag, "Backend"), - (KnownResourceTraits.DashboardTag, "API"), + (KnownResourceTraits.Tag, "Backend"), + (KnownResourceTraits.Tag, "API"), ("Owner", "Finance"), ("Owner", "Platform"), (KnownResourceTraits.DashboardPosition, "-10") @@ -32,8 +32,8 @@ public void RepeatedTraitsAndTagsPreserveAllValues() [Theory] [InlineData("", "value")] [InlineData("Owner", " ")] - [InlineData("Aspire_Dashboard_Tag", " Backend")] - [InlineData("Aspire_Dashboard_Tag", "Back end")] + [InlineData("Aspire_Tag", " Backend")] + [InlineData("Aspire_Tag", "Back end")] [InlineData("Aspire_Dashboard_Position", "invalid")] [InlineData("Aspire_Dashboard_Position", "2147483648")] public void InvalidTraitsAreRejected(string name, string value) @@ -53,7 +53,7 @@ public void TraitsProduceListValuedSnapshotProperties() var properties = ResourceTraitSnapshotProperties.AddTraitProperties(resource.Resource, [source]); Assert.Same(source, properties[0]); - Assert.Equal(["existing", "resource.trait.Aspire_Dashboard_Tag", "resource.trait.Owner"], properties.Select(p => p.Name)); + Assert.Equal(["existing", "resource.trait.Aspire_Tag", "resource.trait.Owner"], properties.Select(p => p.Name)); Assert.Equal(["Backend", "API"], Assert.IsType(properties[1].Value)); Assert.Equal(["Finance, Inc.", "Platform"], Assert.IsType(properties[2].Value)); Assert.Equal("Owner", properties[2].DisplayName); @@ -67,7 +67,7 @@ public void PositionTraitReplacesOnlyPositionAndValidatesBeforeReplacing() Assert.Throws(() => resource.WithTrait(KnownResourceTraits.DashboardPosition, "invalid")); resource.WithTrait(KnownResourceTraits.DashboardPosition, "20"); - Assert.Equal([(KnownResourceTraits.DashboardTag, "Backend"), (KnownResourceTraits.DashboardPosition, "20")], + Assert.Equal([(KnownResourceTraits.Tag, "Backend"), (KnownResourceTraits.DashboardPosition, "20")], resource.Resource.Annotations.OfType().Select(t => (t.Name, t.Value))); }