diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor
index 39f66983a47..f6e3d796052 100644
--- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor
+++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor
@@ -18,7 +18,7 @@
var highlightedCommand = _highlightedCommands[i];
-
+
}
@@ -29,7 +29,7 @@
var outputAction = ResourceMenuBuilder.CreateViewOutputMenuItem(Resource, ResourceByName);
}
-
+
diff --git a/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor
new file mode 100644
index 00000000000..707dc376e6b
--- /dev/null
+++ b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor
@@ -0,0 +1,28 @@
+@namespace Aspire.Dashboard.Components
+@using Aspire.Dashboard.Utils
+
+@* The rich preview the trace page's log markers show in their tooltip. The page renders one, filled with the marker
+ the pointer or keyboard is on, rather than one per marker: a span can have thousands of logs, and each preview is a
+ small table. The shared tooltip clones it when it opens and follows its changes while it's open. *@
+
diff --git a/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs
new file mode 100644
index 00000000000..cf9803cadd5
--- /dev/null
+++ b/src/Aspire.Dashboard/Components/Controls/SpanLogPreview.razor.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Dashboard.Model;
+using Aspire.Dashboard.Otlp.Storage;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.Localization;
+
+namespace Aspire.Dashboard.Components;
+
+public sealed partial class SpanLogPreview : ComponentBase
+{
+ ///
+ /// The id of the preview's element, which each log marker names in its data-tooltip-content attribute.
+ ///
+ public const string ElementId = "span-log-preview";
+
+ private LogSummary? _logEntry;
+
+ [Inject]
+ public required BrowserTimeProvider TimeProvider { get; init; }
+
+ [Inject]
+ public required IStringLocalizer Loc { get; init; }
+
+ ///
+ /// Fills the preview with a log marker's entry, when the pointer or keyboard reaches the marker.
+ ///
+ ///
+ /// The marker's handler targets this component, not the page, so only the preview renders again, not the trace's
+ /// whole waterfall.
+ ///
+ public void Show(LogSummary logEntry)
+ {
+ if (_logEntry?.InternalId == logEntry.InternalId)
+ {
+ return;
+ }
+
+ _logEntry = logEntry;
+ StateHasChanged();
+ }
+
+ internal LogSummary? LogEntry => _logEntry;
+}
diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor
index 17b40fe157d..d352fe9057b 100644
--- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor
+++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor
@@ -344,6 +344,7 @@
data-pane-location="@_lastLocation"
data-pane-interactions-ready="@(_isOrderingRegistered ? "true" : "false")"
data-home-pending="@(_homeNavigation is not null ? "true" : null)"
+ data-escape-home="@(CanEscapeGoHome ? "true" : null)"
style="@GetLayoutStyle()"
@ref="_layoutElement">
@* A single header keeps section subscriptions stable across viewport changes. *@
@@ -505,6 +506,9 @@
}
+ @* Resolved icons are shared. Set the component color rather than mutating the cached icon with WithColor.
+ Every action icon takes its button's text color, labeled or not, so an icon reads as part of its button
+ and dims with it when the button is disabled. *@
@foreach (var command in showLabels ? GetHighlightedCommands(resource) : _isPaneFullCanvas ? GetFullPageRowCommands(resource) : GetSidebarCommands(resource))
{
var commandTitle = !string.IsNullOrEmpty(command.GetDisplayDescription()) ? command.GetDisplayDescription() : command.GetDisplayName();
@@ -512,9 +516,9 @@
{
+
@command.GetDisplayName()
}
@@ -523,12 +527,13 @@
+ Disabled="@IsCommandDisabled(resource, command)">
+
+
}
}
@* Every row has its menu, parameters included: it holds what the row's buttons don't, such as Delete
@@ -536,8 +541,6 @@
diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs
index 41e5889b1c6..ebc59894a2e 100644
--- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs
+++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.cs
@@ -592,6 +592,14 @@ protected override async Task OnParametersSetAsync()
}
}
+ // Whether Escape, when nothing else uses it, goes to the dashboard's home as the brand does (see
+ // registerResourceContextMenu in ResourcesLayout.razor.js). Every page beside the docked list can go there. Home
+ // itself can't, nor a narrow layout, where "/" redirects to the overview because there's no full-page list, nor a
+ // dashboard without the resource service, whose "/" redirects to the structured logs (see Home). While home is
+ // loading, Escape would only start the same navigation again.
+ internal bool CanEscapeGoHome =>
+ ViewportInformation.IsDesktop && DashboardClient.IsEnabled && !_isPaneFullCanvas && _homeNavigation is null;
+
internal void OnHomeNavigationStarting()
{
// Home is already showing, so the navigation won't change the location, and nothing would release a tab
diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css
index 7ec21cfc50b..53bca5855b9 100644
--- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css
+++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.css
@@ -131,7 +131,7 @@
margin: 6px 0 0;
position-try-fallbacks: flip-inline;
box-sizing: border-box;
- width: min(var(--resource-pane-expanded-width, 290px), calc(100vw - 24px));
+ width: min(330px, calc(100vw - 24px));
max-width: none;
height: max-content;
max-height: stretch;
@@ -2784,26 +2784,31 @@
}
}
-/* Compact sidebar rows show their actions only while the row is hovered or focused, or while one of its menus is
+/* Compact sidebar rows show their actions only while the row is hovered or has keyboard-visible focus, 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 is hidden, so the
name gets its room. The swap keeps the name's width about the same: the 92px the rail and its gap gain 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.
+ Mouse selection leaves the row link focused, so :focus-within would keep the actions visible after the pointer leaves.
+ Keep hidden actions tabbable so moving focus from the row link to an action reveals the rail without losing focus.
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"])) {
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) {
grid-template-columns: minmax(0, 1fr);
column-gap: 0;
}
- .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 {
- display: none;
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:not(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-actions {
+ position: absolute;
+ inset-inline-end: 0;
+ opacity: 0;
+ pointer-events: 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 {
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-meta {
display: none;
}
@@ -2827,13 +2832,22 @@
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 {
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .resource-row-error-link {
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 {
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-row:is(:hover, :has(:focus-visible), :has(.resource-row-actions [aria-haspopup][aria-expanded="true"])) .error-badge-spacer {
margin-inline-end: calc(var(--resource-row-error-offset) - 100px);
}
+
+ /* At rest the rows' actions are hidden and their state dots form a column at the end of the list, so the group
+ header's chevron is centered over that column instead of floating just past it. The row link ends 12px before
+ the row and has 8px of end padding, so the 10px dot's center is 25px from the end; the header ends at the same
+ line, and the 20px chevron needs 15px after it to center there. The count badge before it moves with it.
+ Touch screens keep the actions showing, so they have no dot column there and the chevron keeps its place. */
+ .resources-layout:not(.pane-full-canvas) .resource-pane-list.density-compact .resource-tag-header > .resource-row-toggle {
+ margin-inline-end: 15px;
+ }
}
.resource-tree-guide,
diff --git a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js
index 5bb47ef1d2a..7abfce7163f 100644
--- a/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js
+++ b/src/Aspire.Dashboard/Components/Layout/ResourcesLayout.razor.js
@@ -297,6 +297,14 @@ export function registerTabOverflow(registrationId, dotNetRef) {
schedule();
}
+// Stops the strips from moving focus to the tab that last had it when they're re-created. The tab remembered on
+// focusin isn't forgotten when it's blurred, so a caller that takes focus off a tab on purpose clears it here.
+function forgetTabFocus() {
+ for (const state of tabOverflowRegistrations.values()) {
+ state.focusedTab = null;
+ }
+}
+
export function unregisterTabOverflow(registrationId) {
const state = tabOverflowRegistrations.get(registrationId);
if (!state) {
@@ -834,6 +842,81 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) {
event.target.click();
}
};
+ // Escape goes to the dashboard's home, the table of every resource, as the header's brand does, when nothing else
+ // uses the key. Pages used to have a close button that led there, and Escape stayed as its shortcut when the button
+ // went. The layout says when the key may go home (data-escape-home): not at home itself, not on narrow layouts,
+ // which have no table, and not while home is already loading.
+ // Menus, popovers and dialogs close themselves with Escape, and text fields and the terminal keep it. Some of them
+ // close in their own keydown handler, so whether the key is free is decided in the capture phase, before any of
+ // them runs, and the brand is clicked in the bubble phase, only if no handler took the key (preventDefault). The
+ // capture listener is on the window, which comes before the document: the tooltip provider removes a shown tooltip
+ // in its own capture listener on the document.
+ let escapeIsFree = false;
+ const escapeOwnerSelector = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], .xterm, ' +
+ '[popover], fluent-popover-b, fluent-menu, fluent-dialog, dialog, [role="dialog"], [role="menu"], [role="listbox"], [role="combobox"], [role="textbox"]';
+ const hasOpenOverlay = () => {
+ // A control whose popup is open says so (aria-haspopup with aria-expanded="true"), whatever the popup is built
+ // with: the filter button's popover isn't a native popover, and focus stays on the button after clicking it.
+ // A tree toggle has aria-expanded but no popup, so it doesn't count.
+ if (document.querySelector('[aria-haspopup]:not([aria-haspopup="false"])[aria-expanded="true"]')) {
+ return true;
+ }
+ // A shown tooltip is dismissed by Escape (see app-tooltips.js), and the same key mustn't also leave the page.
+ // https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html
+ if (document.querySelector('aspire-tooltip-provider fluent-tooltip:not([aria-hidden="true"])')) {
+ return true;
+ }
+ // A FluentPopover's dialog is in its shadow root, out of reach of the query below, and the button opening it
+ // doesn't always say so, like the URLs column's +N button. Its host says whether it's open:
+ //
+ if (document.querySelector('fluent-popover-b[opened="true"]')) {
+ return true;
+ }
+ try {
+ // Hidden tooltips can stay in the popover layer, so they're left to the check above.
+ return [...document.querySelectorAll(':popover-open, dialog[open]')]
+ .some(element => element.localName !== 'fluent-tooltip' && !element.closest('aspire-tooltip-provider'));
+ }
+ catch {
+ // Browsers without the popover API have no open popovers to protect.
+ return false;
+ }
+ };
+ const onEscapeCapture = event => {
+ escapeIsFree = event.key === 'Escape' && !event.isComposing && !event.repeat &&
+ !event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey &&
+ !event.composedPath().some(target => target instanceof Element && target.matches(escapeOwnerSelector)) &&
+ !hasOpenOverlay();
+ };
+ const onEscape = event => {
+ const isFree = escapeIsFree;
+ escapeIsFree = false;
+ // Fluent's tab list cancels the keys it receives, Escape included, without doing anything with Escape. So a
+ // focused tab, for example the one just clicked to open the page, doesn't keep Escape from going home.
+ const isFromTabList = event.composedPath().some(target => target instanceof Element && target.matches('[role="tablist"]'));
+ if (!isFree || (event.defaultPrevented && !isFromTabList) || layoutElement.dataset.escapeHome !== 'true') {
+ return;
+ }
+ // Clicking the brand's link, rather than navigating here, keeps one way home: the brand clears the stored
+ // selection, holds a tab chosen while home loads, and grows the list across the page (see
+ // MainLayout.GoToAllResourcesAsync). The brand is in the page header, outside the layout element.
+ const home = document.querySelector('header .brand-home a.application-name');
+ if (home && home.getClientRects().length > 0 && !home.closest('[inert]')) {
+ event.preventDefault();
+ // A tab that has focus, such as one moved to with the keyboard, would get it back in the strip re-created
+ // at home (see registerTabOverflow), and since a key was pressed last, it would show its keyboard focus
+ // ring there. That restore is for a tab about to be chosen while home lands; Escape leaves the tab instead.
+ const focusedTab = document.activeElement instanceof Element
+ ? document.activeElement.closest('.resources-tab-header fluent-tab')
+ : null;
+ if (focusedTab) {
+ forgetTabFocus();
+ focusedTab.blur();
+ }
+ home.click();
+ }
+ };
+
// While the resource list fills the page, no tab is shown selected, and choosing the current tab opens its content.
// The tab list can't report that choice (see OpenTabFromFullCanvasAsync), so a click, Enter or Space on a tab is
// passed on. The tabs are in the page header, outside the layout, so this listens on the document.
@@ -866,6 +949,8 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) {
document.addEventListener('keydown', onTabUserActivation, true);
layoutElement.addEventListener('mousedown', onShiftPointerDown, true);
document.addEventListener('keydown', onContextMenuDismiss, true);
+ window.addEventListener('keydown', onEscapeCapture, true);
+ document.addEventListener('keydown', onEscape);
document.addEventListener('click', onTabActivate);
document.addEventListener('keydown', onTabActivate);
layoutElement.addEventListener('click', onClick);
@@ -880,6 +965,8 @@ export function registerResourceContextMenu(layoutElement, dotNetRef) {
document.removeEventListener('keydown', onTabUserActivation, true);
layoutElement.removeEventListener('mousedown', onShiftPointerDown, true);
document.removeEventListener('keydown', onContextMenuDismiss, true);
+ window.removeEventListener('keydown', onEscapeCapture, true);
+ document.removeEventListener('keydown', onEscape);
document.removeEventListener('click', onTabActivate);
document.removeEventListener('keydown', onTabActivate);
layoutElement.removeEventListener('click', onClick);
diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
index 9c084c8e826..25c117ddcd6 100644
--- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
+++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
@@ -267,39 +267,20 @@
@foreach (var item in context.SpanLogs)
{
var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}";
- var tooltipId = $"{buttonId}-tooltip";
- var eventName = item.LogEntry.EventName ?? StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)];
var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId;
var buttonColor = item.LogEntry.IsError ? "var(--error)" : spanColor;
-