Skip to content

Commit f739837

Browse files
authored
Fix various bugs in dashboard metrics (#5522)
* Fix various bugs in dashboard metrics * Comment * Add tests
1 parent 06ee4ae commit f739837

File tree

8 files changed

+120
-30
lines changed

8 files changed

+120
-30
lines changed

src/Aspire.Dashboard/Components/Controls/Chart/MetricTable.razor

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
<div id="metric-table-container" style="height: 40vh; overflow-y: auto; width:1200px;">
2222
@* ItemKey is to preserve row focus by associating rows with their associated time *@
2323
<FluentDataGrid
24-
ResizeLabel="@AspireFluentDataGridHeaderCell.GetResizeLabel(ControlsStringsLoc)"
25-
ResizeType="DataGridResizeType.Discrete"
2624
Items="@_metricsView"
2725
ItemSize="46"
2826
Virtualize="true"
@@ -37,7 +35,7 @@
3735
{
3836
foreach (var (percentile, underlineColor) in percentileColumns)
3937
{
40-
<AspireTemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument, titleCase: true, pluralize: true)}" : $"P{percentile}"))">
38+
<TemplateColumn Title="@((_metricsView.FirstOrDefault() as HistogramMetricView)?.Percentiles[percentile].Name ?? (_instrument is not null ? $"P{percentile} {InstrumentUnitResolver.ResolveDisplayedUnit(_instrument, titleCase: true, pluralize: true)}" : $"P{percentile}"))">
4139
@if (context is HistogramMetricView histogramMetric)
4240
{
4341
var percentileData = histogramMetric.Percentiles[percentile];
@@ -51,13 +49,13 @@
5149
<FluentIcon Style="vertical-align: text-bottom" Value="@icon" Title="@title"/>
5250
}
5351
}
54-
</AspireTemplateColumn>
52+
</TemplateColumn>
5553
}
5654
}
5755
else if (_metrics.Values.All(value => value is MetricValueView))
5856
{
5957
<!-- if we're switching between grid types, this could be false -->
60-
<AspireTemplateColumn Title="@_unitColumnHeader">
58+
<TemplateColumn Title="@_unitColumnHeader">
6159
@{
6260
var metricValueView = context as MetricValueView;
6361
}
@@ -78,11 +76,11 @@
7876
<FluentIcon Style="vertical-align: text-bottom" Value="@icon" Title="@title"/>
7977
}
8078
}
81-
</AspireTemplateColumn>
79+
</TemplateColumn>
8280
}
8381
@if (_exemplars.Count > 0)
8482
{
85-
<AspireTemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableExemplarsColumnHeader)]">
83+
<TemplateColumn Title="@Loc[nameof(ControlsStrings.MetricTableExemplarsColumnHeader)]">
8684
@if (context.Exemplars.Count > 0)
8785
{
8886
@* min-width ensures a consistent button width up to 999 metrics *@
@@ -95,7 +93,7 @@
9593
{
9694
<span>0</span>
9795
}
98-
</AspireTemplateColumn>
96+
</TemplateColumn>
9997
}
10098
</ChildContent>
10199
<EmptyContent>

src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,25 @@
1414
@inject IStringLocalizer<ControlsStrings> ControlsStringsLoc
1515

1616
<div style="max-height: 44vh; overflow-y: auto;">
17-
<FluentDataGrid ResizeLabel="@AspireFluentDataGridHeaderCell.GetResizeLabel(ControlsStringsLoc)"
18-
ResizeType="DataGridResizeType.Discrete"
19-
Items="@MetricView"
17+
<FluentDataGrid Items="@MetricView"
2018
ItemSize="46"
2119
GridTemplateColumns="2fr 1fr 1fr 1fr"
2220
TGridItem="ChartExemplar">
2321
<ChildContent>
24-
<AspireTemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogTraceColumnHeader)]" TooltipText="@(context => GetTitle(context))" Tooltip="true">
22+
<TemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogTraceColumnHeader)]" TooltipText="@(context => GetTitle(context))" Tooltip="true">
2523
<span style="padding-left:5px; border-left-width: 5px; border-left-style: solid; border-left-color: @(context.Span != null ? ColorGenerator.Instance.GetColorHexByKey(OtlpApplication.GetResourceName(context.Span.Source, Content.Applications)) : "transparent");">
2624
@GetTitle(context)
2725
</span>
28-
</AspireTemplateColumn>
29-
<AspireTemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogTimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, TimeProvider.ToLocal(context.Start), MillisecondsDisplay.None, CultureInfo.CurrentCulture))" Tooltip="true">
26+
</TemplateColumn>
27+
<TemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogTimestampColumnHeader)]" TooltipText="@(context => FormatHelpers.FormatDateTime(TimeProvider, TimeProvider.ToLocal(context.Start), MillisecondsDisplay.None, CultureInfo.CurrentCulture))" Tooltip="true">
3028
@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, TimeProvider.ToLocal(context.Start), MillisecondsDisplay.Truncated)
31-
</AspireTemplateColumn>
32-
<AspireTemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogValueColumnHeader)]">
29+
</TemplateColumn>
30+
<TemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogValueColumnHeader)]">
3331
@FormatMetricValue(context.Value)
34-
</AspireTemplateColumn>
35-
<AspireTemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogDetailsColumnHeader)]">
32+
</TemplateColumn>
33+
<TemplateColumn Title="@Loc[nameof(Dialogs.ExemplarsDialogDetailsColumnHeader)]">
3634
<FluentButton Appearance="Appearance.Lightweight" OnClick="@(() => OnViewDetailsAsync(context))">View</FluentButton>
37-
</AspireTemplateColumn>
35+
</TemplateColumn>
3836
</ChildContent>
3937
<EmptyContent>
4038
<FluentIcon Icon="Icons.Regular.Size24.ChartMultiple" />&nbsp;@Loc[nameof(ControlsStrings.MetricTableNoMetricsFound)]

