From fa3881a18d43a2f1dd3d90e2915ce10987f121b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:34:06 -0800 Subject: [PATCH 1/5] Restore single-line compact rows in the dashboard resource list #21727 removed the compact branch of the resource row and stacked compact rows into two lines, so Compact became a shorter Comfortable with "Running" text, and later picked up the availability badges from #21819. It also dropped Spacious's third detail line, leaving the three densities different only in row height and font size. Compact rows are one line again: the name, then aligned type and state columns. In the sidebar, actions replace the columns while a row is hovered or focused or its menu is open; the parent expand toggle stays visible and touch screens always show the actions. The expanded list keeps its actions visible and widens its identity column for the extra columns. Availability is shown by the muted row and its tooltip, and error badges sit before the columns. Spacious sidebar rows show a third line again with an unhealthy health status, the primary endpoint (query values masked) or the source. --- .../Components/Layout/ResourcesLayout.razor | 63 ++++++- .../Layout/ResourcesLayout.razor.css | 168 +++++++++++++++--- .../Layout/ResourceRowAvailabilityTests.cs | 26 +++ .../Layout/ResourcesLayoutTests.cs | 42 +++-- 4 files changed, 263 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index cf58e236c98..8e565c13b8c 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -999,13 +999,44 @@ @onclick:preventDefault="true"> - + Value="@ResourceIconHelpers.GetIconForResource(IconResolver, resource, IsSpacious ? IconSize.Size20 : IconSize.Size16)" /> + @if (IsCompact) + { + @* One line per resource: the name, then aligned columns for type and state, so they line up from + row to row. The state is a dot; its name is in the tooltip and the link's accessible description. + There is no room for the availability badge on one line, so an unavailable row relies on its muted + colors and on the reason in its tooltip and accessible description. *@ + + + @if (resource.IsResourceHidden(showHiddenResources: false)) + { + + } + + @if (errorCount > 0) + { + @* Reserves the badge's width before the columns. The real badge, a sibling link, is overlaid here. *@ + + } + + @GetResourceTypeLabel(resource) + + + + + } + else + { @@ -1040,12 +1071,38 @@ @GetResourceTypeLabel(resource) } + @if (IsSpacious && !_isPaneFullCanvas && !resource.IsParameter) + { + @* The third line of a spacious row: whatever needs attention first, an unhealthy status, then + where to reach the resource, then where it comes from. The full canvas has columns for these. *@ + @if (resource.HealthStatus is { } healthStatus && + healthStatus != Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy) + { + + @healthStatus.Humanize() + + } + else if (primaryUrl is not null) + { + var maskedPrimaryAddress = DashboardUIHelpers.MaskQueryStringValues(primaryUrl.OriginalUrlString); + + @(primaryUrl.DisplayName is { Length: > 0 } primaryUrlName ? primaryUrlName : maskedPrimaryAddress) + + } + else if (ResourceSourceViewModel.GetSourceViewModel(resource) is { } detailSource) + { + + @detailSource.Value + + } + } @if (errorCount > 0) { - @* Reserves the badge's width in the wide grid, where the real badge overlays the end of this link. *@ + @* Reserves the badge's width in the grid layouts, where the real badge overlays the end of this link. *@ } + } @if (errorCount > 0) { diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index b8f32aa8812..f3482838ad7 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -30,8 +30,16 @@ background: var(--aspire-page-background); } +/* Compact rows are a single line, so they're sized for the 28px row actions rather than for two text bands. */ .resource-pane-list.density-compact { - --resource-row-height: var(--aspire-density-resource-row-height, 44px); + --resource-row-height: var(--aspire-density-resource-row-height, 32px); + --resource-row-icon-size: 22px; + /* The type column of the compact rows. See the persistent override below. */ + --resource-row-meta-type-width: 64px; + /* The full canvas's identity column holds only the name in the other densities. A compact row also puts its type + and state columns there, so the column grows by their width (type + 8px gap + 10px state + 8px gap) rather + than taking it from the name. */ + --resource-row-identity-extra: calc(var(--resource-row-meta-type-width) + 26px); --resource-row-font-size: var(--fontSizeBase200); --resource-row-line-height: var(--lineHeightBase200); --resource-secondary-font-size: 11px; @@ -44,8 +52,27 @@ --resource-insight-failure-width: 32px; } +/* Persistent types, such as "RabbitMQ (persistent)", are wider than the others, so every row's type column widens + together and the columns stay aligned. 100cqw is the width of the resource-pane container. The full canvas keeps + 64px: its fixed tracks would otherwise have to give up endpoint width at the narrower breakpoints. + On touch screens the 112px action rail is always showing, so names come first: the column stays at 64px at the + default 420px pane width and only widens with the room a wider pane adds, reaching 128px at 484px. */ +.resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact:has(.resource-row-type-persistent) { + --resource-row-meta-type-width: clamp(64px, calc(100cqw - 356px), 128px); +} + +/* Where rows can be hovered, a row at rest narrows the action rail to 20px (see "Compact sidebar rows show their + actions only while the row is hovered" below), which + leaves room for the full 128px column while still keeping about 180px for names at the default pane width. */ +@media (hover: hover) { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact:has(.resource-row-type-persistent) { + --resource-row-meta-type-width: clamp(48px, calc(100cqw - 180px), 128px); + } +} + .resource-pane-list.density-spacious { --resource-row-height: var(--aspire-density-resource-row-height, 60px); + --resource-row-icon-size: 40px; --resource-row-font-size: var(--fontSizeBase400); --resource-row-line-height: var(--lineHeightBase400); --resource-secondary-font-size: var(--fontSizeBase300); @@ -1953,17 +1980,17 @@ } /* Compact density: one line per resource. The state, type and persistence move to fixed-width columns on the - right, so they line up from row to row. */ + right, so they line up from row to row. The sidebar and full-canvas rules further down assume two text bands; + the "Compact density overrides" section after them puts everything back on one line. */ .density-compact .resource-row-link { gap: 8px; - padding-top: 3px; - padding-bottom: 3px; } .density-compact .resource-row-icon { flex-basis: var(--resource-row-icon-size); width: var(--resource-row-icon-size); height: var(--resource-row-icon-size); + border-radius: 6px; } .density-spacious .resource-row-link { @@ -1978,12 +2005,6 @@ height: var(--resource-row-icon-size); } -.density-compact .resource-row-text { - flex-direction: column; - align-items: flex-start; - gap: 0; -} - .density-compact .resource-row-text ::deep .resource-row-hidden-icon { flex-shrink: 0; } @@ -2020,7 +2041,7 @@ } .resource-row-meta .resource-row-type { - width: 64px; + width: var(--resource-row-meta-type-width); text-align: right; white-space: nowrap; } @@ -2262,7 +2283,7 @@ @container resource-pane (min-width: 540px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(160px, 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 98px [end]; + --resource-row-tracks: [identity] minmax(calc(160px + var(--resource-row-identity-extra, 0px)), 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 98px [end]; } .resource-row-endpoints { @@ -2280,11 +2301,6 @@ } } -/* Keep type columns aligned, reserving room for resource names when the pane is narrow. */ -.density-compact:has(.resource-row-type-persistent) .resource-row-meta .resource-row-type { - width: clamp(48px, calc(100cqw - 180px), 128px); -} - .density-compact .resource-row-toggle, .density-compact .resource-row-toggle-spacer { margin-right: 2px; @@ -2430,7 +2446,7 @@ @container resource-pane (min-width: 720px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(200px, 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 180px [end]; + --resource-row-tracks: [identity] minmax(calc(200px + var(--resource-row-identity-extra, 0px)), 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 180px [end]; column-gap: var(--spacingHorizontalM); } @@ -2467,7 +2483,7 @@ @container resource-pane (min-width: 960px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(220px, 320px) [status] 180px [endpoint] minmax(96px, 1fr) [source] 112px [insights actions] 244px [end]; + --resource-row-tracks: [identity] minmax(calc(220px + var(--resource-row-identity-extra, 0px)), calc(320px + var(--resource-row-identity-extra, 0px))) [status] 180px [endpoint] minmax(96px, 1fr) [source] 112px [insights actions] 244px [end]; column-gap: var(--spacingHorizontalS); } @@ -2592,8 +2608,25 @@ flex-shrink: 0; } +/* A spacious sidebar row adds a third, smaller band for its health, endpoint or source. The row grows to fit it, + and rows without one (parameters) keep two bands centered in the same height. */ +.resources-layout:not(.pane-full-canvas) .resource-pane-list.density-spacious { + --resource-row-height: var(--aspire-density-resource-row-height, calc(2 * var(--resource-row-line-height) + var(--resource-secondary-line-height) + 12px)); +} + +.resources-layout:not(.pane-full-canvas) .resource-row-link:has(.resource-row-detail) { + padding-block: calc((var(--resource-row-height) - 2 * var(--resource-row-line-height) - var(--resource-secondary-line-height)) / 2); +} + +.resources-layout:not(.pane-full-canvas) .resource-row-text:has(> .resource-row-detail) { + grid-template-rows: repeat(2, var(--resource-row-line-height)) var(--resource-secondary-line-height); +} + .resources-layout:not(.pane-full-canvas) .resource-row-detail { - display: none; + min-width: 0; + height: var(--resource-secondary-line-height); + font-size: var(--resource-secondary-font-size); + line-height: var(--resource-secondary-line-height); } .resources-layout:not(.pane-full-canvas) .resource-row-actions { @@ -2675,6 +2708,95 @@ display: none; } +/* Compact density overrides. The sidebar rules above and the full-canvas rules below lay a row's text out in two + fixed bands; a compact row has one. These selectors match the specificity of the sidebar ones and come after + them, so they win in both layouts. */ +.resource-pane-list.density-compact .resource-row-link { + padding-block: 0; +} + +.resource-pane-list.density-compact .resource-row-text { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; +} + +.resource-pane-list.density-compact .resource-row-name { + flex: 0 1 auto; + min-width: 0; +} + +/* Telemetry-only rows and the "All" row have no type/state columns. Their secondary label (the kind, or the counts) + moves to the end of the line, and the name gives way first when space runs out. */ +.resource-pane-list.density-compact .resource-row-title { + flex: 0 1 auto; + width: auto; + height: auto; +} + +.resource-pane-list.density-compact .resource-row-sub { + flex: 0 0 auto; + height: auto; + margin-inline-start: auto; +} + +/* The availability badge doesn't fit on one line. The row is muted instead, and its tooltip and accessible + description give the reason. App-model rows don't render the badge in compact density at all. */ +.resource-pane-list.density-compact .resource-row-availability { + display: none; +} + +/* The error badge is overlaid on the row link (see .resource-row-error-link). In a compact row it goes over its + spacer, just before the type and state columns, instead of over the columns at the end of the link: + 8px link end padding + type column + 8px gap + 10px state column + 8px gap. */ +.resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row-error-link { + margin-inline-end: calc(var(--resource-row-meta-type-width) + 34px); +} + +@container resource-pane (min-width: 420px) { + .resource-pane-list.density-compact .resource-row:has(> .resource-row-wide-metadata) > .resource-row-error-link { + margin-inline-end: calc(var(--resource-row-meta-type-width) + 34px); + } +} + +/* Compact sidebar rows show their actions only while the row is hovered or focused, or while one of its menus is + open, and then in place of the type and state columns. The rest of the time the action rail narrows to the + expand toggle (or the spacer that keeps the columns aligned), so the name gets the rail's room. The swap keeps the + name's width about the same: the 92px the rail gains is about the 90px the columns give up. + An open menu is matched by aria-haspopup as well as aria-expanded: a parent's expand toggle also sets + aria-expanded="true", and that must not keep its actions showing. Only the attribute's presence is tested + because Fluent's menu changes the button's aria-haspopup from "menu" to "true" while the menu is open. + Touch screens can't hover, so (hover: hover) leaves their actions always visible. The expanded list (the full + canvas) is excluded and keeps its fixed, always-visible action slots. */ +@media (hover: hover) { + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) { + grid-template-columns: minmax(0, 1fr) 20px; + } + + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions { + grid-template-columns: 20px; + width: 20px; + min-width: 20px; + max-width: 20px; + } + + /* Every action has .resource-row-action. The menus also have a wrapper that occupies a grid slot. */ + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions ::deep .resource-row-action, + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions ::deep .aspire-menu-button { + display: none; + } + + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-meta { + display: none; + } + + /* With the columns gone, the error badge's spacer ends the row link again, so the badge goes back to the end. */ + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-error-link { + margin-inline-end: 8px; + } +} + .resource-tree-guide, .resource-tree-stem { position: absolute; @@ -2740,7 +2862,7 @@ @container resource-pane (min-width: 1440px) { /* Only the endpoint track absorbs spare width; activity stays next to actions. */ .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] 260px [status] 180px [endpoint] minmax(200px, 1fr) [source] 112px [insights] var(--resource-insight-width) [actions] 244px [end]; + --resource-row-tracks: [identity] calc(260px + var(--resource-row-identity-extra, 0px)) [status] 180px [endpoint] minmax(200px, 1fr) [source] 112px [insights] var(--resource-insight-width) [actions] 244px [end]; } ::deep .resource-row-insights { @@ -2779,13 +2901,13 @@ @container resource-pane (min-width: 1600px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] 320px [status] 180px [endpoint] minmax(280px, 1fr) [source] 112px [insights] var(--resource-insight-width) [actions] 244px [end]; + --resource-row-tracks: [identity] calc(320px + var(--resource-row-identity-extra, 0px)) [status] 180px [endpoint] minmax(280px, 1fr) [source] 112px [insights] var(--resource-insight-width) [actions] 244px [end]; column-gap: var(--spacingHorizontalL); } } @container resource-pane (min-width: 1800px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] 320px [status] 180px [endpoint] minmax(240px, 1fr) [source] 352px [insights] var(--resource-insight-width) [actions] 244px [end]; + --resource-row-tracks: [identity] calc(320px + var(--resource-row-identity-extra, 0px)) [status] 180px [endpoint] minmax(240px, 1fr) [source] 352px [insights] var(--resource-insight-width) [actions] 244px [end]; } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceRowAvailabilityTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceRowAvailabilityTests.cs index 0289bc01fde..b2a34120b51 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceRowAvailabilityTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceRowAvailabilityTests.cs @@ -69,6 +69,32 @@ public async Task ParameterRows_ReflectCurrentTabAndRemainNavigable(string url, Assert.Empty(cut.FindAll("[data-resource-name='key'] .resource-row-availability")); } + [Fact] + public async Task CompactDensity_UnavailableRowKeepsReasonWithoutBadge() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, + initialResources: [ModelTestHelpers.CreateResource("test-TestId")], + resourceChannelProvider: Channel.CreateUnbounded>)); + var navigation = Services.GetRequiredService(); + navigation.NavigateTo("/structuredlogs"); + var cut = Render(p => p.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + Assert.Single(cut.FindAll("[data-resource-name='test-TestId'] .resource-row-availability")); + + await cut.InvokeAsync(() => Services.GetRequiredService().SetDensityAsync(ResourceListDensity.Compact)); + + // A single-line row has no room for the badge, so the row stays muted and keeps the reason in its tooltip + // and accessible description. + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-pane-list.density-compact"))); + var row = cut.Find("[data-resource-name='test-TestId']"); + var link = row.QuerySelector(".resource-row-link")!; + Assert.Empty(row.QuerySelectorAll(".resource-row-availability")); + Assert.True(row.ClassList.Contains("resource-row-unavailable")); + Assert.Equal(Resources.Layout.ResourcePaneNoStructuredLogs, link.GetAttribute("data-tooltip")); + Assert.Contains(Resources.Layout.ResourcePaneNoStructuredLogs, link.GetAttribute("aria-description")); + } + [Theory] [InlineData(AspireDataType.StructuredLogs, "/structuredlogs", "/structuredlogs/resource/test-TestId")] [InlineData(AspireDataType.Traces, "/traces", "/traces/resource/test-TestId")] diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs index c896991bd5e..380c97e2803 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs @@ -405,10 +405,12 @@ public async Task WideRowMetadata_PreservesRuntimeStateAndAccessibleHealthWithRe var cut = Render(p => p.AddCascadingValue(viewport)); cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); await cut.InvokeAsync(() => Services.GetRequiredService().SetDensityAsync(density)); + var compact = density is ResourceListDensity.Compact; Assert.Empty(cut.FindAll(".resource-row-wide-metadata")); - Assert.Equal("Running", cut.Find("[data-resource-name='api'] .state-dot + span").TextContent); - Assert.Equal("10 minutes ago", cut.Find("[data-resource-name='api'] time").TextContent); - Assert.Equal("Finished", cut.Find("[data-resource-name='finished'] .state-dot + span").TextContent); + Assert.Equal("Running", GetStateText(cut.Find("[data-resource-name='api']"))); + // A compact sidebar row is a single line without the start time. The expanded list below has a column for it. + Assert.Equal(compact ? [] : ["10 minutes ago"], cut.FindAll("[data-resource-name='api'] time").Select(time => time.TextContent)); + Assert.Equal("Finished", GetStateText(cut.Find("[data-resource-name='finished']"))); Assert.Empty(cut.FindAll(".resource-pane-canvas-toggle")); ResourceSetupHelpers.ToggleResourceList(cut); @@ -425,7 +427,7 @@ public async Task WideRowMetadata_PreservesRuntimeStateAndAccessibleHealthWithRe Assert.Equal("+1", row.QuerySelector(".resource-row-more-endpoints")!.TextContent.Trim()); var health = row.QuerySelector(".resource-row-health")!; Assert.Empty(row.QuerySelectorAll(".resource-row-health-status")); - Assert.Equal("Running", row.QuerySelector(".state-dot + span")!.TextContent); + Assert.Equal("Running", GetStateText(row)); Assert.Contains("Healthy", row.QuerySelector(".resource-row-link")!.GetAttribute("aria-description")); Assert.Empty(cut.FindAll(".resource-pane-canvas-toggle")); var time = health.QuerySelector(".resource-row-start-time time")!; @@ -450,6 +452,11 @@ public async Task WideRowMetadata_PreservesRuntimeStateAndAccessibleHealthWithRe ResourceSetupHelpers.ToggleResourceList(cut); cut.WaitForAssertion(() => Assert.Equal("Started 11 minutes ago", cut.Find("[data-resource-name='api'] .resource-row-health time").TextContent.Trim())); + + // Compact rows show the state as a dot that names it in its tooltip. The other densities spell it out. + string? GetStateText(AngleSharp.Dom.IElement resourceRow) => compact + ? resourceRow.QuerySelector(".resource-row-meta-state")!.GetAttribute("data-tooltip") + : resourceRow.QuerySelector(".state-dot + span")!.TextContent; } [Theory] @@ -1100,7 +1107,7 @@ public async Task AppHost_ReorderingPreservesHierarchyAndAppendsNewSiblings() } [Fact] - public async Task CompactDensity_PreservesIdentityStatusAndPersistenceWithoutSidebarType() + public async Task CompactDensity_ShowsOneLineWithTypeAndStateColumns() { var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var channel = Channel.CreateUnbounded>(); @@ -1121,13 +1128,26 @@ public async Task CompactDensity_PreservesIdentityStatusAndPersistenceWithoutSid await cut.InvokeAsync(() => Services.GetRequiredService().SetDensityAsync(ResourceListDensity.Compact)); cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-pane-list.density-compact"))); - Assert.Equal(2, cut.FindAll("[data-resource-name] .resource-row-sub").Count); - Assert.Empty(cut.FindAll(".resource-row-type")); + AssertCompactRows(); Assert.Empty(cut.FindAll("[data-resource-name='api'] .persistent-container-badge")); Assert.Single(cut.FindAll("[data-resource-name='cache'] .persistent-container-badge")); + Assert.Equal("Running; Project; Health state: Healthy", cut.Find("[data-resource-name='api'] .resource-row-link").GetAttribute("aria-description")); + + // The expanded resource list keeps the same single-line identity next to its metadata columns. ResourceSetupHelpers.ToggleResourceList(cut); - Assert.Equal("Project", cut.Find("[data-resource-name='api'] .resource-row-type").TextContent); - Assert.Equal("Container (persistent)", cut.Find("[data-resource-name='cache'] .resource-row-type").TextContent); + AssertCompactRows(); + + // One line per resource: the name, then the type and state columns, with no second line of state text. + void AssertCompactRows() + { + Assert.Empty(cut.FindAll("[data-resource-name] .resource-row-sub")); + Assert.Equal("Project", Assert.Single(cut.FindAll("[data-resource-name='api'] .resource-row-meta .resource-row-type")).TextContent); + var cacheType = Assert.Single(cut.FindAll("[data-resource-name='cache'] .resource-row-meta .resource-row-type")); + Assert.Equal("Container (persistent)", cacheType.TextContent); + Assert.True(cacheType.ClassList.Contains("resource-row-type-persistent")); + Assert.Equal(["Running", "Running"], + cut.FindAll("[data-resource-name] .resource-row-meta-state").Select(state => state.GetAttribute("data-tooltip"))); + } } [Fact] @@ -2077,7 +2097,9 @@ public async Task AppHost_ContainerLifetimeQualifiesTypeAndBadgesResourceGlyph(s await cut.InvokeAsync(() => Services.GetRequiredService().SetDensityAsync(density)); - Assert.Empty(cut.FindAll("[data-resource-name='cache'] .resource-row-type")); + // Only compact sidebar rows have a type column; the other densities show the start time instead. + Assert.Equal(density is ResourceListDensity.Compact ? [expectedType] : [], + cut.FindAll("[data-resource-name='cache'] .resource-row-type").Select(type => type.TextContent)); Assert.Equal($"Running; {expectedType}; Health state: Healthy", cut.Find("[data-resource-name='cache'] .resource-row-link").GetAttribute("aria-description")); ResourceSetupHelpers.ToggleResourceList(cut); var type = cut.Find("[data-resource-name='cache'] .resource-row-type"); From 59ddf4f7c49d65f80fcc854f62f70fa820335b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:44:16 -0800 Subject: [PATCH 2/5] Address review feedback on compact and spacious resource rows - Fall back to any endpoint (including tcp:// endpoints that aren't links) for the spacious sidebar detail line, matching the full-canvas endpoint column. - Make the spacious endpoint and source detail keyboard-focusable, with the full value in its tooltip and accessible label, and a focus ring. - Add the compact type and state column width only to the full-canvas name track's maximum, so rows no longer overflow at 540-584, 720-730 and 960-998px. - Update the responsive Playwright tests for 32px compact rows, density-keyed icon sizes, the separate spacious sidebar and full-canvas heights, and hover/focus-revealed compact sidebar actions. Add a horizontal-overflow check and 960/990px stages. - Add bUnit coverage for the spacious detail priority: unhealthy status, then endpoint (masked), then source, omitted for parameters and the full canvas. --- .../Components/Layout/ResourcesLayout.razor | 25 +++- .../Layout/ResourcesLayout.razor.css | 18 ++- .../Layout/ResourcesLayoutTests.cs | 64 +++++++++ .../Playwright/ResourcePaneResponsiveTests.cs | 136 ++++++++++++------ 4 files changed, 192 insertions(+), 51 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor index 8e565c13b8c..6973f3499dd 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor @@ -1074,7 +1074,10 @@ @if (IsSpacious && !_isPaneFullCanvas && !resource.IsParameter) { @* The third line of a spacious row: whatever needs attention first, an unhealthy status, then - where to reach the resource, then where it comes from. The full canvas has columns for these. *@ + where to reach the resource, then where it comes from. The full canvas has columns for these. + The endpoint and source can be cut short, so like the start time above and the full canvas's + columns, they take keyboard focus to show their complete value in a tooltip. The health status + is short and is already in the link's accessible description. *@ @if (resource.HealthStatus is { } healthStatus && healthStatus != Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy) { @@ -1082,16 +1085,26 @@ @healthStatus.Humanize() } - else if (primaryUrl is not null) + else if ((primaryUrl ?? urls.FirstOrDefault()) is { } detailUrl) { - var maskedPrimaryAddress = DashboardUIHelpers.MaskQueryStringValues(primaryUrl.OriginalUrlString); - - @(primaryUrl.DisplayName is { Length: > 0 } primaryUrlName ? primaryUrlName : maskedPrimaryAddress) + @* Like the full canvas's endpoint column, this falls back to endpoints that can't be opened + as links, such as tcp:// ones: the line shows the address as text, not as a link. *@ + var maskedDetailAddress = DashboardUIHelpers.MaskQueryStringValues(detailUrl.OriginalUrlString); + + @(detailUrl.DisplayName is { Length: > 0 } detailUrlName ? detailUrlName : maskedDetailAddress) } else if (ResourceSourceViewModel.GetSourceViewModel(resource) is { } detailSource) { - + @detailSource.Value } diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index f3482838ad7..8cf6a50b98c 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -38,7 +38,11 @@ --resource-row-meta-type-width: 64px; /* The full canvas's identity column holds only the name in the other densities. A compact row also puts its type and state columns there, so the column grows by their width (type + 8px gap + 10px state + 8px gap) rather - than taking it from the name. */ + than taking it from the name. Only where the tracks can afford it, though: from the 1440px breakpoint the + identity track is fixed and the sum still fits, but below that the extra goes into the track's maximum, never + its minimum. Below 960px the identity track is flexible and has no maximum to raise. Adding it to the + minimums made the row wider than the pane at 540-584px, 720-730px and 960-998px, which pushed the actions out + of view. */ --resource-row-identity-extra: calc(var(--resource-row-meta-type-width) + 26px); --resource-row-font-size: var(--fontSizeBase200); --resource-row-line-height: var(--lineHeightBase200); @@ -2283,7 +2287,7 @@ @container resource-pane (min-width: 540px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(calc(160px + var(--resource-row-identity-extra, 0px)), 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 98px [end]; + --resource-row-tracks: [identity] minmax(160px, 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 98px [end]; } .resource-row-endpoints { @@ -2446,7 +2450,7 @@ @container resource-pane (min-width: 720px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(calc(200px + var(--resource-row-identity-extra, 0px)), 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 180px [end]; + --resource-row-tracks: [identity] minmax(200px, 1.5fr) [status] 104px [endpoint] minmax(96px, 1fr) [source insights actions] 180px [end]; column-gap: var(--spacingHorizontalM); } @@ -2483,7 +2487,7 @@ @container resource-pane (min-width: 960px) { .resource-row:has(> .resource-row-wide-metadata) { - --resource-row-tracks: [identity] minmax(calc(220px + var(--resource-row-identity-extra, 0px)), calc(320px + var(--resource-row-identity-extra, 0px))) [status] 180px [endpoint] minmax(96px, 1fr) [source] 112px [insights actions] 244px [end]; + --resource-row-tracks: [identity] minmax(220px, calc(320px + var(--resource-row-identity-extra, 0px))) [status] 180px [endpoint] minmax(96px, 1fr) [source] 112px [insights actions] 244px [end]; column-gap: var(--spacingHorizontalS); } @@ -2629,6 +2633,12 @@ line-height: var(--resource-secondary-line-height); } +/* The endpoint and source details take keyboard focus to show their full value, like .resource-row-source-value. */ +.resource-row-detail:focus-visible { + outline: 2px solid var(--colorStrokeFocus2); + outline-offset: 1px; +} + .resources-layout:not(.pane-full-canvas) .resource-row-actions { display: grid; grid-template-columns: 20px repeat(3, 28px); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs index 380c97e2803..63547030281 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourcesLayoutTests.cs @@ -1150,6 +1150,70 @@ void AssertCompactRows() } } + [Fact] + public async Task SpaciousSidebarDetail_PrefersUnhealthyStatusThenEndpointThenSource() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var channel = Channel.CreateUnbounded>(); + const string address = "https://example.test/api?token=synthetic-secret&mode=preview"; + const string tcpAddress = "tcp://example.test:5432?password=synthetic-secret"; + var projectPath = Path.Combine("workspace", "MyFrontend.csproj"); + UrlViewModel Url(string name, string url, string? displayName = null) => + new(name, new Uri(url), false, false, displayName is null ? UrlDisplayPropertiesViewModel.Empty : new(displayName, 0)); + ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true, + initialResources: + [ + ModelTestHelpers.CreateResource("unhealthy", state: KnownResourceState.Running, reportHealthStatus: Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, + urls: [Url("https", address, "Friendly API")]), + ModelTestHelpers.CreateResource("named", state: KnownResourceState.Running, urls: [Url("https", address, "Friendly API")]), + ModelTestHelpers.CreateResource("unnamed", state: KnownResourceState.Running, urls: [Url("https", address)]), + // tcp:// can't be opened as a link, so it isn't the row's primary URL, but it's still the endpoint to show. + ModelTestHelpers.CreateResource("tcp", state: KnownResourceState.Running, urls: [Url("tcp", tcpAddress)]), + ModelTestHelpers.CreateResource("project", state: KnownResourceState.Running, resourceType: KnownResourceTypes.Project, properties: new() + { + [KnownProperties.Project.Path] = new(KnownProperties.Project.Path, Value.ForString(projectPath), false, null, 0, null, false) + }), + ModelTestHelpers.CreateResource("key", resourceType: KnownResourceTypes.Parameter) + ], resourceChannelProvider: () => channel)); + var cut = Render(builder => builder.AddCascadingValue(viewport)); + cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded)); + Assert.Empty(cut.FindAll(".resource-row-detail")); + + await cut.InvokeAsync(() => Services.GetRequiredService().SetDensityAsync(ResourceListDensity.Spacious)); + + cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-pane-list.density-spacious"))); + // Query values can carry secrets, so the detail shows them masked, as the expanded list's endpoint column does. + const string maskedAddress = "https://example.test/api?token=●●●●●●●●&mode=●●●●●●●●"; + const string maskedTcpAddress = "tcp://example.test:5432?password=●●●●●●●●"; + + // An unhealthy status needs attention first, so it wins over the endpoint. It's short and already in the link's + // accessible description, so it doesn't take focus. + var unhealthy = GetDetail("unhealthy"); + Assert.Equal("Unhealthy", unhealthy.TextContent.Trim()); + Assert.Null(unhealthy.GetAttribute("tabindex")); + + AssertFocusableDetail(GetDetail("named"), "Friendly API", maskedAddress, $"Friendly API: {maskedAddress}"); + AssertFocusableDetail(GetDetail("unnamed"), maskedAddress, maskedAddress, $"https: {maskedAddress}"); + AssertFocusableDetail(GetDetail("tcp"), maskedTcpAddress, maskedTcpAddress, $"tcp: {maskedTcpAddress}"); + AssertFocusableDetail(GetDetail("project"), "MyFrontend.csproj", projectPath, $"Source: {projectPath}"); + Assert.Empty(cut.FindAll("[data-resource-name='key'] .resource-row-detail")); + + // The expanded list has endpoint and source columns instead. + ResourceSetupHelpers.ToggleResourceList(cut); + Assert.Empty(cut.FindAll(".resource-row-detail")); + + AngleSharp.Dom.IElement GetDetail(string resourceName) => + Assert.Single(cut.FindAll($"[data-resource-name='{resourceName}'] .resource-row-detail")); + + static void AssertFocusableDetail(AngleSharp.Dom.IElement detail, string text, string tooltip, string label) + { + Assert.Equal(text, detail.TextContent.Trim()); + Assert.Equal(tooltip, detail.GetAttribute("data-tooltip")); + Assert.Equal(label, detail.GetAttribute("aria-label")); + Assert.Equal("0", detail.GetAttribute("tabindex")); + } + } + [Fact] public async Task TerminalTab_NavigatesWithinTheSelectionAndFallsBackForNonTerminalResources() { diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index 3e24863419d..ccded8345dc 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -187,8 +187,10 @@ await RunTestAsync(async page => await page.SetViewportSizeAsync(2000, 1000); await page.EmulateMediaAsync(new() { ReducedMotion = ReducedMotion.Reduce }); await page.GotoAsync("/resources/cache?pane=tags"); + // These are expanded-list rows: a compact row is a single 32px line, and a spacious one keeps two lines + // because its third-line details have their own columns here. foreach (var (density, fontSize, lineHeight, rowHeight) in new[] - { ("Compact", 11d, 16d, 44d), ("Comfortable", 12d, 18d, 52d), ("Spacious", 14d, 20d, 60d) }) + { ("Compact", 11d, 16d, 32d), ("Comfortable", 12d, 18d, 52d), ("Spacious", 14d, 20d, 60d) }) { await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync(); await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync(); @@ -1008,15 +1010,15 @@ await RunTestAsync(async page => var additions = Enumerable.Range(0, 22).Select(MetadataResourcesFixture.CreateDynamicChild).ToArray(); await fixture.PublishAsync(additions.Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)).ToArray()); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28); - foreach (var (density, height) in new[] { ("Compact", 44d), ("Comfortable", 52d), ("Spacious", 60d) }) + foreach (var density in new[] { "Compact", "Comfortable", "Spacious" }) { await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync(); await page.GetByRole(AriaRole.Button, new() { Name = density, Exact = true }).ClickAsync(); await page.GetByRole(AriaRole.Button, new() { Name = dark ? "Dark" : "Light", Exact = true }).ClickAsync(); await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync(); - await AssertGeometryAsync(height); + await AssertGeometryAsync(density); var pane = page.Locator(".resource-pane"); - foreach (var width in new[] { 420, 560, 769, 1279, 1280, 1281, 1304, 1439, 1440, 1441, 1599, 1600, 1601, 1799, 1800, 1801, 2000 }) + foreach (var width in new[] { 420, 560, 769, 960, 990, 1279, 1280, 1281, 1304, 1439, 1440, 1441, 1599, 1600, 1601, 1799, 1800, 1801, 2000 }) { stage = $"{density}-{width}-expanded"; if (await page.Locator(".pane-full-canvas").CountAsync() > 0) @@ -1051,7 +1053,7 @@ await page.WaitForFunctionAsync(""" { await Assertions.Expect(page.Locator(".resource-row-insight-requests").First).Not.ToBeVisibleAsync(); } - await AssertGeometryAsync(height); + await AssertGeometryAsync(density); await page.ScreenshotAsync(new() { Path = Path.Combine(AppContext.BaseDirectory, $"ux29-final-{(dark ? "dark" : "light")}-{density}-pane{width}.png") @@ -1061,28 +1063,28 @@ await page.ScreenshotAsync(new() await parent.PressAsync("ArrowLeft"); await Assertions.Expect(page.Locator("[data-resource-name='rebuilder-0']")).ToHaveCountAsync(0); stage = $"{density}-{width}-collapsed"; - await AssertGeometryAsync(height); + await AssertGeometryAsync(density); await parent.PressAsync("ArrowRight"); await Assertions.Expect(page.Locator("[data-resource-name='rebuilder-0']")).ToBeVisibleAsync(); var filter = pane.GetByRole(AriaRole.Searchbox); await filter.FillAsync("rebuilder"); await Assertions.Expect(page.Locator("[data-resource-name='worker']")).ToHaveCountAsync(0); stage = $"{density}-{width}-filtered"; - await AssertGeometryAsync(height); + await AssertGeometryAsync(density); await filter.FillAsync(""); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28); stage = $"{density}-{width}-filter-cleared"; - await AssertGeometryAsync(height); + await AssertGeometryAsync(density); } } await fixture.PublishAsync(additions.Take(11).Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Delete, resource)).ToArray()); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(17); stage = "eleven-removed"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await fixture.PublishAsync(additions.Reverse().Select(resource => new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, resource)).ToArray()); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(28); stage = "reinserted-reverse-order"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); // OrderStore survives browser contexts; choose a movable child in the actual order. var siblings = page.Locator(".resource-row[data-resource-parent='api']"); var expectedOrder = await siblings.EvaluateAllAsync("rows => rows.map(row => row.dataset.resourceName)"); @@ -1119,39 +1121,45 @@ await page.WaitForFunctionAsync(""" throw; } stage = "reordered"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); var selectAll = page.GetByRole(AriaRole.Button, new() { Name = "Select all", Exact = true }); await selectAll.ClickAsync(); await Assertions.Expect(page.Locator(".resource-select-all-toggle.on")).ToHaveCountAsync(1); await Assertions.Expect(page.Locator(".resource-row.selected[data-resource-name]")).ToHaveCountAsync(0); stage = "all-nested-selected"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await Assertions.Expect(page.Locator(".pane-full-canvas")).ToHaveCountAsync(0); stage = "all-nested-selected-sidebar"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await page.Locator("#resourcePaneGroupingButton").ClickAsync(); await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "Tags", Exact = true }).ClickAsync(); await Assertions.Expect(page.Locator(".resource-tag-group").First).ToBeVisibleAsync(); stage = "grouped-tags"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await page.ReloadAsync(); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(8); stage = "reloaded"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await page.Locator("#resourcePaneGroupingButton").ClickAsync(); await page.GetByRole(AriaRole.Menuitemradio, new() { Name = "None", Exact = true }).ClickAsync(); await Assertions.Expect(page.Locator(".resource-row[data-resource-name]")).ToHaveCountAsync(6); stage = "ungrouped-after-reload"; - await AssertGeometryAsync(60); + await AssertGeometryAsync("Spacious"); await File.WriteAllTextAsync( Path.Combine(AppContext.BaseDirectory, $"dynamic-geometry-{(dark ? "dark" : "light")}.json"), JsonSerializer.Serialize(snapshots, new JsonSerializerOptions { WriteIndented = true })); - async Task AssertGeometryAsync(double height) + async Task AssertGeometryAsync(string density) { var result = await page.Locator(".resource-pane-list").EvaluateAsync(""" - (list, {height, stage}) => { + (list, {density, stage}) => { const errors = [], rows = [...list.querySelectorAll('.resource-row[data-resource-name]')]; + const compact = density === 'Compact', comfortable = density === 'Comfortable'; + const fullCanvas = !!list.closest('.pane-full-canvas'); + // A compact row is a single line. A spacious sidebar row adds a third line for its health, + // endpoint or source; the expanded list has columns for those, so its rows keep two lines. + const height = compact ? 32 : comfortable ? 52 : fullCanvas ? 60 : 76; + const iconBox = compact ? 22 : comfortable ? 32 : 40, glyphSize = density === 'Spacious' ? 20 : 16; const nameOffsets = rows.map(row => row.querySelector('.resource-row-name').getBoundingClientRect().x - row.querySelector('.resource-row-icon').getBoundingClientRect().right); @@ -1164,20 +1172,20 @@ async Task AssertGeometryAsync(double height) icon.x - b.x - parseFloat(rail.width) < 7) errors.push(`${row.dataset.resourceName}: selection rail/spacing`); if (b.height !== height) errors.push(`${row.dataset.resourceName}: height ${b.height}`); - if (icon.width !== 32 || icon.height !== 32) errors.push(`${row.dataset.resourceName}: icon box`); + if (icon.width !== iconBox || icon.height !== iconBox) errors.push(`${row.dataset.resourceName}: icon box`); const mainGlyph = row.querySelector('.resource-row-glyph svg').getBoundingClientRect(); - if (mainGlyph.width !== 16 || mainGlyph.height !== 16) errors.push(`${row.dataset.resourceName}: glyph`); - const expectedFont = height === 44 ? 12 : height === 52 ? 14 : 16; + if (mainGlyph.width !== glyphSize || mainGlyph.height !== glyphSize) errors.push(`${row.dataset.resourceName}: glyph`); + const expectedFont = compact ? 12 : comfortable ? 14 : 16; if (parseFloat(getComputedStyle(row.querySelector('.resource-row-name')).fontSize) !== expectedFont) errors.push(`${row.dataset.resourceName}: density font`); for (const chip of row.querySelectorAll('.resource-row-insight')) { const chipBox = chip.getBoundingClientRect(); if (!chipBox.width) continue; const style = getComputedStyle(chip), glyph = chip.querySelector('svg').getBoundingClientRect(); - const font = height === 44 ? 11 : height === 52 ? 12 : 14; - const line = height === 44 ? 16 : height === 52 ? 18 : 20; - const target = height === 44 ? 24 : height === 52 ? 28 : 32; - const iconSize = height === 44 ? 12 : height === 52 ? 14 : 16; + const font = compact ? 11 : comfortable ? 12 : 14; + const line = compact ? 16 : comfortable ? 18 : 20; + const target = compact ? 24 : comfortable ? 28 : 32; + const iconSize = compact ? 12 : comfortable ? 14 : 16; if (parseFloat(style.fontSize) !== font || parseFloat(style.lineHeight) !== line || chipBox.height !== target || chipBox.width < target || glyph.width !== iconSize || glyph.height !== iconSize || @@ -1204,9 +1212,12 @@ async Task AssertGeometryAsync(double height) const badge = row.querySelector('.persistent-container-badge'); if (badge) { const bb = badge.getBoundingClientRect(), style = getComputedStyle(badge); + // A compact row's 22px icon box leaves no room under its 16px glyph for the 10px badge + // inside a 32px row, so there the badge overlaps the glyph's lower corner, as it did + // before #21727. It must still stay inside the row and clear of the name. if (style.position !== 'absolute' || style.pointerEvents !== 'none' || bb.right !== icon.right + 4 || bb.bottom !== icon.bottom + 3 || - bb.y < mainGlyph.bottom || bb.bottom > b.bottom || + (!compact && bb.y < mainGlyph.bottom) || bb.bottom > b.bottom || bb.right > row.querySelector('.resource-row-name').getBoundingClientRect().x) errors.push(`${i}: badge overlay/clipping`); } @@ -1221,8 +1232,10 @@ async Task AssertGeometryAsync(double height) errors.push(`${row.dataset.resourceName}: incomplete start time; client=${time.clientWidth}; scroll=${time.scrollWidth}; text=${time.textContent}`); } } - const fullCanvas = !!list.closest('.pane-full-canvas'); const paneWidth = list.closest('.resource-pane').getBoundingClientRect().width; + // The row tracks' minimums must fit the pane at every width, or the actions scroll out of view. + if (list.scrollWidth > list.clientWidth + 1) + errors.push(`horizontal overflow; client=${list.clientWidth}; scroll=${list.scrollWidth}`); const columns = {}; if (fullCanvas && paneWidth >= 960) { const templates = new Set(rows.map(row => getComputedStyle(row).gridTemplateColumns)); @@ -1278,7 +1291,7 @@ async Task AssertGeometryAsync(double height) icon:row.querySelector('.resource-row-icon').getBoundingClientRect().toJSON(), badge:row.querySelector('.persistent-container-badge')?.getBoundingClientRect().toJSON()}))}; } - """, new { height, stage }); + """, new { density, stage }); var errors = result.GetProperty("errors").EnumerateArray().Select(error => error.GetString()).ToArray(); Assert.True(errors.Length == 0, $"{stage}: {string.Join("; ", errors)}"); snapshots.Add(result); @@ -1668,7 +1681,8 @@ await RunTestAsync(async page => { await page.SetViewportSizeAsync(1440, 900); await page.GotoAsync("/resources/cache?pane=tags"); - foreach (var (density, height) in new[] { ("Compact", 44d), ("Comfortable", 52d), ("Spacious", 60d) }) + // A spacious sidebar row has a third line; the expanded list keeps two because it has columns for those details. + foreach (var (density, sidebarHeight, fullCanvasHeight) in new[] { ("Compact", 32d, 32d), ("Comfortable", 52d, 52d), ("Spacious", 76d, 60d) }) { await page.SetViewportSizeAsync(1440, 900); if (await page.Locator(".resources-layout.pane-full-canvas").CountAsync() > 0) @@ -1716,8 +1730,10 @@ await page.WaitForFunctionAsync(""" """); } await Assertions.Expect(pane).ToBeVisibleAsync(); - var rows = await pane.EvaluateAsync(""" - pane => [...pane.querySelectorAll('.resource-row[data-resource-name]')].map(row => { + // Compact sidebar rows reveal their actions on hover, so measure them with the pointer away first. + await page.Mouse.MoveAsync(1, 1); + const string measureRows = """ + rows => rows.map(row => { const rect = row.getBoundingClientRect(); const next = row.nextElementSibling; const rail = row.querySelector('.resource-row-actions'); @@ -1727,21 +1743,52 @@ await page.WaitForFunctionAsync(""" return [rect.height, next?.matches('.resource-row[data-resource-name]') ? next.getBoundingClientRect().top - rect.bottom : 1, rail.getBoundingClientRect().width, shown(overflow) ? 1 : 0, - essential.every(shown) ? 1 : 0]; + essential.every(shown) ? 1 : 0, + [...rail.querySelectorAll('.resource-row-action')].some(shown) ? 1 : 0, + row.matches(':hover, :focus-within') ? 1 : 0]; }) - """); + """; + var resourceRows = pane.Locator(".resource-row[data-resource-name]"); + var rows = await resourceRows.EvaluateAllAsync(measureRows); Assert.NotEmpty(rows); + var compactSidebar = density == "Compact" && !fullCanvas; Assert.All(rows, values => { - Assert.Equal(height, values[0]); + Assert.Equal(fullCanvas ? fullCanvasHeight : sidebarHeight, values[0]); Assert.Equal(1, values[1]); - Assert.Equal(1, values[3]); - if (!fullCanvas) + if (compactSidebar && values[6] == 0) { - Assert.Equal(112, values[2]); - Assert.Equal(1, values[4]); + // At rest, a compact row's rail narrows to the expand toggle and hides every action, so the + // name gets the room. Hovering or focusing it brings the actions back (checked below). + Assert.Equal(20, values[2]); + Assert.Equal(0, values[5]); + } + else + { + Assert.Equal(1, values[3]); + if (!fullCanvas) + { + Assert.Equal(112, values[2]); + Assert.Equal(1, values[4]); + } } }); + if (compactSidebar) + { + // At most one row can still have focus from the controls used above; the others are at rest. + Assert.True(rows.Count(values => values[6] == 0) >= rows.Length - 1); + // Hover reveals the full rail, and so does keyboard focus, so the actions stay reachable + // without a pointer. + foreach (var reveal in new Func[] { row => row.HoverAsync(), row => row.Locator(".resource-row-link").FocusAsync() }) + { + var revealed = resourceRows.First; + await reveal(revealed); + var values = (await revealed.EvaluateAsync($"row => ({measureRows})([row])"))[0]; + Assert.Equal(new double[] { 112, 1, 1, 1 }, values[2..6]); + await page.Mouse.MoveAsync(1, 1); + await revealed.Locator(".resource-row-link").BlurAsync(); + } + } var list = pane.Locator(".resource-pane-list"); Assert.True(await list.EvaluateAsync("el => el.scrollWidth <= el.clientWidth + 1"), $"{density}; full canvas={fullCanvas}; " + await list.EvaluateAsync(""" @@ -1863,17 +1910,24 @@ await RunTestAsync(async page => await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", paneWidth.ToString(CultureInfo.InvariantCulture)); var row = pane.Locator("[data-resource-name='api']").First; - await Assertions.Expect(row.Locator(".resource-row-type")).ToHaveCountAsync(0); + var endpoint = row.Locator(".resource-row-primary-endpoint"); + // A compact row is one line: the name, then the type and state columns. The state is a dot that names + // itself in its tooltip. At rest the actions are hidden, so the name gets the action rail's room. + await page.Mouse.MoveAsync(1, 1); + await Assertions.Expect(row.Locator(".resource-row-meta .resource-row-type")).ToBeVisibleAsync(); + await Assertions.Expect(row.Locator(".resource-row-sub")).ToHaveCountAsync(0); await Assertions.Expect(row.Locator(".resource-row-endpoint-address")).Not.ToBeVisibleAsync(); - await Assertions.Expect(row.Locator(".resource-row-link .state-dot + span")).ToHaveTextAsync("Running (Unhealthy)"); + await Assertions.Expect(row.Locator(".resource-row-meta-state")).ToHaveAttributeAsync("data-tooltip", "Running (Unhealthy)"); await Assertions.Expect(row.Locator(".resource-row-link")).ToHaveAttributeAsync("aria-description", new System.Text.RegularExpressions.Regex("Unhealthy")); await Assertions.Expect(row.Locator(".resource-row-link .state-dot")).ToHaveCountAsync(1); + await Assertions.Expect(endpoint).Not.ToBeVisibleAsync(); Assert.True(await row.Locator(".resource-row-text").EvaluateAsync("el => el.clientWidth") >= 160); + // Hover and focus swap the type and state columns for the actions, which keeps the name's room. await row.HoverAsync(); await row.Locator(".resource-row-link").FocusAsync(); + await Assertions.Expect(row.Locator(".resource-row-meta")).Not.ToBeVisibleAsync(); Assert.True(await row.Locator(".resource-row-text").EvaluateAsync("el => el.clientWidth") >= 160); - var endpoint = row.Locator(".resource-row-primary-endpoint"); await Assertions.Expect(endpoint).ToBeVisibleAsync(); await Assertions.Expect(endpoint).ToHaveAttributeAsync("href", MetadataResourcesFixture.Endpoint); await Assertions.Expect(endpoint).ToHaveAttributeAsync("target", "_blank"); From 7e9e3de93d39497cd6538ea2bdf0a84dab1aba52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:05:44 -0800 Subject: [PATCH 3/5] Keep the compact error badge in place while row actions show In compact sidebar rows, the unviewed-error badge sat before the type and state columns at rest, but moved next to the actions while they showed. With a persistent type in the list (a 128px type column) that was a 62px jump, which also slid the badge out from under a pointer that was on it. The badge now takes whichever place is further from the row's end in both states: clear of the columns or clear of the actions. Its spacer moves with it, so the name still stops before the badge. Add a Playwright test that seeds error logs and checks, at rest, on row hover, on badge hover, on focus and with the menu open, that the badge doesn't move, isn't covered, sits over its spacer and overlaps no column or action, with and without a persistent type. --- .../Layout/ResourcesLayout.razor.css | 27 +++- .../Playwright/ResourcePaneResponsiveTests.cs | 132 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css index 8cf6a50b98c..79866cc058a 100644 --- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css @@ -2801,9 +2801,32 @@ display: none; } - /* With the columns gone, the error badge's spacer ends the row link again, so the badge goes back to the end. */ + /* The error badge keeps one place whether the columns or the actions show, so it never moves, least of all out + from under a pointer that is on it. Its margin is measured from the end of the name track, which is 92px + wider at rest than while the actions show (the 112px rail narrows to 20px). At rest the badge must clear the + type and state columns: the type column + 34px (see above). While the actions show it must clear the link's + 8px end padding, which is 8px + 92px = 100px from the end of the wider, at-rest track. The badge takes + whichever is further from the end in both states, so with wide persistent type columns a gap is left before + the actions. The spacer, which stops the name before the badge, moves with it: at rest it sits before the + columns, and while the actions show only the link's 8px end padding follows it. */ + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact { + --resource-row-error-offset: max(calc(var(--resource-row-meta-type-width) + 34px), 100px); + } + + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row-error-link { + margin-inline-end: var(--resource-row-error-offset); + } + + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .error-badge-spacer { + margin-inline-end: calc(var(--resource-row-error-offset) - var(--resource-row-meta-type-width) - 34px); + } + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-error-link { - margin-inline-end: 8px; + margin-inline-end: calc(var(--resource-row-error-offset) - 92px); + } + + .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :focus-within, :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .error-badge-spacer { + margin-inline-end: calc(var(--resource-row-error-offset) - 100px); } } diff --git a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs index ccded8345dc..8458a3ed49f 100644 --- a/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs +++ b/tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/ResourcePaneResponsiveTests.cs @@ -25,6 +25,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Playwright; +using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; using Xunit; using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; @@ -1947,6 +1948,137 @@ await RunTestAsync(async page => }); } + [Theory] + [InlineData(420)] + [InlineData(560)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task CompactSidebar_ErrorBadgeKeepsItsPlaceWhileActionsShow(int paneWidth) + { + // The fixture's persistent "cache" container widens every row's compact type column. That is when the badge + // used to move: at rest it sat before the wide columns, and while the actions showed it went to the end of the + // narrower name track, out from under a pointer that was on it. + var services = fixture.DashboardApp.Services; + var writer = services.GetRequiredService(); + await writer.AddLogsAsync(new AddContext(), new RepeatedField + { + new[] { "api", "cache" }.Select(name => + { + var resource = CreateResource(name); + resource.Attributes.Remove(resource.Attributes.Single(attribute => attribute.Key == OtlpResource.SERVICE_INSTANCE_ID)); + return new ResourceLogs + { + Resource = resource, + ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord(message: $"{name} failed", severity: SeverityNumber.Error) } } } + }; + }) + }); + + try + { + await RunTestAsync(async page => + { + await page.SetViewportSizeAsync(1440, 900); + await page.EmulateMediaAsync(new() { ColorScheme = ColorScheme.Light }); + await page.GotoAsync("/resources/cache?pane=tags"); + await page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }).ClickAsync(); + await page.GetByRole(AriaRole.Button, new() { Name = "Compact", Exact = true }).ClickAsync(); + await page.GetByRole(AriaRole.Button, new() { Name = "Close", Exact = true }).Last.ClickAsync(); + var pane = page.Locator(".resource-pane"); + var separator = page.GetByRole(AriaRole.Separator, new() { Name = Resources.Layout.ResourcePaneResize }); + var handle = await separator.BoundingBoxAsync(); + var initialPane = await pane.BoundingBoxAsync(); + Assert.NotNull(handle); + Assert.NotNull(initialPane); + await page.Mouse.MoveAsync(handle.X + handle.Width / 2, handle.Y + handle.Height / 2); + await page.Mouse.DownAsync(); + await page.Mouse.MoveAsync(handle.X + handle.Width / 2 + paneWidth - initialPane.Width, handle.Y + handle.Height / 2); + await page.Mouse.UpAsync(); + await Assertions.Expect(separator).ToHaveAttributeAsync("aria-valuenow", paneWidth.ToString(CultureInfo.InvariantCulture)); + + // Per row: the badge's box; whether it is the topmost element at its center; the hidden spacer's box, + // which stops the name before the badge; the end of the name's text; whether the badge overlaps a + // shown column or action; and whether any action is shown. + const string measure = """ + row => { + const box = el => el.getBoundingClientRect(); + const shown = el => box(el).width > 0 && box(el).height > 0 && getComputedStyle(el).visibility !== 'hidden'; + const badge = row.querySelector(':scope > .resource-row-error-link'); + const b = box(badge); + const top = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); + const spacer = box(row.querySelector('.error-badge-spacer')); + const others = [...row.querySelectorAll('.resource-row-meta > *, .resource-row-actions .resource-row-action, .resource-row-actions .resource-row-toggle')] + .filter(shown); + const overlaps = others.some(el => { const o = box(el); return o.left < b.right - 0.5 && o.right > b.left + 0.5; }); + return [b.left, b.top, b.right, b.bottom, top?.closest('.resource-row-error-link') === badge ? 1 : 0, + spacer.left, spacer.right, box(row.querySelector('.resource-row-text')).right, overlaps ? 1 : 0, + [...row.querySelectorAll('.resource-row-actions .resource-row-action')].some(shown) ? 1 : 0]; + } + """; + + // With the persistent type in the list, the wide columns decide the badge's place. Filtered down to + // "api", the columns are 64px and the actions decide it instead. + await AssertRowAsync("api", "persistent types", typeWidth: 128); + await AssertRowAsync("cache", "persistent types", typeWidth: 128); + await pane.GetByRole(AriaRole.Searchbox).FillAsync("api"); + await Assertions.Expect(pane.Locator("[data-resource-name='cache']")).ToHaveCountAsync(0); + await AssertRowAsync("api", "no persistent types", typeWidth: 64); + + async Task AssertRowAsync(string name, string stage, double typeWidth) + { + var row = pane.Locator($"[data-resource-name='{name}']").First; + var badge = row.Locator(":scope > .resource-row-error-link"); + var context = $"{name} ({stage})"; + await row.ScrollIntoViewIfNeededAsync(); + await page.Mouse.MoveAsync(1, 1); + await Assertions.Expect(badge).ToHaveTextAsync("1"); + var type = await row.Locator(".resource-row-meta .resource-row-type").BoundingBoxAsync(); + Assert.NotNull(type); + Assert.Equal(typeWidth, type.Width, 0.5); + var rest = await row.EvaluateAsync(measure); + AssertBadge(rest, rest, context, "rest", actionsShown: false); + + // Pointing at the row or at the badge itself, focusing the row, and opening its menu all show the + // actions in place of the columns. None of them may move or cover the badge. + await row.Locator(".resource-row-name").HoverAsync(); + AssertBadge(rest, await row.EvaluateAsync(measure), context, "row hover", actionsShown: true); + + await page.Mouse.MoveAsync(1, 1); + await page.Mouse.MoveAsync((float)((rest[0] + rest[2]) / 2), (float)((rest[1] + rest[3]) / 2)); + AssertBadge(rest, await row.EvaluateAsync(measure), context, "badge hover", actionsShown: true); + + await page.Mouse.MoveAsync(1, 1); + await row.Locator(".resource-row-link").FocusAsync(); + AssertBadge(rest, await row.EvaluateAsync(measure), context, "focus", actionsShown: true); + + await row.HoverAsync(); + await row.Locator(".resource-row-actions .aspire-menu-button").Last.ClickAsync(); + await page.Mouse.MoveAsync(1, 1); + AssertBadge(rest, await row.EvaluateAsync(measure), context, "menu open", actionsShown: true); + await page.Keyboard.PressAsync("Escape"); + } + + static void AssertBadge(double[] rest, double[] current, string row, string state, bool actionsShown) + { + var context = $"{row}, {state}"; + Assert.True(Math.Abs(current[0] - rest[0]) < 0.5 && Math.Abs(current[1] - rest[1]) < 0.5, + $"{context}: the badge moved from ({rest[0]}, {rest[1]}) to ({current[0]}, {current[1]})."); + Assert.True(current[4] == 1, $"{context}: something covers the badge."); + Assert.True(Math.Abs(current[5] - current[0]) < 0.5 && Math.Abs(current[6] - current[2]) < 0.5, + $"{context}: the spacer ({current[5]}-{current[6]}) is not under the badge ({current[0]}-{current[2]})."); + Assert.True(current[7] <= current[0] + 0.5, $"{context}: the name ends at {current[7]}, under the badge at {current[0]}."); + Assert.True(current[8] == 0, $"{context}: the badge overlaps a column or an action."); + Assert.Equal(actionsShown ? 1d : 0d, current[9]); + } + }); + } + finally + { + // The fixture is shared by the class, so later tests must not see these badges. + await writer.ClearStructuredLogsAsync(); + services.GetRequiredService().MarkViewedErrorLogs(null); + } + } + [Theory] [InlineData(769, false)] [InlineData(1440, false)] From d0f9dfc0e055e773b1e00ccfdde3df0241da1573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:54:57 -0800 Subject: [PATCH 4/5] Open the dashboard on the full resource list and refine the resource pane - Open on the compact full-page list of all resources, with no tab selected. A reload keeps the view the browser tab showed, and /graph opens the overview with the graph. - Escape closes the resource view like the Close button, whose tooltip names the key. Open popups, popovers, menus and tooltips keep Escape for themselves. - While the content is closed, no tab is shown selected and rows show no unavailable telemetry state. Selecting resources reopens the last tab; selecting a tab opens it for all resources. - Replace the kind toggles with a Telemetry sources option in the filter's resource types, and rename Select all to All resources, with the header using the same icon. - Show when a stopped resource stopped, keep a row's second line on one line with an ellipsis before its error badge, and pin metrics for the selected resources only. - In the full-page list, use the resources grid's URLs column (links and a +N list) and drop the console logs, JSON and +N actions, which stay in the row's menu. - Address review feedback: mask URL credentials in the spacious detail, keep the compact expand toggle in place, and name a compact row's type and state in its tooltip. --- .../Controls/Chart/ChartContainer.razor | 2 +- .../Controls/Chart/ChartContainer.razor.cs | 6 + .../Controls/Chart/MeterOverview.razor | 2 +- .../Controls/Chart/MeterOverview.razor.cs | 7 + .../Controls/Chart/MetricPinButton.razor | 15 +- .../Controls/DetailPageCloseButton.razor | 5 +- .../Controls/DetailPageCloseButton.razor.cs | 7 + .../Controls/MetricGroupList.razor.cs | 6 +- .../Controls/ResourcePaneFilter.razor | 23 +- .../Controls/ResourcePaneFilter.razor.cs | 50 +- .../Components/Layout/ResourcesLayout.razor | 218 +++------ .../Layout/ResourcesLayout.razor.cs | 326 +++++++------ .../Layout/ResourcesLayout.razor.css | 285 ++++++----- .../Layout/ResourcesLayout.razor.js | 103 +++- .../Components/Legacy/Pages/Metrics.razor | 3 +- .../Components/Pages/GraphRedirect.razor.cs | 8 + .../Components/Pages/Metrics.razor | 6 +- .../Components/Pages/Metrics.razor.cs | 10 + .../Components/Pages/ResourceOverview.razor | 2 +- .../Model/DashboardViewPreferences.cs | 13 +- src/Aspire.Dashboard/Model/MetricPin.cs | 32 ++ src/Aspire.Dashboard/Model/MetricPinStore.cs | 72 ++- .../Resources/ControlsStrings.Designer.cs | 9 + .../Resources/ControlsStrings.resx | 4 + .../Resources/Layout.Designer.cs | 63 +-- src/Aspire.Dashboard/Resources/Layout.resx | 22 +- .../Resources/xlf/ControlsStrings.cs.xlf | 5 + .../Resources/xlf/ControlsStrings.de.xlf | 5 + .../Resources/xlf/ControlsStrings.es.xlf | 5 + .../Resources/xlf/ControlsStrings.fr.xlf | 5 + .../Resources/xlf/ControlsStrings.it.xlf | 5 + .../Resources/xlf/ControlsStrings.ja.xlf | 5 + .../Resources/xlf/ControlsStrings.ko.xlf | 5 + .../Resources/xlf/ControlsStrings.pl.xlf | 5 + .../Resources/xlf/ControlsStrings.pt-BR.xlf | 5 + .../Resources/xlf/ControlsStrings.ru.xlf | 5 + .../Resources/xlf/ControlsStrings.tr.xlf | 5 + .../Resources/xlf/ControlsStrings.zh-Hans.xlf | 5 + .../Resources/xlf/ControlsStrings.zh-Hant.xlf | 5 + .../Resources/xlf/Layout.cs.xlf | 35 +- .../Resources/xlf/Layout.de.xlf | 35 +- .../Resources/xlf/Layout.es.xlf | 35 +- .../Resources/xlf/Layout.fr.xlf | 35 +- .../Resources/xlf/Layout.it.xlf | 35 +- .../Resources/xlf/Layout.ja.xlf | 35 +- .../Resources/xlf/Layout.ko.xlf | 35 +- .../Resources/xlf/Layout.pl.xlf | 35 +- .../Resources/xlf/Layout.pt-BR.xlf | 35 +- .../Resources/xlf/Layout.ru.xlf | 35 +- .../Resources/xlf/Layout.tr.xlf | 35 +- .../Resources/xlf/Layout.zh-Hans.xlf | 35 +- .../Resources/xlf/Layout.zh-Hant.xlf | 35 +- .../DashboardJsonSerializerContext.cs | 3 +- .../Utils/BrowserStorageKeys.cs | 1 + .../Layout/ResourceRowAvailabilityTests.cs | 49 +- .../Layout/ResourcesLayoutCloseTests.cs | 21 + .../Layout/ResourcesLayoutTests.cs | 327 +++++++++---- .../Model/DashboardViewPreferencesTests.cs | 3 +- .../Pages/MetricsTests.cs | 60 ++- .../Playwright/DesktopNavMenuTests.cs | 10 +- .../Infrastructure/PlaywrightTestsBase.cs | 24 +- .../Infrastructure/ResourcePaneNavigation.cs | 15 + .../Playwright/OverviewLayoutTests.cs | 3 + .../ResourceOverviewEndpointTests.cs | 3 +- .../Playwright/ResourcePaneFullCanvasTests.cs | 83 +++- .../ResourcePaneInteractionTests.cs | 29 +- .../Playwright/ResourcePaneMotionTests.cs | 12 +- .../Playwright/ResourcePaneResponsiveTests.cs | 443 +++++++++++++----- .../ResourcePanelNavigationTests.cs | 4 +- .../Integration/Playwright/ResourcesTests.cs | 4 + .../Model/MetricPinStoreTests.cs | 111 +++-- .../Shared/DashboardModel/ModelTestHelpers.cs | 8 +- 72 files changed, 1795 insertions(+), 1237 deletions(-) create mode 100644 src/Aspire.Dashboard/Model/MetricPin.cs diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index 3eedc37fc60..259b3d1c449 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -26,7 +26,7 @@ else aria-label="@BackLabel" /> }

