Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@
OnResize="@(r => _manager.SetWidthFraction(r.Orientation == Orientation.Horizontal ? r.Panel1Fraction : 1))">
<DetailsTitleTemplate>
@{
var eventName = OtlpHelpers.GetValue(context!.LogEntry.Attributes, "event.name")
?? OtlpHelpers.GetValue(context!.LogEntry.Attributes, "logrecord.event.name")
?? Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)];
var eventName = StructureLogsDetailsViewModel.GetEventName(context!.LogEntry, Loc);
}

<div class="pane-details-title" title="@($"{eventName} ({context!.LogEntry.Scope.Name})")">
Expand Down
82 changes: 70 additions & 12 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
@using Aspire.Dashboard.Components.Controls.Grid
@using Aspire.Dashboard.Resources
@using Aspire.Dashboard.Utils
@inject IStringLocalizer<Dashboard.Resources.TraceDetail> Loc
@inject IStringLocalizer<ControlsStrings> ControlStringsLoc

<PageTitle>
<ApplicationName
Expand All @@ -22,7 +20,7 @@
<AspirePageContentLayout
AddNewlineOnToolbar="true"
MobileToolbarButtonText="@Loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailMobileToolbarButtonText)]"
IsSummaryDetailsViewOpen="@(SelectedSpan is not null)">
IsSummaryDetailsViewOpen="@(SelectedData is not null)">
<PageTitleSection>
<div class="page-header">
<h1>
Expand Down Expand Up @@ -73,16 +71,31 @@
</ToolbarSection>
<MainSection>
<SummaryDetailsView
ShowDetails="SelectedSpan is not null"
ShowDetails="SelectedData is not null"
OnDismiss="@(() => ClearSelectedSpanAsync(causedByUserAction: true))"
ViewKey="TraceDetail"
SelectedValue="@SelectedSpan"
SelectedValue="@SelectedData"
OnResize="@(r => _manager.SetWidthFraction(r.Orientation == Orientation.Horizontal ? r.Panel1Fraction : 1))">
<DetailsTitleTemplate>
@{ var shortedSpanId = OtlpHelpers.ToShortenedId(context!.Span.SpanId); }
<div class="pane-details-title" title="@($"{context!.Title} ({shortedSpanId})")">
@context!.Title
<span class="pane-details-subtext">@shortedSpanId</span>
@{
string? title = null;
string? subtitle = null;

if (context?.SpanViewModel is { } spanVm)
{
title = spanVm.Title;
subtitle = OtlpHelpers.ToShortenedId(spanVm.Span.SpanId);
}
else if (context?.LogEntryViewModel is { } logEntryVm)
{
title = StructureLogsDetailsViewModel.GetEventName(logEntryVm.LogEntry, StructuredLogsLoc);
subtitle = logEntryVm.LogEntry.Scope.Name;
}
}

<div class="pane-details-title" title="@($"{title} ({subtitle})")">
@title
<span class="pane-details-subtext">@subtitle</span>
</div>
</DetailsTitleTemplate>
<Summary>
Expand Down Expand Up @@ -203,9 +216,47 @@
</div>
</HeaderCellItemTemplate>
<ChildContent>
@{
var spanColor = @ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source));
}
<div class="ticks">
<div class="span-container" style="grid-template-columns: @context.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)% @context.Width.ToString("F2", CultureInfo.InvariantCulture)% min-content;">
<div class="span-bar" style="grid-column: 2; background: @ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source));"></div>
<div class="span-container" style="position: relative; grid-template-columns: @context.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)% @context.Width.ToString("F2", CultureInfo.InvariantCulture)% min-content;">
<div class="span-button-container">
@foreach (var item in context.SpanLogs)
{
var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}";
var eventName = StructureLogsDetailsViewModel.GetEventName(item.LogEntry, StructuredLogsLoc);
var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId;

<button id="@buttonId"
Copy link

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log entry buttons lack a visible label or aria-label, which makes them inaccessible to screen readers. Add an aria-label attribute (for example, aria-label="<event name>") to each button to provide an accessible name.

Copilot uses AI. Check for mistakes.
class="@($"span-log-entry-button {(isSelected ? "span-log-entry-selected" : null)}")"
data-span-color="@spanColor"
style="@($"left: {@item.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)}%; --span-color: {spanColor}")"
@onclick="@(() => ToggleSpanLogsAsync(item.LogEntry))"
@onclick:stopPropagation="true">
</button>
<FluentTooltip Anchor="@buttonId" MaxWidth="400px">
<div class="log-tooltip-title-container">
<span class="log-tooltip-title">@eventName</span> <span class="log-tooltip-subtitle">@item.LogEntry.Scope.Name</span><br />
</div>
<table class="log-tooltip-table">
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsLevelColumnHeader)]</td>
<td>@item.LogEntry.Severity</td>
</tr>
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsTimestampColumnHeader)]</td>
<td>@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, item.LogEntry.TimeStamp, MillisecondsDisplay.Truncated)</td>
</tr>
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsMessageColumnHeader)]</td>
<td>@OtlpHelpers.TruncateString(item.LogEntry.Message, maxLength: 200)</td>
</tr>
</table>
</FluentTooltip>
}
</div>
<div class="span-bar" style="grid-column: 2; background: @spanColor;"></div>
<div class="span-bar-label @(context.LabelIsRight ? "span-bar-label-right" : "span-bar-label-left")">
<span class="span-bar-label-detail">@SpanWaterfallViewModel.GetTitle(context.Span, _applications)</span>
<span>@DurationFormatter.FormatDuration(context.Span.Duration)</span>
Expand Down Expand Up @@ -233,7 +284,14 @@
</GridColumnManager>
</Summary>
<Details>
<SpanDetails ViewModel="context" />
@if (context?.SpanViewModel is { } spanVm)
{
<SpanDetails ViewModel="spanVm" />
}
else if (context?.LogEntryViewModel is { } logEntryVm)
{
<StructuredLogDetails ViewModel="logEntryVm" />
}
</Details>
</SummaryDetailsView>
</MainSection>
Expand Down
62 changes: 54 additions & 8 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;