src/Aspire.Dashboard/Components/Pages/Metrics.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
TGridItem="OtlpInstrumentSummary">
9292
<ChildContent>
9393
<TemplateColumn Title="@Loc[nameof(Dashboard.Resources.Metrics.MetricsInsturementNameGridNameColumnHeader)]">
94-
<FluentAnchor Href="@DashboardUrls.MetricsUrl(resource: PageViewModel.SelectedApplication.Name, meter: context.Parent.MeterName, instrument: context.Name)" Appearance="Appearance.Lightweight">
94+
<FluentAnchor Href="@DashboardUrls.MetricsUrl(resource: PageViewModel.SelectedApplication.Name, meter: context.Parent.MeterName, instrument: context.Name, duration: DurationMinutes, view: ViewKindName)" Appearance="Appearance.Lightweight">
9595
@context.Name
9696
</FluentAnchor>
9797
</TemplateColumn>

src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public partial class Metrics : IDisposable, IPageWithSessionAndUrlState<Metrics.
4343

4444
[Parameter]
4545
[SupplyParameterFromQuery(Name = "duration")]
46-
public int DurationMinutes { get; set; }
46+
public int? DurationMinutes { get; set; }
4747

4848
[Parameter]
4949
[SupplyParameterFromQuery(Name = "view")]
@@ -238,15 +238,11 @@ private Task HandleSelectedTreeItemChangedAsync()
238238

239239
public string GetUrlFromSerializableViewModel(MetricsPageState serializable)
240240
{
241-
var duration = PageViewModel.SelectedDuration.Id != s_defaultDuration
242-
? (int?)serializable.DurationMinutes
243-
: null;
244-
245241
var url = DashboardUrls.MetricsUrl(
246242
resource: serializable.ApplicationName,
247243
meter: serializable.MeterName,
248244
instrument: serializable.InstrumentName,
249-
duration: duration,
245+
duration: serializable.DurationMinutes,
250246
view: serializable.ViewKind);
251247

252248
return url;

src/Aspire.Dashboard/wwwroot/js/app-metrics.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,12 @@ export function initializeChart(id, traces, exemplarTrace, rangeStartTime, range
119119

120120
const resizeObserver = new ResizeObserver(entries => {
121121
for (let entry of entries) {
122-
Plotly.Plots.resize(entry.target);
122+
// Don't resize if not visible.
123+
var display = window.getComputedStyle(entry.target).display;
124+
var isHidden = !display || display === "none";
125+
if (!isHidden) {
126+
Plotly.Plots.resize(entry.target);
127+
}
123128
}
124129
});
125130
plot.then(plotyDiv => {

tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Web;
45
using Aspire.Dashboard.Components.Controls;
56
using Aspire.Dashboard.Components.Pages;
67
using Aspire.Dashboard.Components.Resize;
@@ -14,6 +15,7 @@
1415
using Microsoft.Extensions.DependencyInjection;
1516
using OpenTelemetry.Proto.Metrics.V1;
1617
using Xunit;
18+
using static Aspire.Dashboard.Components.Pages.Metrics;
1719
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
1820

1921
namespace Aspire.Dashboard.Components.Tests.Pages;
@@ -41,13 +43,87 @@ public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared
4143
expectedInstrumentNameAfterChange: null);
4244
}
4345

46+
[Fact]
47+
public void InitialLoad_HasSessionState_RedirectUsingState()
48+
{
49+
// Arrange
50+
var testSessionStorage = new TestSessionStorage
51+
{
52+
OnGetAsync = key =>
53+
{
54+
if (key == BrowserStorageKeys.MetricsPageState)
55+
{
56+
var state = new MetricsPageState
57+
{
58+
ApplicationName = "TestApp",
59+
MeterName = "test-meter",
60+
InstrumentName = "test-instrument",
61+
DurationMinutes = 720,
62+
ViewKind = MetricViewKind.Table.ToString()
63+
};
64+
return (true, state);
65+
}
66+
else
67+
{
68+
throw new InvalidOperationException("Unexpected key: " + key);
69+
}
70+
}
71+
};
72+
MetricsSetupHelpers.SetupMetricsPage(this, sessionStorage: testSessionStorage);
73+
74+
var navigationManager = Services.GetRequiredService<NavigationManager>();
75+
navigationManager.NavigateTo(DashboardUrls.MetricsUrl());
76+
77+
Uri? loadRedirect = null;
78+
navigationManager.LocationChanged += (s, a) =>
79+
{
80+
loadRedirect = new Uri(a.Location);
81+
};
82+
83+
var telemetryRepository = Services.GetRequiredService<TelemetryRepository>();
84+
telemetryRepository.AddMetrics(new AddContext(), new RepeatedField<ResourceMetrics>
85+
{
86+
new ResourceMetrics
87+
{
88+
Resource = CreateResource(name: "TestApp"),
89+
ScopeMetrics =
90+
{
91+
new ScopeMetrics
92+
{
93+
Scope = CreateScope(name: "test-meter"),
94+
Metrics =
95+
{
96+
CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
97+
}
98+
}
99+
}
100+
}
101+
});
102+
103+
// Act
104+
var cut = RenderComponent<Metrics>(builder =>
105+
{
106+
builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
107+
});
108+
109+
// Assert
110+
Assert.NotNull(loadRedirect);
111+
Assert.Equal("/metrics/resource/TestApp", loadRedirect.AbsolutePath);
112+
113+
var query = HttpUtility.ParseQueryString(loadRedirect.Query);
114+
Assert.Equal("test-meter", query["meter"]);
115+
Assert.Equal("test-instrument", query["instrument"]);
116+
Assert.Equal("720", query["duration"]);
117+
Assert.Equal(MetricViewKind.Table.ToString(), query["view"]);
118+
}
119+
44120
private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string app2InstrumentName, string? expectedInstrumentNameAfterChange)
45121
{
46122
// Arrange
47123
MetricsSetupHelpers.SetupMetricsPage(this);
48124

49125
var navigationManager = Services.GetRequiredService<NavigationManager>();
50-
navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName));
126+
navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName, duration: 720, view: MetricViewKind.Table.ToString()));
51127