@_instrument.Summary.Name

- +

@_instrument.Summary.Description

diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 22f03e34857..cb5021de796 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -54,6 +54,12 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Parameter, EditorRequired] public required List Resources { get; set; } + /// + /// Gets or sets the names of the resources that pinning the instrument applies to: those of the current selection. + /// + [Parameter, EditorRequired] + public required IReadOnlyList PinResourceNames { get; set; } + /// /// Gets or sets whether a counter shows its running total instead of its change per interval. /// diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor b/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor index b317f0745c2..ce323addcb4 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor @@ -73,7 +73,7 @@ } - + } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor.cs index 89c69c6a643..f9d70141836 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MeterOverview.razor.cs @@ -29,6 +29,13 @@ public partial class MeterOverview : LiveSparklinesBase [Parameter, EditorRequired] public required Func GetInstrumentUrl { get; set; } + /// + /// Gets or sets the names of the resources that a card's pin applies to: those of the current selection, so the + /// cards of an instrument reported by several of them share one pin. + /// + [Parameter, EditorRequired] + public required IReadOnlyList PinResourceNames { get; set; } + /// /// Gets or sets the resource label shown as a pill on each card. Null hides it, for lists of a single resource. /// diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricPinButton.razor b/src/Aspire.Dashboard/Components/Controls/Chart/MetricPinButton.razor index ae9ff02aaf4..a47c64ebb1c 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricPinButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricPinButton.razor @@ -34,6 +34,13 @@ [Parameter, EditorRequired] public required OtlpInstrumentKey InstrumentKey { get; set; } + /// + /// Gets or sets the names of the resources the pin applies to: those of the current selection. The button shows + /// the instrument pinned when any of them has it pinned, and unpinning it unpins it for all of them. + /// + [Parameter, EditorRequired] + public required IReadOnlyList ResourceNames { get; set; } + [Parameter] public string? Class { get; set; } @@ -52,20 +59,20 @@ protected override void OnParametersSet() { - _isPinned = PinStore.IsPinned(InstrumentKey); + _isPinned = PinStore.IsPinned(InstrumentKey, ResourceNames); } private void TogglePinned() { - PinStore.SetPinned(InstrumentKey, !_isPinned); - _isPinned = PinStore.IsPinned(InstrumentKey); + PinStore.SetPinned(InstrumentKey, ResourceNames, !_isPinned); + _isPinned = PinStore.IsPinned(InstrumentKey, ResourceNames); } private void OnPinsChanged() { _ = InvokeAsync(() => { - _isPinned = PinStore.IsPinned(InstrumentKey); + _isPinned = PinStore.IsPinned(InstrumentKey, ResourceNames); StateHasChanged(); }); } diff --git a/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor b/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor index 5a9292297f2..12571a1a294 100644 --- a/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor @@ -7,5 +7,6 @@ IconStart="@(new Icons.Regular.Size20.Dismiss())" OnClick="@CloseAsync" data-parent-url="@ParentUrl" - data-tooltip="@Loc[nameof(ControlsStrings.SummaryDetailsViewCloseView)]" - aria-label="@Loc[nameof(ControlsStrings.SummaryDetailsViewCloseView)]" /> + data-tooltip="@Loc[ClosesOnEscape ? nameof(ControlsStrings.SummaryDetailsViewCloseViewWithShortcut) : nameof(ControlsStrings.SummaryDetailsViewCloseView)]" + aria-label="@Loc[nameof(ControlsStrings.SummaryDetailsViewCloseView)]" + aria-keyshortcuts="@(ClosesOnEscape ? "Escape" : null)" /> diff --git a/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor.cs index 39243fd589e..23e6f50e8d4 100644 --- a/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/DetailPageCloseButton.razor.cs @@ -16,6 +16,13 @@ public partial class DetailPageCloseButton [Parameter] public EventCallback OnClose { get; set; } + /// + /// Gets or sets whether the page's Escape key also closes it, as the resources view's does. The tooltip then names + /// the key. The button doesn't handle the key itself. + /// + [Parameter] + public bool ClosesOnEscape { get; set; } + private async Task CloseAsync() { await OnClose.InvokeAsync(); diff --git a/src/Aspire.Dashboard/Components/Controls/MetricGroupList.razor.cs b/src/Aspire.Dashboard/Components/Controls/MetricGroupList.razor.cs index d7bc9e9d4c1..19e42954ea6 100644 --- a/src/Aspire.Dashboard/Components/Controls/MetricGroupList.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/MetricGroupList.razor.cs @@ -69,9 +69,9 @@ private List GetGroups() var filter = PageViewModel.TextFilter; var groups = new List(); - // Pins are stored by meter and instrument name across resources, so count this resource's instruments - // that are pinned rather than the stored pins. - var pinnedCount = instruments.Count(i => MetricFilter.Matches(i, filter) && PinStore.IsPinned(i.GetKey())); + // Count the listed instruments that are pinned for their resource rather than the stored pins, which include + // other resources' pins. + var pinnedCount = instruments.Count(i => MetricFilter.Matches(i, filter) && PinStore.IsPinned(i)); // Keep the pinned group while it's selected, even with no matches, so it doesn't disappear under the user. if (pinnedCount > 0 || PageViewModel.ShowPinned) diff --git a/src/Aspire.Dashboard/Components/Controls/ResourcePaneFilter.razor b/src/Aspire.Dashboard/Components/Controls/ResourcePaneFilter.razor index ae07cd4f3b5..bd47f4931a6 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourcePaneFilter.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourcePaneFilter.razor @@ -46,9 +46,11 @@ @foreach (var group in groups) { var options = group.Values.ToArray().OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).ToList(); - var selectedCount = options.Count(pair => pair.Value); + var trailingOptions = group.TrailingOptions ?? []; + var optionCount = options.Count + trailingOptions.Count; + var selectedCount = options.Count(pair => pair.Value) + trailingOptions.Count(option => option.IsVisible()); var isExpanded = IsExpanded(group, options); - var allState = selectedCount == options.Count ? true : selectedCount == 0 ? false : (bool?)null; + var allState = selectedCount == optionCount ? true : selectedCount == 0 ? false : (bool?)null;
@@ -97,9 +111,12 @@ @* Changes apply as they are made. OK only closes the popover, so there is always a way to dismiss it. *@