File: Pages\MetricsTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Web;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Pages;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Model.MetricValues;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared;
using Aspire.Tests.Shared.Telemetry;
using Bunit;
using Google.Protobuf.Collections;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Time.Testing;
using Microsoft.FluentUI.AspNetCore.Components;
using OpenTelemetry.Proto.Metrics.V1;
using Aspire.Tests;
using Xunit;
using static Aspire.Dashboard.Components.Pages.Metrics;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
 
namespace Aspire.Dashboard.Components.Tests.Pages;
 
[UseCulture("en-US")]
public partial class MetricsTests : DashboardTestContext
{
    [Theory]
    [InlineData(AspireDataType.StructuredLogs)]
    [InlineData(AspireDataType.Traces)]
    [InlineData(AspireDataType.Metrics)]
    public async Task EmptyState_HelpDisappearsAfterAnySignalAndStaysHiddenAfterClear(AspireDataType signal)
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var cut = Render<Metrics>(parameters => parameters.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".telemetry-help-button")));
        Assert.Equal(Resources.Metrics.MetricsSelectAResource, cut.Find(".telemetry-empty-content > div").TextContent.Trim());
 
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await TelemetryHelpSetupHelpers.AddSignalAsync(repository, signal);
        cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".telemetry-help-button")));
        await repository.ClearStructuredLogsAsync();
        await repository.ClearTracesAsync();
        await repository.ClearMetricsAsync();
 
        cut.WaitForAssertion(() =>
        {
            Assert.Single(cut.FindAll(".telemetry-empty-content"));
            Assert.Empty(cut.FindAll(".telemetry-help-button"));
        });
    }
 
    [Fact]
    public async Task EmptyState_SelectedResourceWithoutMetrics_ShowsHelp()
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
        await Services.GetRequiredService<SqliteTelemetryRepository>().AddMetricsAsync(new(),
            [new ResourceMetrics { Resource = CreateResource("test") }]);
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
 
        var cut = Render<Metrics>(parameters => parameters.AddCascadingValue(viewport));
 
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".telemetry-help-button")));
        Assert.Equal(Resources.Metrics.MetricsNoMetricsForResource, cut.Find(".telemetry-empty-content > div").TextContent.Trim());
    }
 
    private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
 
    [Theory]
    [InlineData(MetricViewKind.Graph)]
    [InlineData(MetricViewKind.Table)]
    public async Task ChartContainer_NoSharedHistogramBounds_ShowsWarningAndCountRemainsAvailable(MetricViewKind view)
    {
        JSInterop.Mode = JSRuntimeMode.Loose;
        MetricsSetupHelpers.SetupMetricsPage(this);
        var timeProvider = new TestTimeProvider { UtcNow = DateTimeOffset.UtcNow };
        Services.AddSingleton<BrowserTimeProvider>(timeProvider);
        Services.AddSingleton<TimeProvider>(new FakeTimeProvider(timeProvider.UtcNow));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        var time = timeProvider.GetUtcNow().UtcDateTime.AddSeconds(-3);
        var first = HistogramTestHelpers.CreatePoint(time, time.AddSeconds(1), [3, 0], [10]);
        var second = HistogramTestHelpers.CreatePoint(time, time.AddSeconds(1), [5, 0], [20]);
        first.Attributes.Add(new OpenTelemetry.Proto.Common.V1.KeyValue
        {
            Key = "instance",
            Value = new OpenTelemetry.Proto.Common.V1.AnyValue { StringValue = "first" }
        });
        second.Attributes.Add(new OpenTelemetry.Proto.Common.V1.KeyValue
        {
            Key = "instance",
            Value = new OpenTelemetry.Proto.Common.V1.AnyValue { StringValue = "second" }
        });
        await repository.AddMetricsAsync(new AddContext(),
            [HistogramTestHelpers.CreateMetrics(AggregationTemporality.Delta, first, second)]);
        var resource = Assert.Single(repository.GetResources());
        var cut = Render<ChartContainer>(builder =>
        {
            builder.Add(component => component.ResourceKey, resource.ResourceKey);
            builder.Add(component => component.MeterName, "test-meter");
            builder.Add(component => component.InstrumentName, "histogram");
            builder.Add(component => component.Duration, TimeSpan.FromMinutes(1));
            builder.Add(component => component.ActiveView, view);
            builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask);
            builder.Add(component => component.Resources, [resource]);
        });
 
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(
                Resources.ControlsStrings.ChartContainerIncompatibleHistogramBounds,
                cut.Find(".block-warning").TextContent.Trim());
            if (view == MetricViewKind.Graph)
            {
                AssertUnavailablePercentiles();
            }
        });
 
        var instrumentViewModel = view == MetricViewKind.Graph
            ? cut.FindComponent<PlotlyChart>().Instance.InstrumentViewModel
            : cut.FindComponent<MetricTable>().Instance.InstrumentViewModel;
        Assert.True(instrumentViewModel.HasIncompatibleHistogramBounds);
        Assert.False(instrumentViewModel.HasOverflow);
 
        var filters = cut.FindComponent<ChartFilters>();
        await cut.InvokeAsync(() => filters.Instance.ShowCountChanged.InvokeAsync(true));
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(
                Resources.ControlsStrings.ChartContainerIncompatibleHistogramBounds,
                cut.Find(".block-warning").TextContent.Trim());
            if (view == MetricViewKind.Graph)
            {
                Assert.True(cut.FindComponent<PlotlyChart>().Instance.InstrumentViewModel.ShowCount);
                var invocation = JSInterop.Invocations.Last(i => i.Identifier is "initializeChart" or "updateChart");
                var trace = Assert.Single(Assert.IsAssignableFrom<IEnumerable<PlotlyTrace>>(invocation.Arguments[1]));
                Assert.Equal("Count", trace.Name);
                Assert.Contains(8d, trace.Y);
            }
            else
            {
                Assert.True(cut.FindComponent<MetricTable>().Instance.InstrumentViewModel.ShowCount);
                Assert.Equal(["Time", "Count"], cut.FindComponent<MetricTable>().FindAll("th").Select(column => column.TextContent.Trim()));
            }
        });
 
        await cut.InvokeAsync(() => filters.Instance.ShowCountChanged.InvokeAsync(false));
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(
                Resources.ControlsStrings.ChartContainerIncompatibleHistogramBounds,
                cut.Find(".block-warning").TextContent.Trim());
            if (view == MetricViewKind.Graph)
            {
                AssertUnavailablePercentiles();
            }
        });
 
        var instrument = instrumentViewModel.Instrument;
        var dimensions = instrumentViewModel.MatchedDimensions;
        Assert.NotNull(instrument);
        Assert.NotNull(dimensions);
 
        timeProvider.UtcNow = timeProvider.UtcNow.AddSeconds(2);
        await cut.InvokeAsync(() => instrumentViewModel.UpdateDataAsync(instrument, dimensions, hasOverflow: true));
        cut.WaitForAssertion(() =>
        {
            Assert.True(instrumentViewModel.HasIncompatibleHistogramBounds);
            Assert.Equal(2, cut.FindAll(".block-warning").Count);
            Assert.Equal(
                Resources.ControlsStrings.ChartContainerOverflowTitle,
                cut.Find(".block-warning .title").TextContent);
        });
 
        timeProvider.UtcNow = timeProvider.UtcNow.AddSeconds(2);
        await cut.InvokeAsync(() => instrumentViewModel.UpdateDataAsync(instrument, dimensions, hasOverflow: false));
        cut.WaitForAssertion(() => Assert.Equal(
            Resources.ControlsStrings.ChartContainerIncompatibleHistogramBounds,
            Assert.Single(cut.FindAll(".block-warning")).TextContent.Trim()));
 
        void AssertUnavailablePercentiles()
        {
            var invocation = JSInterop.Invocations.Last(i => i.Identifier is "initializeChart" or "updateChart");
            var traces = Assert.IsAssignableFrom<IEnumerable<PlotlyTrace>>(invocation.Arguments[1]).ToArray();
            Assert.Equal(3, traces.Length);
            Assert.All(traces, trace =>
            {
                Assert.Equal(32, trace.Y.Count);
                Assert.Equal(trace.X.Count, trace.Y.Count);
                Assert.All(trace.Y, Assert.Null);
            });
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task ChartContainer_UnavailableHistogramPercentiles_TablePreservesExemplars(bool includeAvailableIntervals, bool onlyShowValueChanges)
    {
        JSInterop.Mode = JSRuntimeMode.Loose;
        MetricsSetupHelpers.SetupMetricsPage(this);
        JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.observeAttributeChange", _ => true).SetVoidResult();
        var timeProvider = new TestTimeProvider { UtcNow = DateTimeOffset.UtcNow };
        Services.AddSingleton<BrowserTimeProvider>(timeProvider);
        Services.AddSingleton<TimeProvider>(new FakeTimeProvider(timeProvider.UtcNow));
        var dialogs = new ConcurrentQueue<ExemplarsDialogViewModel>();
        Services.AddSingleton<IDialogService>(new TestDialogService((content, _) =>
        {
            dialogs.Enqueue(Assert.IsType<ExemplarsDialogViewModel>(content));
            return Task.CompletedTask;
        }));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        var time = timeProvider.GetUtcNow().UtcDateTime.AddSeconds(-5);
        var points = new List<HistogramDataPoint>();
        if (includeAvailableIntervals)
        {
            AddPoints(time.AddSeconds(-2), compatibleBounds: true, exemplarValue: 1);
        }
        AddPoints(time, compatibleBounds: false, exemplarValue: 2);
        if (includeAvailableIntervals)
        {
            AddPoints(time.AddSeconds(2), compatibleBounds: true, exemplarValue: 3);
        }
        var addContext = new AddContext();
        await repository.AddMetricsAsync(addContext, [HistogramTestHelpers.CreateMetrics(AggregationTemporality.Delta, [.. points])]);
        Assert.Equal(0, addContext.FailureCount);
 
        var resource = Assert.Single(repository.GetResources());
        var cut = Render<ChartContainer>(builder =>
        {
            builder.Add(component => component.ResourceKey, resource.ResourceKey);
            builder.Add(component => component.MeterName, "test-meter");
            builder.Add(component => component.InstrumentName, "histogram");
            builder.Add(component => component.Duration, TimeSpan.FromMinutes(1));
            builder.Add(component => component.ActiveView, MetricViewKind.Table);
            builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask);
            builder.Add(component => component.Resources, [resource]);
        });
        cut.WaitForState(() => cut.FindComponents<MetricTable>().Count == 1);
        var table = cut.FindComponent<MetricTable>();
        var instrumentViewModel = table.Instance.InstrumentViewModel;
        cut.WaitForAssertion(() =>
        {
            Assert.NotNull(instrumentViewModel.Instrument);
            Assert.NotNull(instrumentViewModel.MatchedDimensions);
        });
        await cut.InvokeAsync(async () =>
        {
            table.Instance.OnlyShowValueChangesInTable = onlyShowValueChanges;
            timeProvider.UtcNow = timeProvider.UtcNow.AddSeconds(3);
            await instrumentViewModel.UpdateDataAsync(instrumentViewModel.Instrument!, instrumentViewModel.MatchedDimensions!, hasOverflow: false);
        });
 
        // Two-second chart windows are aligned one second behind the live clock.
        var expectedTime = FormatHelpers.FormatTimeWithOptionalDate(timeProvider, timeProvider.ToLocal(new DateTimeOffset(time.AddSeconds(2))));
        cut.WaitForAssertion(() =>
        {
            Assert.True(instrumentViewModel.HasIncompatibleHistogramBounds);
            Assert.Equal(includeAvailableIntervals ? ["1", "2", "1"] : ["2"],
                table.FindAll("fluent-button").Select(button => button.TextContent.Trim()));
            Assert.Equal((includeAvailableIntervals ? 3 : 1) * 5, table.FindAll("[role='gridcell']").Count);
            var button = Assert.Single(table.FindAll("fluent-button"), button => button.TextContent.Trim() == "2");
            var row = button.Closest("[role='row']");
            Assert.NotNull(row);
            Assert.Equal([expectedTime, "", "", "", "2"], row.QuerySelectorAll("[role='gridcell']").Select(cell => cell.TextContent.Trim()));
            Assert.All(row.QuerySelectorAll("[role='gridcell']").Skip(1).Take(3),
                cell => Assert.Equal("empty-data", Assert.Single(cell.Children).ClassName));
        });
 
        table.FindAll("fluent-button").Single(button => button.TextContent.Trim() == "2").Click();
        var dialog = Assert.Single(dialogs);
        Assert.Same(instrumentViewModel.Instrument, dialog.Instrument);
        Assert.Equal([2d, 22d], dialog.Exemplars.Select(exemplar => exemplar.Value).Order());
        var expectedExemplarTime = timeProvider.ToLocalDateTimeOffset(new DateTimeOffset(time.AddMilliseconds(500)));
        Assert.All(dialog.Exemplars, exemplar => Assert.Equal(expectedExemplarTime, exemplar.Start));
 
        void AddPoints(DateTime start, bool compatibleBounds, double exemplarValue)
        {
            var first = HistogramTestHelpers.CreatePoint(start, start.AddSeconds(1), [3, 0], [10]);
            var second = HistogramTestHelpers.CreatePoint(start, start.AddSeconds(1), [5, 0], compatibleBounds ? [10] : [20]);
            first.Exemplars.Add(CreateExemplar(start.AddMilliseconds(500), exemplarValue));
            if (!compatibleBounds)
            {
                second.Exemplars.Add(CreateExemplar(start.AddMilliseconds(500), exemplarValue + 20));
            }
            foreach (var (point, instance) in new[] { (first, "first"), (second, "second") })
            {
                point.Attributes.Add(new OpenTelemetry.Proto.Common.V1.KeyValue
                {
                    Key = "instance",
                    Value = new OpenTelemetry.Proto.Common.V1.AnyValue { StringValue = instance }
                });
                points.Add(point);
            }
        }
    }
 
    [Theory]
    [InlineData(false, true)]
    [InlineData(true, false)]
    public async Task SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemetryRepositoryIsReadOnly, bool dashboardClientIsReadOnly)
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
        Services.AddSingleton<IDashboardClient>(new TestDashboardClient(isReadOnly: dashboardClientIsReadOnly));
        await FluentUISetupHelpers.ConfigureTelemetryRepository(this, telemetryRepositoryIsReadOnly, _ => Task.CompletedTask);
 
        var cut = Render<Metrics>(builder => builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)));
 
        Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent<PauseIncomingDataSwitch>().Instance.Disabled);
        Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent<ClearSignalsButton>().FindComponent<AspireMenuButton>().Instance.Disabled);
    }
 
    [Fact]
    public async Task ChangeResource_MeterAndInstrumentOnNewResource_InstrumentSet()
    {
        await ChangeResourceAndAssertInstrument(
            app1InstrumentName: "test1",
            app2InstrumentName: "test1",
            expectedMeterNameAfterChange: "test-meter",
            expectedInstrumentNameAfterChange: "test1");
    }
 
    [Fact]
    public async Task ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared()
    {
        await ChangeResourceAndAssertInstrument(
            app1InstrumentName: "test1",
            app2InstrumentName: "test2",
            expectedMeterNameAfterChange: null,
            expectedInstrumentNameAfterChange: null);
    }
 
    [Fact]
    public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChanged()
    {
        JSInterop.Mode = JSRuntimeMode.Loose;
        MetricsSetupHelpers.SetupMetricsPage(this);
        var chartTimeProvider = new TestTimeProvider { UtcNow = DateTimeOffset.UtcNow };
        Services.AddSingleton<BrowserTimeProvider>(chartTimeProvider);
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        var metricTime = chartTimeProvider.GetUtcNow().UtcDateTime.AddMinutes(-1);
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(
                                metricName: "test-instrument",
                                startTime: metricTime,
                                attributes: [new KeyValuePair<string, string>("http.method", "GET")])
                        }
                    }
                }
            }
        });
 
        var resource = telemetryRepository.GetResources().Single();
        var cut = Render<ChartContainer>(builder =>
        {
            builder.Add(component => component.ResourceKey, resource.ResourceKey);
            builder.Add(component => component.MeterName, "test-meter");
            builder.Add(component => component.InstrumentName, "test-instrument");
            builder.Add(component => component.Duration, TimeSpan.FromMinutes(5));
            builder.Add(component => component.ActiveView, MetricViewKind.Graph);
            builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask);
            builder.Add(component => component.Resources, [resource]);
        });
        cut.WaitForAssertion(() =>
        {
            var dimensionFilter = Assert.Single(cut.Instance.DimensionFilters);
            Assert.Equal("GET", Assert.Single(dimensionFilter.SelectedValues).Value);
            Assert.Single(cut.FindComponents<PlotlyChart>());
            Assert.Empty(cut.FindComponents<MetricTable>());
        });
        var activities = new ConcurrentQueue<Activity>();
        using var listener = ActivityListenerHelper.Create(telemetryRepository.SqlActivitySource, onActivityStopped: activities.Enqueue);
 
        cut.Render(builder => builder.Add(component => component.ActiveView, MetricViewKind.Table));
 
        Assert.Empty(cut.FindComponents<PlotlyChart>());
        Assert.Single(cut.FindComponents<MetricTable>());
        if (cut.FindAll(".empty-content-cell") is [var emptyContentCell])
        {
            Assert.Equal("Loading...", emptyContentCell.TextContent.Trim());
        }
        cut.WaitForAssertion(() => Assert.Contains("1Value increased", cut.FindAll("[role='gridcell']").Select(cell => cell.TextContent.Trim())));
        Assert.Empty(activities);
 
        cut.Render(builder => builder.Add(component => component.ActiveView, MetricViewKind.Graph));
 
        Assert.Single(cut.FindComponents<PlotlyChart>());
        Assert.Empty(cut.FindComponents<MetricTable>());
        Assert.Empty(activities);
 
        var chartFilters = cut.FindComponent<ChartFilters>().Instance;
        var filterPopover = cut.FindComponent<ChartFilterPopover>().Instance;
        var filterButtonId = cut.Find(".chart-filter-button").Id;
        cut.Find(".chart-filter-button").Click();
        cut.WaitForAssertion(() => Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened")));
 
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(
                                metricName: "test-instrument",
                                startTime: metricTime,
                                attributes: [new KeyValuePair<string, string>("http.method", "POST")])
                        }
                    }
                }
            }
        });
        activities.Clear();
 
        cut.Render(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5)));
 
        Assert.Empty(activities);
 
        cut.Render(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(1)));
 
        cut.WaitForAssertion(() =>
        {
            var updatedFilter = Assert.Single(cut.Instance.DimensionFilters);
            Assert.Same(chartFilters, cut.FindComponent<ChartFilters>().Instance);
            Assert.Same(filterPopover, cut.FindComponent<ChartFilterPopover>().Instance);
            Assert.Equal(filterButtonId, cut.Find(".chart-filter-button").Id);
            Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened"));
            Assert.Collection(
                updatedFilter.SelectedValues.Select(value => value.Value).Order(),
                value => Assert.Equal("GET", value),
                value => Assert.Equal("POST", value));
 
            var chart = cut.FindComponent<PlotlyChart>();
            var dimensions = chart.Instance.InstrumentViewModel.MatchedDimensions!;
            Assert.Equal(2, dimensions.Count);
            Assert.All(dimensions, dimension => Assert.Equal(1, Assert.IsType<MetricValue<long>>(Assert.Single(dimension.Values)).Value));
            Assert.Contains(activities, activity => ((string?)activity.GetTagItem("db.query.text"))?.Contains("telemetry_metric_points", StringComparison.Ordinal) == true);
        });
    }
 
    [Fact]
    public async Task ChartContainer_InstrumentChanged_TableUpdates()
    {
        JSInterop.Mode = JSRuntimeMode.Loose;
        MetricsSetupHelpers.SetupMetricsPage(this);
        await FluentUISetupHelpers.ConfigureTelemetryRepository(this, readOnly: true, telemetryRepository => telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "instrument-1", startTime: s_testTime.AddMinutes(1), value: 0),
                            CreateSumMetric(metricName: "instrument-1", startTime: s_testTime.AddMinutes(2), value: 1),
                            CreateSumMetric(metricName: "instrument-2", startTime: s_testTime.AddMinutes(1), value: 0),
                            CreateSumMetric(metricName: "instrument-2", startTime: s_testTime.AddMinutes(2), value: 2)
                        }
                    }
                }
            }
        }));
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        var resource = telemetryRepository.GetResources().Single();
        var cut = Render<ChartContainer>(builder =>
        {
            builder.Add(component => component.ResourceKey, resource.ResourceKey);
            builder.Add(component => component.MeterName, "test-meter");
            builder.Add(component => component.InstrumentName, "instrument-1");
            builder.Add(component => component.Duration, TimeSpan.FromMinutes(5));
            builder.Add(component => component.ActiveView, MetricViewKind.Table);
            builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask);
            builder.Add(component => component.Resources, [resource]);
        });
 
        cut.WaitForState(() => cut.FindComponents<MetricTable>().Count == 1);
        var table = cut.FindComponent<MetricTable>();
        table.Render();
        table.WaitForAssertion(() => Assert.Contains("1Value increased", table.FindAll("[role='gridcell']").Select(cell => cell.TextContent.Trim())));
 
        cut.Render(builder => builder.Add(component => component.InstrumentName, "instrument-2"));
 
        cut.WaitForAssertion(() =>
        {
            var updatedTable = cut.FindComponent<MetricTable>();
            Assert.Equal("instrument-2", updatedTable.Instance.InstrumentViewModel.Instrument?.Name);
            Assert.NotEmpty(updatedTable.Instance.InstrumentViewModel.MatchedDimensions!);
        });
        cut.WaitForAssertion(() => Assert.Contains("2Value increased", cut.FindAll("[role='gridcell']").Select(cell => cell.TextContent.Trim())));
    }
 
    [Fact]
    public async Task ChartContainer_TickUpdate_PreservesComponentCultureAndStopsOnDispose()
    {
        JSInterop.Mode = JSRuntimeMode.Loose;
        MetricsSetupHelpers.SetupMetricsPage(this);
        var timeProvider = new SignalingFakeTimeProvider();
        Services.AddSingleton<TimeProvider>(timeProvider);
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        var metric = CreateSumMetric(metricName: "test-instrument", startTime: DateTime.UtcNow);
        // End in the current SQLite query window while spanning the fixed browser time used by the rendered chart.
        metric.Sum.DataPoints.Single().StartTimeUnixNano = 0;
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            metric
                        }
                    }
                }
            }
        });
        var resource = telemetryRepository.GetResources().Single();
 
        var activitySource = Services.GetRequiredService<DashboardActivitySource>();
        var tickActivityCount = 0;
        var activityStarted = new TaskCompletionSource<Activity>(TaskCreationOptions.RunContinuationsAsynchronously);
        var activityStopped = new TaskCompletionSource<Activity>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var listener = ActivityListenerHelper.Create(
            activitySource.ActivitySource,
            onActivityStarted: activity =>
            {
                if (activity.OperationName == "Update metric chart data from tick")
                {
                    Interlocked.Increment(ref tickActivityCount);
                    activityStarted.TrySetResult(activity);
                }
            },
            onActivityStopped: activity =>
            {
                if (activity.OperationName == "Update metric chart data from tick")
                {
                    activityStopped.TrySetResult(activity);
                }
            });
 
        var componentCulture = (CultureInfo)CultureInfo.CurrentCulture.Clone();
        componentCulture.DateTimeFormat.AMDesignator = "am";
        var originalCulture = CultureInfo.CurrentCulture;
 
        try
        {
            CultureInfo.CurrentCulture = componentCulture;
            var cut = Render<ChartContainer>(builder =>
            {
                builder.Add(component => component.ResourceKey, resource.ResourceKey);
                builder.Add(component => component.MeterName, "test-meter");
                builder.Add(component => component.InstrumentName, "test-instrument");
                builder.Add(component => component.Duration, TimeSpan.FromSeconds(1));
                builder.Add(component => component.ActiveView, MetricViewKind.Table);
                builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask);
                builder.Add(component => component.Resources, [resource]);
            });
 
            cut.WaitForAssertion(() => Assert.Single(cut.FindComponents<MetricTable>()));
            await timeProvider.TimerCreated.Task.DefaultTimeout();
            timeProvider.Advance(TimeSpan.FromSeconds(1));
 
            var started = await activityStarted.Task.WaitAsync(DefaultWaitTimeout);
            var activity = await activityStopped.Task.WaitAsync(DefaultWaitTimeout);
 
            Assert.Same(started, activity);
            Assert.Equal(ActivityKind.Internal, activity.Kind);
            Assert.Null(activity.ParentId);
            cut.WaitForAssertion(() =>
            {
                Assert.Equal("12:59:57 am", cut.Find("[role='gridcell']").TextContent.Trim(), ignoreWhiteSpaceDifferences: true);
            });
 
            await cut.Instance.DisposeAsync().DefaultTimeout();
            var activityCountAfterDispose = Volatile.Read(ref tickActivityCount);
            timeProvider.Advance(TimeSpan.FromSeconds(10));
 
            Assert.Equal(activityCountAfterDispose, Volatile.Read(ref tickActivityCount));
        }
        finally
        {
            CultureInfo.CurrentCulture = originalCulture;
        }
    }
 
    [Fact]
    public async Task InitialLoad_SingleResource_RedirectToResource()
    {
        // Arrange
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
 
        var targetLocationTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var changeHandler = navigationManager.RegisterLocationChangingHandler(c =>
        {
            targetLocationTcs.SetResult(c.TargetLocation);
            return ValueTask.CompletedTask;
        });
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        // Act
        var cut = Render<Metrics>(builder =>
        {
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        });
 
        // Assert
        Assert.NotNull(targetLocationTcs);
        Assert.Equal("/metrics/resource/TestApp?duration=5", await targetLocationTcs.Task.DefaultTimeout());
    }
 
    [Fact]
    public async Task InitialLoad_HasSessionState_RedirectUsingState()
    {
        // Arrange
        var testSessionStorage = new TestSessionStorage
        {
            OnGetAsync = key =>
            {
                if (key == BrowserStorageKeys.MetricsPageState)
                {
                    var state = new MetricsPageState
                    {
                        ResourceName = "TestApp2",
                        MeterName = "test-meter",
                        InstrumentName = "test-instrument",
                        DurationMinutes = 720,
                        ViewKind = MetricViewKind.Table.ToString()
                    };
                    return (true, state);
                }
                else
                {
                    throw new InvalidOperationException("Unexpected key: " + key);
                }
            }
        };
 
        MetricsSetupHelpers.SetupMetricsPage(this, sessionStorage: testSessionStorage);
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.MetricsUrl());
 
        Uri? loadRedirect = null;
        navigationManager.LocationChanged += (s, a) =>
        {
            loadRedirect = new Uri(a.Location);
        };
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            },
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp2"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        // Act
        var cut = Render<Metrics>(builder =>
        {
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        });
 
        // Assert
        Assert.NotNull(loadRedirect);
        Assert.Equal("/metrics/resource/TestApp2", loadRedirect.AbsolutePath);
 
        var query = HttpUtility.ParseQueryString(loadRedirect.Query);
        Assert.Equal("test-meter", query["meter"]);
        Assert.Equal("test-instrument", query["instrument"]);
        Assert.Equal("720", query["duration"]);
        Assert.Equal(MetricViewKind.Table.ToString(), query["view"]);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("test-instrument")]
    public async Task InitialLoad_RouteSelection_SelectsTreeItem(string? instrumentName)
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.MetricsUrl(
            resource: "TestApp",
            meter: "test-meter",
            instrument: instrumentName,
            duration: 5,
            view: MetricViewKind.Table.ToString()));
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        var cut = Render<Metrics>(builder =>
        {
            builder.Add(component => component.ResourceName, "TestApp");
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        });
 
        cut.WaitForAssertion(() =>
        {
            var tree = cut.FindComponent<FluentTreeView>();
            var treeItems = cut.FindComponents<FluentTreeItem>();
            var selectedItem = Assert.Single(treeItems, item => item.Instance.Id == tree.Instance.SelectedId);
            var meterItems = treeItems.Where(item => item.Instance.Data is string).ToList();
            var expandedMeterItems = meterItems.Where(item => item.Find("fluent-tree-item").HasAttribute("expanded")).ToList();
 
            Assert.True(selectedItem.Find("fluent-tree-item").HasAttribute("selected"));
            Assert.Equal("test-meter", Assert.Single(expandedMeterItems).Instance.Data);
            if (instrumentName is null)
            {
                Assert.Equal("test-meter", selectedItem.Instance.Data);
            }
            else
            {
                Assert.Equal(instrumentName, Assert.IsType<OtlpInstrumentSummary>(selectedItem.Instance.Data).Name);
            }
        });
    }
 
    [Fact]
    public async Task PauseIncomingData_DisplaysPauseWarningInPageFooter()
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument"));
 
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var cut = Render<Metrics>(builder =>
        {
            builder.Add(m => m.ResourceName, "TestApp");
            builder.AddCascadingValue(viewport);
        });
 
        cut.WaitForState(() => cut.Instance.PageViewModel.SelectedInstrument is not null);
 
        var pauseManager = Services.GetRequiredService<PauseManager>();
        var timeProvider = Services.GetRequiredService<BrowserTimeProvider>();
        var loc = Services.GetRequiredService<IStringLocalizer<Resources.Metrics>>();
 
        cut.FindComponent<PauseIncomingDataSwitch>().WaitForElement("fluent-button").Click();
 
        cut.WaitForAssertion(() =>
        {
            Assert.True(pauseManager.AreMetricsPaused(out var pausedAt));
            var pauseWarning = cut.FindComponent<PauseWarning>();
            Assert.Equal(
                string.Format(
                    CultureInfo.CurrentCulture,
                    loc[nameof(Resources.Metrics.PauseInProgressText)],
                    FormatHelpers.FormatTimeWithOptionalDate(timeProvider, pausedAt.Value)),
                pauseWarning.Instance.PauseText);
            Assert.Single(cut.Find("footer").QuerySelectorAll(".block-warning"));
        });
 
        cut.FindComponent<PauseIncomingDataSwitch>().WaitForElement("fluent-button").Click();
 
        cut.WaitForAssertion(() =>
        {
            Assert.False(pauseManager.AreMetricsPaused(out _));
            Assert.False(cut.HasComponent<PauseWarning>());
        });
    }
 
    [Fact]
    public async Task MetricsTree_MetricsAdded_TreeUpdated()
    {
        // Arrange
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter1"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument1-1", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        // Act 1
        // Initial page load
        var cut = Render<Metrics>(builder =>
        {
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
            builder.Add(m => m.ResourceName, "TestApp");
        });
 
        // Assert 2
        cut.WaitForState(() => cut.Instance.PageViewModel.Instruments?.Count == 1);
 
        cut.WaitForAssertion(() =>
        {
            // Assert in wait to make sure rendering has caught up to data update.
            var tree1 = cut.FindComponent<FluentTreeView>();
            var items1 = tree1.FindComponents<FluentTreeItem>();
 
            foreach (var instrument in cut.Instance.PageViewModel.Instruments!)
            {
                Assert.Single(items1, i => i.Instance.Data as OtlpInstrumentSummary == instrument);
                Assert.Single(items1, i => i.Instance.Data as string == instrument.Parent.Name);
            }
        });
 
        // Act 2
        // New instruments added
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter1"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument1-2", startTime: s_testTime.AddMinutes(1))
                        }
                    },
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter2"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument2-1", startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        // Assert 2
        cut.WaitForState(() => cut.Instance.PageViewModel.Instruments?.Count == 3);
 
        cut.WaitForAssertion(() =>
        {
            // Assert in wait to make sure rendering has caught up to data update.
            var tree2 = cut.FindComponent<FluentTreeView>();
            var items2 = tree2.FindComponents<FluentTreeItem>();
 
            foreach (var instrument in cut.Instance.PageViewModel.Instruments!)
            {
                Assert.Single(items2, i => i.Instance.Data as OtlpInstrumentSummary == instrument);
                Assert.Single(items2, i => i.Instance.Data as string == instrument.Parent.Name);
            }
        });
    }
 
    [Fact]
    public async Task ReadOnly_ChartEndsAtLatestMetricTime()
    {
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        await FluentUISetupHelpers.ConfigureTelemetryRepository(this, readOnly: true, telemetryRepository => telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1)),
                            CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(2))
                        }
                    }
                }
            }
        }));
        Services.GetRequiredService<PauseManager>().SetMetricsPaused(true);
        Services.GetRequiredService<NavigationManager>().NavigateTo(
            DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument", duration: 5, view: MetricViewKind.Graph.ToString()));
 
        var cut = Render<Metrics>(builder =>
        {
            builder.Add(m => m.ResourceName, "TestApp");
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        });
 
        cut.WaitForAssertion(() => Assert.Single(cut.FindComponents<PlotlyChart>()));
        var chart = cut.FindComponent<PlotlyChart>().Instance;
        var expectedEndTime = new DateTimeOffset(s_testTime.AddMinutes(2));
        Assert.Equal(expectedEndTime, chart.DataEndTime);
        cut.WaitForAssertion(() =>
        {
            var initializeChart = Assert.Single(JSInterop.Invocations, invocation => invocation.Identifier == "initializeChart");
            Assert.Equal(chart.TimeProvider.ToLocal(expectedEndTime), Assert.IsType<DateTime>(initializeChart.Arguments[3]));
        });
    }
 
    private async Task ChangeResourceAndAssertInstrument(string app1InstrumentName, string app2InstrumentName, string? expectedMeterNameAfterChange, string? expectedInstrumentNameAfterChange)
    {
        // Arrange
        MetricsSetupHelpers.SetupMetricsPage(this);
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName, duration: 720, view: MetricViewKind.Table.ToString()));
 
        var telemetryRepository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField<ResourceMetrics>
        {
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: app1InstrumentName, startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            },
            new ResourceMetrics
            {
                Resource = CreateResource(name: "TestApp2"),
                ScopeMetrics =
                {
                    new ScopeMetrics
                    {
                        Scope = CreateScope(name: "test-meter"),
                        Metrics =
                        {
                            CreateSumMetric(metricName: app2InstrumentName, startTime: s_testTime.AddMinutes(1))
                        }
                    }
                }
            }
        });
 
        // Act 1
        var cut = Render<Metrics>(builder =>
        {
            builder.Add(m => m.ResourceName, "TestApp");
            builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false));
        });
 
        navigationManager.LocationChanged += (sender, e) =>
        {
            var expectedUrl = DashboardUrls.MetricsUrl(resource: "TestApp2", meter: expectedMeterNameAfterChange, instrument: expectedInstrumentNameAfterChange, duration: 720, view: "Table");
            Assert.EndsWith(expectedUrl, e.Location);
 
            cut.Render(builder =>
            {
                builder.Add(m => m.ResourceName, "TestApp2");
            });
        };
 
        var viewModel = cut.Instance.PageViewModel;
 
        // Assert 1
        Assert.Equal("test-meter", viewModel.SelectedMeter);
        Assert.Equal(app1InstrumentName, viewModel.SelectedInstrument!.Name);
 
        // Act 2
        var resourceSelect = cut.FindComponent<ResourceSelect>();
        var selectedResource = resourceSelect.Instance.Resources!.Single(resource => resource.Name == "TestApp2");
        await resourceSelect.InvokeAsync(() => resourceSelect.Instance.SelectedResourceChanged.InvokeAsync(selectedResource));
 
        cut.WaitForAssertion(() => Assert.Equal("TestApp2", viewModel.SelectedResource.Name));
 
        Assert.Equal(expectedInstrumentNameAfterChange, viewModel.SelectedInstrument?.Name);
        Assert.Equal(expectedMeterNameAfterChange, viewModel.SelectedMeter);
 
        Assert.Equal(MetricViewKind.Table, viewModel.SelectedViewKind);
        Assert.Equal(TimeSpan.FromMinutes(720), viewModel.SelectedDuration.Id);
    }
 
    private sealed class SignalingFakeTimeProvider : FakeTimeProvider
    {
        public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
        {
            var timer = base.CreateTimer(callback, state, dueTime, period);
            TimerCreated.TrySetResult();
            return timer;
        }
    }
}