diff --git a/spelling.dic b/spelling.dic index dbad166156c..0dd2dd607ca 100644 --- a/spelling.dic +++ b/spelling.dic @@ -46,6 +46,7 @@ rediscommander runtimeconfig sqlserver trce +uninstrumented upsert uris urls diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index 2e2c7aa9475..1aa24229e62 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -64,58 +64,65 @@ if (!isServerKind) { // Client span has 19px extra content: + // - 5px extra margin-left // - 5px border // - 9px padding-left - // - 5px extra margin-left - marginLeft += 5; - spanNameContainerStyle = $"margin-left: {marginLeft}px; border-left-color: {ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))}; border-left-width: 5px; border-left-style: solid; padding-left: 9px;"; + spanNameContainerStyle = $"margin-left: 5px; border-left-color: {ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source))}; border-left-width: 5px; border-left-style: solid; padding-left: 9px;"; } else { // Server span has 19px extra content: // - 16px icon // - 3px padding-left - spanNameContainerStyle = $"margin-left: {marginLeft}px;"; + spanNameContainerStyle = string.Empty; } } - - @if (isServerKind) - { - - } - @if (context.IsError) - { - - } - @GetResourceName(context.Span.Source) - @if (context.HasUninstrumentedPeer) - { - - @{ - Icon icon; - if (context.Span.Attributes.HasKey("db.system")) - { - icon = new Icons.Filled.Size16.Database(); - } - else - { - // Everything else. - icon = new Icons.Filled.Size16.ArrowCircleRight(); + + + @if (context.Children.Count > 0) + { + @(context.IsCollapsed ? '+' : '-') + } + + + @if (isServerKind) + { + + } + + @if (context.IsError) + { + + } + @GetResourceName(context.Span.Source) + @if (context.HasUninstrumentedPeer) + { + + @{ + Icon icon; + if (context.Span.Attributes.HasKey("db.system")) + { + icon = new Icons.Filled.Size16.Database(); + } + else + { + // Everything else. + icon = new Icons.Filled.Size16.ArrowCircleRight(); + } } - } - - @context.UninstrumentedPeer - - } - @context.GetDisplaySummary() + + @context.UninstrumentedPeer + + } + @context.GetDisplaySummary() + diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index fa1ef264e2e..bbd6ae8d312 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -20,6 +20,7 @@ public partial class TraceDetail : ComponentBase private List? _spanWaterfallViewModels; private int _maxDepth; private List _applications = default!; + private readonly List _collapsedSpanIds = []; [Parameter] public required string TraceId { get; set; } @@ -49,41 +50,46 @@ private ValueTask> GetData(GridI { Debug.Assert(_spanWaterfallViewModels != null); + var visibleSpanWaterfallViewModels = _spanWaterfallViewModels.Where(viewModel => !viewModel.IsHidden).ToList(); return ValueTask.FromResult(new GridItemsProviderResult { - Items = _spanWaterfallViewModels, + Items = visibleSpanWaterfallViewModels, TotalItemCount = _spanWaterfallViewModels.Count }); } - private static List CreateSpanWaterfallViewModels(OtlpTrace trace, IEnumerable outgoingPeerResolvers) + private static List CreateSpanWaterfallViewModels(OtlpTrace trace, TraceDetailState state) { var orderedSpans = new List(); // There should be one root span but just in case, we'll add them all. foreach (var rootSpan in trace.Spans.Where(s => string.IsNullOrEmpty(s.ParentSpanId)).OrderBy(s => s.StartTime)) { - AddSelfAndChildren(orderedSpans, rootSpan, depth: 1, outgoingPeerResolvers, CreateViewModel); + AddSelfAndChildren(orderedSpans, rootSpan, depth: 1, hidden: false, state, CreateViewModel); } // Unparented spans. foreach (var unparentedSpan in trace.Spans.Where(s => !string.IsNullOrEmpty(s.ParentSpanId) && s.GetParentSpan() == null).OrderBy(s => s.StartTime)) { - AddSelfAndChildren(orderedSpans, unparentedSpan, depth: 1, outgoingPeerResolvers, CreateViewModel); + AddSelfAndChildren(orderedSpans, unparentedSpan, depth: 1, hidden: false, state, CreateViewModel); } return orderedSpans; - static void AddSelfAndChildren(List orderedSpans, OtlpSpan span, int depth, IEnumerable outgoingPeerResolvers, Func, SpanWaterfallViewModel> createViewModel) + static SpanWaterfallViewModel AddSelfAndChildren(List orderedSpans, OtlpSpan span, int depth, bool hidden, TraceDetailState state, Func createViewModel) { - orderedSpans.Add(createViewModel(span, depth, outgoingPeerResolvers)); + var viewModel = createViewModel(span, depth, hidden, state); + orderedSpans.Add(viewModel); depth++; foreach (var child in span.GetChildSpans().OrderBy(s => s.StartTime)) { - AddSelfAndChildren(orderedSpans, child, depth, outgoingPeerResolvers, createViewModel); + var childViewModel = AddSelfAndChildren(orderedSpans, child, depth, viewModel.IsHidden || viewModel.IsCollapsed, state, createViewModel); + viewModel.Children.Add(childViewModel); } + + return viewModel; } - static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, IEnumerable outgoingPeerResolvers) + static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hidden, TraceDetailState state) { var traceStart = span.Trace.FirstSpan.StartTime; var relativeStart = span.StartTime - traceStart; @@ -99,10 +105,11 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, IEnumera // A span may indicate a call to another service but the service isn't instrumented. var hasPeerService = span.Attributes.Any(a => a.Key == OtlpSpan.PeerServiceAttributeKey); var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any(); - var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, outgoingPeerResolvers) : null; + var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, state.OutgoingPeerResolvers) : null; var viewModel = new SpanWaterfallViewModel { + Children = [], Span = span, LeftOffset = leftOffset, Width = width, @@ -110,6 +117,17 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, IEnumera LabelIsRight = labelIsRight, UninstrumentedPeer = uninstrumentedPeer }; + + // Restore hidden/collapsed state to new view model. + if (state.CollapsedSpanIds.Contains(span.SpanId)) + { + viewModel.IsCollapsed = true; + } + if (hidden) + { + viewModel.IsHidden = true; + } + return viewModel; } } @@ -146,7 +164,7 @@ private void UpdateDetailViewData() _trace = TelemetryRepository.GetTrace(TraceId); if (_trace != null) { - _spanWaterfallViewModels = CreateSpanWaterfallViewModels(_trace, OutgoingPeerResolvers); + _spanWaterfallViewModels = CreateSpanWaterfallViewModels(_trace, new TraceDetailState(OutgoingPeerResolvers, _collapsedSpanIds)); _maxDepth = _spanWaterfallViewModels.Max(s => s.Depth); if (_tracesSubscription is null || _tracesSubscription.ApplicationId != _trace.FirstSpan.Source.InstanceId) @@ -181,6 +199,22 @@ private string GetRowClass(SpanWaterfallViewModel viewModel) public SpanDetailsViewModel? SelectedSpan { get; set; } + private void OnToggleCollapse(SpanWaterfallViewModel viewModel) + { + // View model data is recreated if the trace updates. + // Persist the collapsed state in a separate list. + if (viewModel.IsCollapsed) + { + viewModel.IsCollapsed = false; + _collapsedSpanIds.Remove(viewModel.Span.SpanId); + } + else + { + viewModel.IsCollapsed = true; + _collapsedSpanIds.Add(viewModel.Span.SpanId); + } + } + private void OnShowProperties(SpanWaterfallViewModel viewModel) { if (SelectedSpan?.Span == viewModel.Span) @@ -219,4 +253,6 @@ public void Dispose() } _tracesSubscription?.Dispose(); } + + private sealed record TraceDetailState(IEnumerable OutgoingPeerResolvers, List CollapsedSpanIds); } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css index 108e7d436f6..dab81b4c1cd 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css @@ -86,7 +86,17 @@ grid-row: 1; } +::deep .span-collapse-symbol { + display: inline-block; + width: 20px; + text-align: center; + padding: 0px 5px; + line-height: 28px; + user-select: none; +} + ::deep .span-name-container { + margin-left: -3px; line-height: 28px; } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 74851f62055..09d8b660f04 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -9,16 +9,29 @@ namespace Aspire.Dashboard.Model.Otlp; public sealed class SpanWaterfallViewModel { + public required List Children { get; init; } public required OtlpSpan Span { get; init; } public required double LeftOffset { get; init; } public required double Width { get; init; } public required int Depth { get; init; } public required bool LabelIsRight { get; init; } public required string? UninstrumentedPeer { get; init; } + public bool IsHidden { get; set; } [MemberNotNullWhen(true, nameof(UninstrumentedPeer))] public bool HasUninstrumentedPeer => !string.IsNullOrEmpty(UninstrumentedPeer); public bool IsError => Span.Status == OtlpSpanStatusCode.Error; + private bool _isCollapsed; + public bool IsCollapsed + { + get => _isCollapsed; + set + { + _isCollapsed = value; + UpdateHidden(); + } + } + public string GetTooltip() { var tooltip = $"{Span.Source.ApplicationName}: {GetDisplaySummary()}"; @@ -86,4 +99,10 @@ public string GetDisplaySummary() return Span.Name; } + + private void UpdateHidden(bool isParentCollapsed = false) + { + IsHidden = isParentCollapsed; + Children.ForEach(child => child.UpdateHidden(isParentCollapsed || IsCollapsed)); + } }