namespace Aspire.Dashboard.Components.Pages;

Expand Down Expand Up @@ -64,6 +66,15 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp
[Inject]
public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }

[Inject]
public required IStringLocalizer<Dashboard.Resources.TraceDetail> Loc { get; init; }

[Inject]
public required IStringLocalizer<Dashboard.Resources.StructuredLogs> StructuredLogsLoc { get; init; }

[Inject]
public required IStringLocalizer<ControlsStrings> ControlStringsLoc { get; init; }

protected override void OnInitialized()
{
TelemetryContextProvider.Initialize(TelemetryContext);
Expand Down Expand Up @@ -195,8 +206,22 @@ private void UpdateDetailViewData()
return;
}

var logsContext = new GetLogsContext
{
ApplicationKey = null,
Count = int.MaxValue,
StartIndex = 0,
Filters = [new TelemetryFilter
{
Field = KnownStructuredLogFields.TraceIdField,
Condition = FilterCondition.Equals,
Value = _trace.TraceId
}]
};
var result = TelemetryRepository.GetLogs(logsContext);

Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count);
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result.Items, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
_maxDepth = _spanWaterfallViewModels.Max(s => s.Depth);

var apps = new HashSet<OtlpApplication>();
Expand All @@ -213,7 +238,7 @@ private void UpdateDetailViewData()

private async Task HandleAfterFilterBindAsync()
{
SelectedSpan = null;
SelectedData = null;
await InvokeAsync(StateHasChanged);

await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
Expand Down Expand Up @@ -242,15 +267,21 @@ private void UpdateSubscription()
private string GetRowClass(SpanWaterfallViewModel viewModel)
{
// Test with id rather than the object reference because the data and view model objects are recreated on trace updates.
if (viewModel.Span.SpanId == SelectedSpan?.Span.SpanId)
if (viewModel.Span.SpanId == SelectedData?.SpanViewModel?.Span.SpanId)
{
return "selected-row";
}

return string.Empty;
}

public SpanDetailsViewModel? SelectedSpan { get; set; }
public SelectedDataItem? SelectedData { get; set; }

public class SelectedDataItem
{
public SpanDetailsViewModel? SpanViewModel { get; init; }
public StructureLogsDetailsViewModel? LogEntryViewModel { get; init; }
}

private async Task OnToggleCollapse(SpanWaterfallViewModel viewModel)
{
Expand All @@ -275,7 +306,7 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin
{
_elementIdBeforeDetailsViewOpened = buttonId;

if (SelectedSpan?.Span.SpanId == viewModel.Span.SpanId)
if (SelectedData?.SpanViewModel?.Span.SpanId == viewModel.Span.SpanId)
{
await ClearSelectedSpanAsync();
}
Expand All @@ -300,7 +331,7 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin
Backlinks = backlinks,
};

SelectedSpan = spanDetailsViewModel;
SelectedData = new SelectedDataItem { SpanViewModel = spanDetailsViewModel };
}
}

Expand All @@ -323,7 +354,7 @@ private SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, Key

private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)
{
SelectedSpan = null;
SelectedData = null;

if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
{
Expand All @@ -335,6 +366,21 @@ private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)

private string GetResourceName(OtlpApplicationView app) => OtlpApplication.GetResourceName(app, _applications);

private async Task ToggleSpanLogsAsync(OtlpLogEntry logEntry)
{
if (SelectedData?.LogEntryViewModel?.LogEntry.InternalId == logEntry.InternalId)
{
await ClearSelectedSpanAsync();
}
else
{
SelectedData = new SelectedDataItem
{
LogEntryViewModel = new StructureLogsDetailsViewModel { LogEntry = logEntry }
};
}
}

public void Dispose()
{
foreach (var subscription in _peerChangesSubscriptions)
Expand Down
72 changes: 72 additions & 0 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,75 @@
display: flex;
column-gap: 8px;
}

::deep .span-button-container {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
}

::deep .span-log-entry-button {
position: absolute;
width: 15px;
height: 15px;
overflow: hidden;
border-radius: 50%;
align-self: center;
opacity: 0.8;
transform: translateX(-50%);
background: color-mix(in srgb, var(--span-color), white 30%);
border-color: color-mix(in srgb, var(--span-color), black 50%);
}

::deep .span-log-entry-button:hover {
opacity: 1;
background: color-mix(in srgb, var(--span-color), white 50%);
}

::deep .span-log-entry-selected {
opacity: 1;
background: color-mix(in srgb, var(--span-color), white 50%);
width: 20px;
height: 20px;
}

::deep .log-tooltip-title {
font-weight: bold;
font-size: 16px;
}

::deep .log-tooltip-subtitle {
color: var(--foreground-subtext-rest);
font-size: 12px;
padding-left: 0.5rem;
}

::deep.log-tooltip-title-container {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-bottom: 8px;
margin-top: 4px;
}

::deep.log-tooltip-table {
margin-bottom: 0;
width: 100%;
table-layout: fixed;
}

::deep.log-tooltip-table td:nth-child(1) {
width: 25%;
}

::deep.log-tooltip-table td {
padding: calc((var(--design-unit) + var(--focus-stroke-width) - var(--stroke-width))* 1px) 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-content: center;
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
}
Loading