52128
var telemetryRepository = Services.GetRequiredService<TelemetryRepository>();
53129
telemetryRepository.AddMetrics(new AddContext(), new RepeatedField<ResourceMetrics>
@@ -110,5 +186,8 @@ private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string
110186
// Meter is cleared if instrument is cleared.
111187
Assert.Equal("test-meter", viewModel.SelectedMeter!.MeterName);
112188
}
189+
190+
Assert.Equal(MetricViewKind.Table, viewModel.SelectedViewKind);
191+
Assert.Equal(TimeSpan.FromMinutes(720), viewModel.SelectedDuration.Id);
113192
}
114193
}

tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ internal static void SetupPlotlyChart(TestContext context)
4747
context.Services.AddSingleton<IDialogService, DialogService>();
4848
}
4949

50-
internal static void SetupMetricsPage(TestContext context)
50+
internal static void SetupMetricsPage(TestContext context, ISessionStorage? sessionStorage = null)
5151
{
5252
var version = typeof(FluentMain).Assembly.GetName().Version!;
5353

@@ -78,7 +78,7 @@ internal static void SetupMetricsPage(TestContext context)
7878
context.Services.AddSingleton<DimensionManager>();
7979
context.Services.AddSingleton<IDialogService, DialogService>();
8080
context.Services.AddSingleton<BrowserTimeProvider, TestTimeProvider>();
81-
context.Services.AddSingleton<ISessionStorage, TestSessionStorage>();
81+
context.Services.AddSingleton<ISessionStorage>(sessionStorage ?? new TestSessionStorage());
8282
context.Services.AddSingleton<ILocalStorage, TestLocalStorage>();
8383
context.Services.AddSingleton<ShortcutManager>();
8484
context.Services.AddSingleton<LibraryConfiguration>();

tests/Aspire.Dashboard.Components.Tests/Shared/TestSessionStorage.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,27 @@ namespace Aspire.Dashboard.Components.Tests.Shared;
77

88
public sealed class TestSessionStorage : ISessionStorage
99
{
10+
public Func<string, (bool Success, object? Value)>? OnGetAsync { get; set; }
11+
public Action<string, object?>? OnSetAsync { get; set; }
12+
1013
public Task<StorageResult<T>> GetAsync<T>(string key)
1114
{
15+
if (OnGetAsync is { } callback)
16+
{
17+
var (success, value) = callback(key);
18+
return Task.FromResult(new StorageResult<T>(Success: success, Value: (T)(value ?? default(T))!));
19+
}
20+
1221
return Task.FromResult<StorageResult<T>>(new StorageResult<T>(Success: false, Value: default));
1322
}
1423

1524
public Task SetAsync<T>(string key, T value)
1625
{
26+
if (OnSetAsync is { } callback)
27+
{
28+
callback(key, value);
29+
}
30+
1731
return Task.CompletedTask;
1832
}
1933
}

0 commit comments

Comments
 (0)