File: Layout\ResourcesLayoutActivityTests.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.Threading.Channels;
using Aspire.Dashboard.Components.Layout;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Google.Protobuf.Collections;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
using OpenTelemetry.Proto.Logs.V1;
using OpenTelemetry.Proto.Trace.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
 
namespace Aspire.Dashboard.Components.Tests.Layout;
 
[UseCulture("en-US")]
public class ResourcesLayoutActivityTests : DashboardTestContext
{
    [Fact]
    public async Task Canvas_MobileViewportCancelsPendingQueryAndDoesNotResumeOnDesktop()
    {
        var desktop = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var mobile = new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var started = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
        var calls = 0;
        var timers = new FakeTimeProvider();
        ResourceSetupHelpers.SetupResourcesLayout(this, desktop,
            new TestDashboardClient(isEnabled: true, initialResources: [ModelTestHelpers.CreateResource("api-TestId")],
                resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services =>
            {
                services.AddSingleton<BrowserTimeProvider>(new TestTimeProvider { Timers = timers });
                services.AddSingleton<ITelemetryRepository>(provider => new TestTelemetryRepository(provider.GetRequiredService<SqliteTelemetryRepository>())
                {
                    GetRequestSpansAsyncHandler = async (_, token) =>
                    {
                        Interlocked.Increment(ref calls);
                        started.TrySetResult(token);
                        await Task.Delay(Timeout.InfiniteTimeSpan, token);
                        return new GetRequestSpansResponse { Items = [], TotalItemCount = 0 };
                    }
                });
            });
        var now = Services.GetRequiredService<BrowserTimeProvider>().GetUtcNow().UtcDateTime.AddSeconds(-1);
        await Services.GetRequiredService<SqliteTelemetryRepository>().AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
        {
            new ResourceSpans
            {
                Resource = CreateResource("api"),
                ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans =
                {
                    CreateSpan("1", "1", now, now.AddMilliseconds(10), kind: Span.Types.SpanKind.Server)
                } } }
            }
        });
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources");
        var cut = Render<CascadingValue<ViewportInformation>>(builder =>
            builder.Add(p => p.Value, desktop).AddChildContent<ResourcesLayout>());
        cut.WaitForAssertion(() => Assert.True(cut.FindComponent<ResourcesLayout>().Instance.IsLoaded));
        ResourceSetupHelpers.ToggleResourceList(cut);
        var token = await started.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
        cut.Render(builder => builder.Add(p => p.Value, mobile).AddChildContent<ResourcesLayout>());
        Assert.True(token.IsCancellationRequested);
        Assert.Empty(cut.FindAll(".resource-row-insight,.pane-full-canvas"));
        cut.Render(builder => builder.Add(p => p.Value, desktop).AddChildContent<ResourcesLayout>());
        timers.Advance(TimeSpan.FromSeconds(30));
        Assert.Empty(cut.FindAll(".resource-row-insight,.pane-full-canvas"));
        Assert.Equal(1, Volatile.Read(ref calls));
    }
 
    [Fact]
    public async Task Canvas_UnchangedLogNotificationsDoNotRenderResourceList()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        Func<Task>? notifyLogs = null;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport,
            new TestDashboardClient(isEnabled: true,
                initialResources: Enumerable.Range(0, 28).Select(i => ModelTestHelpers.CreateResource($"service-{i}-TestId")).ToList(),
                resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services => services.AddSingleton<ITelemetryRepository>(provider =>
                new TestTelemetryRepository(provider.GetRequiredService<SqliteTelemetryRepository>())
                {
                    OnNewLogsRegistered = callback => notifyLogs = callback,
                    // Log ingestion also publishes resource notifications. Isolate the log callback's render guard.
                    OnNewResourcesSubscribed = subscription => subscription.Dispose()
                }));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await AddLogAsync(SeverityNumber.Info);
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources?pane=tags");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        ResourceSetupHelpers.ToggleResourceList(cut);
        Assert.NotNull(notifyLogs);
        var before = cut.RenderCount;
        for (var i = 0; i < 28; i++)
        {
            await notifyLogs();
        }
        Assert.Equal(before, cut.RenderCount);
        await AddLogAsync(SeverityNumber.Error);
        cut.WaitForAssertion(() =>
        {
            // The badge links to the resource's error logs, outside the row link so it doesn't select the row.
            var errorLink = cut.Find("[data-resource-name='service-0-TestId'] > a.resource-row-error-link");
            Assert.Equal("1", errorLink.TextContent);
            Assert.Equal("/structuredlogs/resource/service-0-TestId?logLevel=error", errorLink.GetAttribute("href"));
        });
        var afterError = cut.RenderCount;
        await notifyLogs();
        Assert.Equal(afterError, cut.RenderCount);
        repository.MarkViewedErrorLogs(new ResourceKey("service-0", "TestId"));
        await notifyLogs();
        cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".resource-row .error-badge")));
 
        Task AddLogAsync(SeverityNumber severity) => repository.AddLogsAsync(new AddContext(), new RepeatedField<ResourceLogs>
        {
            new ResourceLogs
            {
                Resource = CreateResource("service-0"),
                ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord(severity: severity) } } }
            }
        });
    }
 
    [Fact]
    public async Task Canvas_VisibleCadenceExpiresAndResumesWithoutPerMembershipPolling()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var timers = new FakeTimeProvider();
        var clock = new TestTimeProvider { Timers = timers };
        var names = Enumerable.Range(0, 28).Select(i => $"service-{i}").ToArray();
        var calls = 0;
        var active = 0;
        var maximumActive = 0;
        var doubleFirst = false;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport,
            new TestDashboardClient(isEnabled: true,
                initialResources: names.Select(name => ModelTestHelpers.CreateResource($"{name}-TestId")).ToList(),
                resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services =>
            {
                services.AddSingleton<BrowserTimeProvider>(clock);
                services.AddSingleton<ITelemetryRepository>(provider =>
                {
                    var inner = provider.GetRequiredService<SqliteTelemetryRepository>();
                    return new TestTelemetryRepository(inner)
                    {
                        GetRequestSpansAsyncHandler = async (request, token) =>
                        {
                            Interlocked.Increment(ref calls);
                            maximumActive = Math.Max(maximumActive, Interlocked.Increment(ref active));
                            try
                            {
                                Assert.Equal(28, request.ResourceKeys.Count);
                                Assert.Equal(28, request.ResourceKeys.Distinct().Count());
                                var result = await inner.GetRequestSpansAsync(request, token);
                                return doubleFirst
                                    ? new GetRequestSpansResponse
                                    {
                                        Items = [.. result.Items, .. result.Items.Where(span => span.Resource.ResourceKey == new ResourceKey(names[0], "TestId"))],
                                        TotalItemCount = result.TotalItemCount + 20
                                    }
                                    : result;
                            }
                            finally
                            {
                                Interlocked.Decrement(ref active);
                            }
                        }
                    };
                });
            });
        async Task SeedAsync(string prefix, IReadOnlyList<string> seedNames)
        {
            var now = clock.GetUtcNow().UtcDateTime.AddSeconds(-1);
            await Services.GetRequiredService<SqliteTelemetryRepository>().AddTracesAsync(new AddContext(),
                new RepeatedField<ResourceSpans>
                {
                    seedNames.Select(name => new ResourceSpans
                    {
                        Resource = CreateResource(name),
                        ScopeSpans =
                        {
                            new ScopeSpans
                            {
                                Scope = CreateScope(),
                                Spans =
                                {
                                    Enumerable.Range(1, 20).Select(i => CreateSpan($"{prefix}-{name}-{i}", $"{prefix}-{name}-{i}",
                                        now, now.AddMilliseconds(i), kind: Span.Types.SpanKind.Server))
                                }
                            }
                        }
                    })
                });
        }
        await SeedAsync("initial", names);
        var tags = Services.GetRequiredService<ResourceTagStore>();
        foreach (var name in names)
        {
            tags.AddTag($"{name}-TestId", "Backend");
            tags.AddTag($"{name}-TestId", "Shared");
        }
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources?pane=tags");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        ResourceSetupHelpers.ToggleResourceList(cut);
        WaitForInsightCount(2);
        // Child renders finish before the shared refresh's finally releases its coalescing gate.
        await cut.Instance.PaneActivityRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
        Assert.Equal(1, Volatile.Read(ref calls));
        var changedKey = new ResourceKey(names[0], "TestId");
        var changed = cut.FindComponents<ResourcePaneInsights>()
            .Where(component => component.Instance.ResourceKey == changedKey).ToArray();
        var unchanged = cut.FindComponents<ResourcePaneInsights>()
            .Where(component => component.Instance.ResourceKey != changedKey)
            .Select(component => (Component: component, Renders: component.RenderCount,
                Snapshot: cut.Instance.GetPaneActivitySnapshot(component.Instance.ResourceKey))).ToArray();
        doubleFirst = true;
        // Tick the polling timer without changing the displayed clock to isolate
        // a changed resource from the separate renewal of all active URL windows.
        timers.Advance(ResourcesLayout.s_paneActivityRefreshInterval);
        foreach (var component in changed)
        {
            component.WaitForAssertion(() => Assert.Equal("40 incoming", component.Find(".resource-row-insight-requests span").TextContent));
        }
        Assert.Equal(["40 incoming", "40 incoming"],
            cut.FindAll($"[data-resource-name='{names[0]}-TestId'] .resource-row-insight-requests span").Select(span => span.TextContent));
        await cut.Instance.PaneActivityRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
        Assert.Equal(2, Volatile.Read(ref calls));
        Assert.All(unchanged, item =>
        {
            Assert.Equal(item.Renders, item.Component.RenderCount);
            Assert.Same(item.Snapshot, cut.Instance.GetPaneActivitySnapshot(item.Component.Instance.ResourceKey));
        });
        var href = changed[0].Find(".resource-row-insight-requests").GetAttribute("href");
        clock.UtcNow += TimeSpan.FromSeconds(14);
        timers.Advance(TimeSpan.FromSeconds(14));
        await cut.InvokeAsync(() => { });
        Assert.Equal(2, Volatile.Read(ref calls));
        clock.UtcNow += TimeSpan.FromSeconds(1);
        timers.Advance(TimeSpan.FromSeconds(1));
        changed[0].WaitForAssertion(() =>
        {
            Assert.Equal(3, Volatile.Read(ref calls));
            Assert.NotEqual(href, changed[0].Find(".resource-row-insight-requests").GetAttribute("href"));
        });
        await cut.Instance.PaneActivityRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
        clock.UtcNow += TimeSpan.FromMinutes(6);
        timers.Advance(ResourcesLayout.s_paneActivityRefreshInterval);
        WaitForInsightCount(0);
        await cut.Instance.PaneActivityRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
        var beforeHidden = Volatile.Read(ref calls);
        await cut.InvokeAsync(() => cut.Instance.SetPaneActivityVisible(false));
        doubleFirst = false;
        await SeedAsync("fresh", names);
        clock.UtcNow += TimeSpan.FromSeconds(30);
        timers.Advance(TimeSpan.FromSeconds(30));
        await cut.InvokeAsync(() => { });
        Assert.Equal(beforeHidden, Volatile.Read(ref calls));
        await cut.InvokeAsync(() => cut.Instance.SetPaneActivityVisible(true));
        WaitForInsightCount(2);
        await cut.Instance.PaneActivityRefreshTask.WaitAsync(TimeSpan.FromSeconds(10));
        Assert.Equal(beforeHidden + 1, Volatile.Read(ref calls));
        var url = new Uri(new Uri("http://localhost"), cut.Find(".resource-row-insight-requests").GetAttribute("href"));
        var filters = TelemetryFilterFormatter.DeserializeFiltersFromString(QueryHelpers.ParseQuery(url.Query)["filters"].ToString());
        Assert.Equal(clock.GetUtcNow().UtcDateTime.ToString("O"), filters[1].Value);
        await cut.Instance.DisposeAsync();
        timers.Advance(TimeSpan.FromMinutes(1));
        Assert.Equal(beforeHidden + 1, Volatile.Read(ref calls));
        Assert.Equal(1, maximumActive);
 
        void WaitForInsightCount(int count)
        {
            // Activity updates render the insight children, not their layout. Await each child's own render signal.
            var components = cut.FindComponents<ResourcePaneInsights>();
            Assert.Equal(56, components.Count);
            foreach (var component in components)
            {
                component.WaitForAssertion(() => Assert.Equal(count, component.FindAll(".resource-row-insight").Count));
            }
            Assert.Equal(count * components.Count, cut.FindAll(".resource-row-insight").Count);
        }
    }
 
    [Theory]
    [InlineData(false, 2)]
    [InlineData(true, 20)]
    public async Task Canvas_SharedBoundedQueryFeedsDuplicateTagRowsAndExactScopedLinks(bool sampled, int sampleCount)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var channel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var calls = 0;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                ModelTestHelpers.CreateResource("api-TestId"),
                ModelTestHelpers.CreateResource("quiet"),
                ModelTestHelpers.CreateResource("key", resourceType: KnownResourceTypes.Parameter)
            ], resourceChannelProvider: () => channel),
            configureServices: services => services.AddSingleton<ITelemetryRepository>(CreateRepository));
 
        ITelemetryRepository CreateRepository(IServiceProvider services)
        {
            var inner = services.GetRequiredService<SqliteTelemetryRepository>();
            return new TestTelemetryRepository(inner)
            {
                GetRequestSpansAsyncHandler = async (request, token) =>
                {
                    Interlocked.Increment(ref calls);
                    Assert.Equal(TimeSpan.FromMinutes(5), request.EndTime - request.StartTime);
                    Assert.Equal(5000, request.MaxCount);
                    Assert.Equal([new ResourceKey("api", "TestId")], request.ResourceKeys);
                    var result = await inner.GetRequestSpansAsync(request, token);
                    return sampled ? new GetRequestSpansResponse { Items = result.Items, TotalItemCount = 5001 } : result;
                }
            };
        }
        var now = Services.GetRequiredService<BrowserTimeProvider>().GetUtcNow().UtcDateTime.AddSeconds(-5);
        await Services.GetRequiredService<SqliteTelemetryRepository>().AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
        {
            new ResourceSpans
            {
                Resource = CreateResource("api"),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = CreateScope(),
                        Spans =
                        {
                            Enumerable.Range(1, sampleCount).Select(i =>
                                CreateSpan(i.ToString(), i.ToString(), now, now.AddMilliseconds(i),
                                    kind: i == 1 ? Span.Types.SpanKind.Consumer : Span.Types.SpanKind.Server,
                                    status: new Status { Code = i == 1 ? Status.Types.StatusCode.Error : Status.Types.StatusCode.Ok })),
                            CreateSpan("100", "100", now.AddMinutes(-6), now.AddMinutes(-6).AddMilliseconds(100), kind: Span.Types.SpanKind.Server),
                            CreateSpan("101", "101", now, now.AddMilliseconds(50), kind: Span.Types.SpanKind.Client)
                        }
                    }
                }
            }
        });
        var tags = Services.GetRequiredService<ResourceTagStore>();
        tags.AddTag("api-TestId", "Backend");
        tags.AddTag("api-TestId", "Shared");
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/resources?pane=tags");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        Assert.Equal(0, Volatile.Read(ref calls));
        ResourceSetupHelpers.ToggleResourceList(cut);
 
        cut.WaitForAssertion(() => Assert.Equal(sampleCount >= 20 ? 6 : 4, cut.FindAll(".resource-row-insight").Count));
        var expected = sampled ? new[] { "20+ incoming", "p95 19ms", "" } : ["2 incoming", ""];
        Assert.Equal(expected.Concat(expected), cut.FindAll(".resource-row-insight").Select(link => link.TextContent.Trim()));
        Assert.All(cut.FindAll(".resource-row-insight-latency"),
            link => Assert.StartsWith(sampled ? "Sample p95 19ms." : "p95 19ms.", link.GetAttribute("aria-label")));
        Assert.All(cut.FindAll(".resource-row-insight-failed"), link =>
        {
            Assert.StartsWith(sampled ? "1+ failed." : "1 failed.", link.GetAttribute("aria-label"));
            Assert.Single(link.QuerySelectorAll("svg"));
        });
        Assert.All(cut.FindAll(".resource-row-insight"), link =>
        {
            var url = new Uri(new Uri("http://localhost"), link.GetAttribute("href"));
            Assert.Equal("/traces/resource/api-TestId", url.AbsolutePath);
            var filters = TelemetryFilterFormatter.DeserializeFiltersFromString(QueryHelpers.ParseQuery(url.Query)["filters"].ToString());
            Assert.Equal(link.ClassList.Contains("resource-row-insight-failed") ? 3 : 2, filters.Count);
            Assert.Equal(KnownTraceFields.TimestampField, filters[0].Field);
            Assert.Equal(FilterCondition.GreaterThanOrEqual, filters[0].Condition);
            Assert.Equal(FilterCondition.LessThan, filters[1].Condition);
            Assert.Equal(TimeSpan.FromMinutes(5), DateTime.Parse(filters[1].Value) - DateTime.Parse(filters[0].Value));
            Assert.Contains("last 5 minutes", link.GetAttribute("aria-label"));
            Assert.Contains("Refreshed", link.GetAttribute("data-tooltip"));
            Assert.Equal(sampled, link.GetAttribute("data-tooltip")!.Contains("Counts are minimums", StringComparison.Ordinal));
        });
        Assert.Equal(1, Volatile.Read(ref calls));
        Assert.All(cut.FindAll(".resource-row[data-resource-name='quiet'], .resource-row[data-resource-name='key']"),
            row => Assert.Empty(row.QuerySelectorAll(".resource-row-insight")));
 
        navigation.NavigateTo("/traces/resource/api-TestId");
        cut.Render();
        Assert.Equal("api-TestId", cut.Instance.SelectedResourceName);
        Assert.Equal(ResourceTab.Traces, cut.Instance.CurrentTab);
        Assert.Empty(cut.FindAll(".resource-row-insight"));
        Assert.Equal(1, Volatile.Read(ref calls));
    }
 
    [Fact]
    public async Task Canvas_ReopeningDiscardsOldGenerationAndDisposalCancelsPendingQuery()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var secondRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var calls = 0;
        CancellationToken queryToken = default;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport,
            new TestDashboardClient(isEnabled: true, initialResources: [ModelTestHelpers.CreateResource("api-TestId")],
                resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services => services.AddSingleton<ITelemetryRepository>(provider =>
            {
                var inner = provider.GetRequiredService<SqliteTelemetryRepository>();
                return new TestTelemetryRepository(inner)
                {
                    GetRequestSpansAsyncHandler = async (request, token) =>
                    {
                        var result = await inner.GetRequestSpansAsync(request, token);
                        var first = Interlocked.Increment(ref calls) == 1;
                        queryToken = token;
                        (first ? firstStarted : secondStarted).SetResult();
                        await (first ? firstRelease : secondRelease).Task.WaitAsync(token);
                        return result;
                    }
                };
            }));
        var now = Services.GetRequiredService<BrowserTimeProvider>().GetUtcNow().UtcDateTime.AddSeconds(-1);
        await Services.GetRequiredService<SqliteTelemetryRepository>().AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
        {
            new ResourceSpans
            {
                Resource = CreateResource("api"),
                ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans =
                {
                    CreateSpan("1", "1", now, now.AddMilliseconds(10), kind: Span.Types.SpanKind.Server)
                } } }
            }
        });
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources?pane=tags");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        ResourceSetupHelpers.ToggleResourceList(cut);
        try
        {
            await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(10));
            Assert.Empty(cut.FindAll(".resource-row-insight"));
            ResourceSetupHelpers.ToggleResourceList(cut);
            ResourceSetupHelpers.ToggleResourceList(cut);
            firstRelease.SetResult();
            await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(10));
            Assert.Empty(cut.FindAll(".resource-row-insight"));
            Assert.Equal(2, Volatile.Read(ref calls));
            await cut.Instance.DisposeAsync();
            Assert.True(queryToken.IsCancellationRequested);
            Assert.Empty(cut.FindAll(".resource-row-insight"));
        }
        finally
        {
            firstRelease.TrySetResult();
            secondRelease.TrySetResult();
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Canvas_RefreshRemovesExpiredOrFailedData(bool failRefresh)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var clock = new TestTimeProvider();
        var calls = 0;
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport,
            new TestDashboardClient(isEnabled: true, initialResources: [ModelTestHelpers.CreateResource("api-TestId")],
                resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services =>
            {
                services.AddSingleton<BrowserTimeProvider>(clock);
                services.AddSingleton<ITelemetryRepository>(provider =>
                {
                    var inner = provider.GetRequiredService<SqliteTelemetryRepository>();
                    return new TestTelemetryRepository(inner)
                    {
                        GetRequestSpansAsyncHandler = (request, token) =>
                        {
                            if (Interlocked.Increment(ref calls) > 1 && failRefresh)
                            {
                                throw new InvalidOperationException("Deterministic activity refresh failure.");
                            }
 
                            return inner.GetRequestSpansAsync(request, token);
                        }
                    };
                });
            });
        var now = clock.UtcNow.UtcDateTime.AddSeconds(-1);
        async Task SeedAsync(string id)
        {
            await Services.GetRequiredService<SqliteTelemetryRepository>().AddTracesAsync(new AddContext(), new RepeatedField<ResourceSpans>
            {
                new ResourceSpans
                {
                    Resource = CreateResource("api"),
                    ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans =
                    {
                        CreateSpan(id, id, now, now.AddMilliseconds(10), kind: Span.Types.SpanKind.Server)
                    } } }
                }
            });
        }
 
        await SeedAsync("1");
        Services.GetRequiredService<NavigationManager>().NavigateTo("/resources?pane=tags");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        ResourceSetupHelpers.ToggleResourceList(cut);
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".resource-row-insight")));
        clock.UtcNow += TimeSpan.FromMinutes(6);
        await SeedAsync("2");
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(2, Volatile.Read(ref calls));
            Assert.Empty(cut.FindAll(".resource-row-insight"));
        });
    }
}