diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index f9717853a8..29e13975d9 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -34,7 +34,7 @@ } @@ -44,11 +44,11 @@ Immediate="true" @bind-Value="_filter" slot="end" - @bind-Value:after="HandleSearchFilterChanged" /> + @bind-Value:after="HandleSearchFilterChangedAsync" /> @@ -77,9 +77,13 @@ @ControlsStringsLoc[ControlsStrings.ViewAction] + @{ + var id = $"details-button-{context.Uid}"; + } @ControlsStringsLoc[nameof(ControlsStrings.ViewAction)] + OnClick="@(() => ShowResourceDetailsAsync(context, id))">@ControlsStringsLoc[nameof(ControlsStrings.ViewAction)] @if (HasResourcesWithCommands) { diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 9404de7d4f..c68c788244 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -10,6 +10,7 @@ using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Pages; @@ -30,6 +31,8 @@ public partial class Resources : ComponentBase, IAsyncDisposable public required IToastService ToastService { get; init; } [Inject] public required BrowserTimeProvider TimeProvider { get; init; } + [Inject] + public required IJSRuntime JS { get; init; } private ResourceViewModel? SelectedResource { get; set; } @@ -41,6 +44,7 @@ public partial class Resources : ComponentBase, IAsyncDisposable private bool _isTypeFilterVisible; private Task? _resourceSubscriptionTask; private bool _isLoading = true; + private string? _elementIdBeforeDetailsViewOpened; public Resources() { @@ -49,7 +53,7 @@ public Resources() private bool Filter(ResourceViewModel resource) => _visibleResourceTypes.ContainsKey(resource.ResourceType) && (_filter.Length == 0 || resource.MatchesFilter(_filter)) && resource.State != ResourceStates.HiddenState; - protected void OnResourceTypeVisibilityChanged(string resourceType, bool isVisible) + protected Task OnResourceTypeVisibilityChangedAsync(string resourceType, bool isVisible) { if (isVisible) { @@ -60,12 +64,12 @@ protected void OnResourceTypeVisibilityChanged(string resourceType, bool isVisib _visibleResourceTypes.TryRemove(resourceType, out _); } - ClearSelectedResource(); + return ClearSelectedResourceAsync(); } - private void HandleSearchFilterChanged() + private Task HandleSearchFilterChangedAsync() { - ClearSelectedResource(); + return ClearSelectedResourceAsync(); } private bool? AreAllTypesVisible @@ -202,11 +206,13 @@ private bool ApplicationErrorCountsChanged(Dictionary newA return false; } - private void ShowResourceDetails(ResourceViewModel resource) + private async Task ShowResourceDetailsAsync(ResourceViewModel resource, string buttonId) { + _elementIdBeforeDetailsViewOpened = buttonId; + if (SelectedResource == resource) { - ClearSelectedResource(); + await ClearSelectedResourceAsync(); } else { @@ -214,9 +220,16 @@ private void ShowResourceDetails(ResourceViewModel resource) } } - private void ClearSelectedResource() + private async Task ClearSelectedResourceAsync(bool causedByUserAction = false) { SelectedResource = null; + + if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction) + { + await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened); + } + + _elementIdBeforeDetailsViewOpened = null; } private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName); diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor index 018dfb3cf3..1595823114 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor @@ -24,7 +24,7 @@ @bind-SelectedResource:after="HandleSelectedApplicationChangedAsync" /> @@ -60,7 +60,7 @@ @@ -103,7 +103,10 @@ } - @ControlsStringsLoc[nameof(ControlsStrings.ViewAction)] + @{ + var id = $"details-button-{context.InternalId}"; + } + @ControlsStringsLoc[nameof(ControlsStrings.ViewAction)] diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 88ff4465bf..facbd0bd74 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -27,6 +27,7 @@ public partial class StructuredLogs : IPageWithSessionAndUrlState DashboardUrls.StructuredLogsBasePath; public string SessionStorageKey => "StructuredLogs_PageState"; @@ -147,13 +148,12 @@ private Task HandleSelectedApplicationChangedAsync() return this.AfterViewModelChangedAsync(); } - private Task HandleSelectedLogLevelChangedAsync() + private async Task HandleSelectedLogLevelChangedAsync() { _applicationChanged = true; - ClearSelectedLogEntry(); - - return this.AfterViewModelChangedAsync(); + await ClearSelectedLogEntryAsync(); + await this.AfterViewModelChangedAsync(); } private void UpdateSubscription() @@ -170,11 +170,13 @@ private void UpdateSubscription() } } - private void OnShowProperties(OtlpLogEntry entry) + private async Task OnShowPropertiesAsync(OtlpLogEntry entry, string buttonId) { + _elementIdBeforeDetailsViewOpened = buttonId; + if (SelectedLogEntry?.LogEntry == entry) { - ClearSelectedLogEntry(); + await ClearSelectedLogEntryAsync(); } else { @@ -187,9 +189,16 @@ private void OnShowProperties(OtlpLogEntry entry) } } - private void ClearSelectedLogEntry() + private async Task ClearSelectedLogEntryAsync(bool causedByUserAction = false) { SelectedLogEntry = null; + + if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction) + { + await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened); + } + + _elementIdBeforeDetailsViewOpened = null; } private async Task OpenFilterAsync(LogFilter? entry) @@ -213,7 +222,7 @@ private async Task OpenFilterAsync(LogFilter? entry) await DialogService.ShowPanelAsync(data, parameters); } - private Task HandleFilterDialog(DialogResult result) + private async Task HandleFilterDialog(DialogResult result) { if (result.Data is FilterDialogResult filterResult && filterResult.Filter is LogFilter filter) { @@ -226,10 +235,10 @@ private Task HandleFilterDialog(DialogResult result) ViewModel.AddFilter(filter); } - ClearSelectedLogEntry(); + await ClearSelectedLogEntryAsync(); } - return this.AfterViewModelChangedAsync(); + await this.AfterViewModelChangedAsync(); } private void HandleFilter(ChangeEventArgs args) @@ -237,13 +246,14 @@ private void HandleFilter(ChangeEventArgs args) if (args.Value is string newFilter) { PageViewModel.Filter = newFilter; - ClearSelectedLogEntry(); _filterCts?.Cancel(); // Debouncing logic. Apply the filter after a delay. var cts = _filterCts = new CancellationTokenSource(); _ = Task.Run(async () => { + await ClearSelectedLogEntryAsync(); + await Task.Delay(400, cts.Token); ViewModel.FilterText = newFilter; await InvokeAsync(StateHasChanged); @@ -251,11 +261,15 @@ private void HandleFilter(ChangeEventArgs args) } } - private void HandleClear() + private async Task HandleClearAsync() { - _filterCts?.Cancel(); + if (_filterCts is not null) + { + await _filterCts.CancelAsync(); + } + ViewModel.FilterText = string.Empty; - ClearSelectedLogEntry(); + await ClearSelectedLogEntryAsync(); StateHasChanged(); } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index 9287f6ba5c..f680ad2287 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -42,7 +42,7 @@ @@ -55,7 +55,7 @@ -
+
@{ var isServerOrConsumer = context.Span.Kind == OtlpSpanKind.Server || context.Span.Kind == OtlpSpanKind.Consumer; // Indent the span name based on the depth of the span. @@ -152,7 +152,7 @@
-
+
@@ -169,10 +169,15 @@ + @{ + var id = context.Span.SpanId; + } + @ControlStringsLoc[nameof(ControlsStrings.ViewAction)] + Appearance="Appearance.Lightweight" OnClick="@(() => OnShowPropertiesAsync(context, id))">@ControlStringsLoc[nameof(ControlsStrings.ViewAction)]
diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 7e4f999179..bd29565f36 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Otlp.Storage; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; +using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Pages; @@ -20,6 +21,7 @@ public partial class TraceDetail : ComponentBase private int _maxDepth; private List _applications = default!; private readonly List _collapsedSpanIds = []; + private string? _elementIdBeforeDetailsViewOpened; [Parameter] public required string TraceId { get; set; } @@ -33,6 +35,9 @@ public partial class TraceDetail : ComponentBase [Inject] public required BrowserTimeProvider TimeProvider { get; set; } + [Inject] + public required IJSRuntime JS { get; set; } + protected override void OnInitialized() { foreach (var resolver in OutgoingPeerResolvers) @@ -239,11 +244,13 @@ private void OnToggleCollapse(SpanWaterfallViewModel viewModel) } } - private void OnShowProperties(SpanWaterfallViewModel viewModel) + private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, string? buttonId) { + _elementIdBeforeDetailsViewOpened = buttonId; + if (SelectedSpan?.Span == viewModel.Span) { - ClearSelectedSpan(); + await ClearSelectedSpanAsync(); } else { @@ -262,9 +269,16 @@ private void OnShowProperties(SpanWaterfallViewModel viewModel) } } - private void ClearSelectedSpan() + private async Task ClearSelectedSpanAsync(bool causedByUserAction = false) { SelectedSpan = null; + + if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction) + { + await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened); + } + + _elementIdBeforeDetailsViewOpened = null; } private string GetResourceName(OtlpApplication app) => OtlpApplication.GetResourceName(app, _applications); diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index 1d1e3a21d8..1ad14ed4c5 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -21,6 +21,7 @@ public class OtlpLogEntry public string? OriginalFormat { get; } public OtlpApplication Application { get; } public OtlpScope Scope { get; } + public Guid InternalId { get; } public OtlpLogEntry(LogRecord record, OtlpApplication logApp, OtlpScope scope, TelemetryLimitOptions options) { @@ -56,6 +57,7 @@ public OtlpLogEntry(LogRecord record, OtlpApplication logApp, OtlpScope scope, T ParentId = parentId ?? string.Empty; Application = logApp; Scope = scope; + InternalId = Guid.NewGuid(); } private static LogLevel MapSeverity(SeverityNumber severityNumber) => severityNumber switch diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index da84c50dff..9624bb5f6f 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -419,3 +419,10 @@ window.getBrowserTimeZone = function () { return options.timeZone; } + +window.focusElement = function(selector) { + const element = document.getElementById(selector); + if (element) { + element.focus(); + } +}