File: Layout\ResourceRowAvailabilityTests.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.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OpenTelemetry.Proto.Trace.V1;
using OpenTelemetry.Proto.Logs.V1;
using OpenTelemetry.Proto.Metrics.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
 
namespace Aspire.Dashboard.Components.Tests.Layout;
 
[UseCulture("en-US")]
public class ResourceRowAvailabilityTests : DashboardTestContext
{
    [Theory]
    [InlineData("/resources", null)]
    [InlineData("/consolelogs", "Parameters do not produce console logs. Select to view this parameter's overview.")]
    [InlineData("/structuredlogs", "Parameters do not produce structured logs. Select to view this parameter's overview.")]
    [InlineData("/traces", "Parameters do not produce traces. Select to view this parameter's overview.")]
    [InlineData("/metrics", "Parameters do not produce metrics. Select to view this parameter's overview.")]
    [InlineData("/terminals", "Parameters do not support terminal sessions. Select to view this parameter's overview.")]
    public async Task ParameterRows_ReflectCurrentTabAndRemainNavigable(string url, string? reason)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("key", resourceType: KnownResourceTypes.Parameter)],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        var row = cut.Find("[data-resource-name='key']");
        var link = row.QuerySelector(".resource-row-link")!;
        var unavailable = reason is not null;
        Assert.Equal(unavailable, row.ClassList.Contains("resource-row-unavailable"));
        Assert.Equal(unavailable ? [Resources.Layout.ResourcePaneNotApplicableLabel] : [],
            row.QuerySelectorAll(".resource-row-availability").Select(badge => badge.TextContent));
        Assert.Equal(reason, link.GetAttribute("data-tooltip"));
        if (reason is not null)
        {
            Assert.Contains(reason, link.GetAttribute("aria-description"));
        }
        Assert.False(link.HasAttribute("disabled"));
        Assert.False(link.HasAttribute("aria-disabled"));
        Assert.False(link.HasAttribute("tabindex"));
        Assert.Equal("/resources/key", link.GetAttribute("href"));
 
        await link.ClickAsync();
        cut.Render();
 
        Assert.Equal("resources/key", navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Equal(["key"], cut.Instance.SelectedResourceNames);
        Assert.False(cut.Find("[data-resource-name='key']").ClassList.Contains("resource-row-unavailable"));
        Assert.Empty(cut.FindAll("[data-resource-name='key'] .resource-row-availability"));
    }
 
    [Theory]
    [InlineData(AspireDataType.StructuredLogs, "/structuredlogs", "/structuredlogs/resource/test-TestId")]
    [InlineData(AspireDataType.Traces, "/traces", "/traces/resource/test-TestId")]
    [InlineData(AspireDataType.Metrics, "/metrics", "/metrics/resource/test-TestId")]
    public async Task SignalAvailability_UpdatesWithoutChangingSelectionOrNavigation(AspireDataType signal, string url, string selectedUrl)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        var client = new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("test-TestId")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, client);
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        var tooltip = signal switch
        {
            AspireDataType.StructuredLogs => Resources.Layout.ResourcePaneNoStructuredLogs,
            AspireDataType.Traces => Resources.Layout.ResourcePaneNoTraces,
            _ => Resources.Layout.ResourcePaneNoMetrics
        };
        var link = cut.Find("[data-resource-name='test-TestId'] .resource-row-link");
        var label = signal switch
        {
            AspireDataType.StructuredLogs => Resources.Layout.ResourcePaneNoStructuredLogsLabel,
            AspireDataType.Traces => Resources.Layout.ResourcePaneNoTracesLabel,
            _ => Resources.Layout.ResourcePaneNoMetricsLabel
        };
        Assert.Equal(label, cut.Find("[data-resource-name='test-TestId'] .resource-row-availability").TextContent);
        Assert.Equal(tooltip, link.GetAttribute("data-tooltip"));
        Assert.Contains(tooltip, link.GetAttribute("aria-description"));
        Assert.False(link.HasAttribute("aria-disabled"));
 
        await link.ClickAsync();
        cut.Render();
        Assert.Equal(selectedUrl.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Equal(["test-TestId"], cut.Instance.SelectedResourceNames);
        Assert.True(cut.Find("[data-resource-name='test-TestId']").ClassList.Contains("selected"));
        Assert.True(cut.Find("[data-resource-name='test-TestId']").ClassList.Contains("resource-row-unavailable"));
        Assert.Empty(cut.Instance.GetSelectionTelemetryKeys(null)!);
 
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await TelemetryHelpSetupHelpers.AddSignalAsync(repository, signal);
        cut.WaitForAssertion(() =>
        {
            Assert.False(cut.Find("[data-resource-name='test-TestId']").ClassList.Contains("resource-row-unavailable"));
            Assert.Null(cut.Find("[data-resource-name='test-TestId'] .resource-row-link").GetAttribute("data-tooltip"));
            Assert.Empty(cut.FindAll("[data-resource-name='test-TestId'] .resource-row-availability"));
        });
        Assert.Equal(selectedUrl.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Equal(["test-TestId"], cut.Instance.SelectedResourceNames);
        Assert.Equal(["test-TestId"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.GetCompositeName()));
 
        await repository.ClearSelectedSignalsAsync(new() { ["test-TestId"] = [signal] });
        cut.WaitForAssertion(() => Assert.Equal(tooltip, cut.Find("[data-resource-name='test-TestId'] .resource-row-link").GetAttribute("data-tooltip")));
        Assert.Equal(label, cut.Find("[data-resource-name='test-TestId'] .resource-row-availability").TextContent);
        Assert.Equal(["test-TestId"], cut.Instance.SelectedResourceNames);
        Assert.Equal(selectedUrl.TrimStart('/'), navigation.ToBaseRelativePath(navigation.Uri));
        Assert.Equal(1, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData(AspireDataType.StructuredLogs, "/structuredlogs", "No logs")]
    [InlineData(AspireDataType.Traces, "/traces", "No traces")]
    public async Task RetentionEvictsLastItem_RestoresBadgeWithoutChangingSelection(AspireDataType signal, string url, string label)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("test-TestId")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services => services.AddSingleton<IOptions<DashboardOptions>>(Options.Create(new DashboardOptions
            {
                TelemetryLimits = { MaxLogCount = 1, MaxTraceCount = 1 }
            })));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await TelemetryHelpSetupHelpers.AddSignalAsync(repository, signal);
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        Assert.Empty(cut.FindAll("[data-resource-name='test-TestId'] .resource-row-availability"));
        await cut.Find("[data-resource-name='test-TestId'] .resource-row-link").ClickAsync();
        cut.Render();
        cut.WaitForAssertion(() => Assert.Equal(["test-TestId"], cut.Instance.SelectedResourceNames));
        var selectedUri = navigation.Uri;
 
        if (signal == AspireDataType.StructuredLogs)
        {
            await repository.AddLogsAsync(new(),
            [
                new ResourceLogs
                {
                    Resource = CreateResource("other"),
                    ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord(time: DateTime.UtcNow.AddDays(1)) } } }
                }
            ]);
        }
        else
        {
            await repository.AddTracesAsync(new(),
            [
                new ResourceSpans
                {
                    Resource = CreateResource("other"),
                    ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans = { CreateSpan("other-trace", "other-span", DateTime.UnixEpoch.AddDays(1), DateTime.UnixEpoch.AddDays(1).AddSeconds(1)) } } }
                }
            ]);
        }
 
        cut.WaitForAssertion(() => Assert.Equal(label,
            cut.Find("[data-resource-name='test-TestId'] .resource-row-availability").TextContent));
        Assert.Equal(["test-TestId"], cut.Instance.SelectedResourceNames);
        Assert.Equal(selectedUri, navigation.Uri);
        var resource = repository.GetResourceByCompositeName("test-TestId")!;
        Assert.True(signal == AspireDataType.StructuredLogs ? resource.HasLogs : resource.HasTraces);
    }
 
    [Theory]
    [InlineData(null, false)]
    [InlineData(AspireDataType.StructuredLogs, false)]
    [InlineData(AspireDataType.Metrics, false)]
    [InlineData(AspireDataType.StructuredLogs, true)]
    [InlineData(AspireDataType.Metrics, true)]
    public async Task Peer_IsAvailableForTracesUntilItsOutgoingSpanIsCleared(AspireDataType? reportedSignal, bool reportAfterSpan)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("cache",
                urls: [new("tcp", new Uri("tcp://localhost:6379"), false, false, UrlDisplayPropertiesViewModel.Empty)])],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>),
            configureServices: services => services.AddSingleton<IOutgoingPeerResolver, ResourceOutgoingPeerResolver>());
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        KeyValuePair<string, string>[] attributes = [KeyValuePair.Create("peer.service", "localhost:6379")];
        var resolver = Services.GetRequiredService<IOutgoingPeerResolver>();
        await AsyncTestHelpers.AssertIsTrueRetryAsync(() => Task.FromResult(resolver.TryResolvePeer(attributes, out _, out _)),
            "The resource peer resolver did not receive its initial resource snapshot.");
        if (reportedSignal is not null && !reportAfterSpan)
        {
            await AddPeerSignalAsync();
        }
        await repository.AddTracesAsync(new(),
        [
            new ResourceSpans
            {
                Resource = CreateResource("api"),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = CreateScope(),
                        Spans =
                        {
                            CreateSpan("trace", "span", DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(1),
                                attributes: attributes, kind: Span.Types.SpanKind.Client)
                        }
                    }
                }
            }
        ]);
        Services.GetRequiredService<NavigationManager>().NavigateTo("/traces/resource/cache");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(reportedSignal is null || reportAfterSpan, repository.GetResourceByCompositeName("cache")!.UninstrumentedPeer);
        Assert.False(cut.Find("[data-resource-name='cache']").ClassList.Contains("resource-row-unavailable"));
        Assert.Empty(cut.FindAll("[data-resource-name='cache'] .resource-row-availability"));
        Assert.Equal(["cache"], cut.Instance.SelectedResourceNames);
        Assert.Equal(["cache"], cut.Instance.GetSelectionTelemetryKeys(null)!.Select(key => key.Name));
 
        if (reportedSignal is not null && reportAfterSpan)
        {
            await AddPeerSignalAsync();
            cut.WaitForAssertion(() =>
            {
                Assert.False(repository.GetResourceByCompositeName("cache")!.UninstrumentedPeer);
                Assert.False(cut.Find("[data-resource-name='cache']").ClassList.Contains("resource-row-unavailable"));
                Assert.Empty(cut.FindAll("[data-resource-name='cache'] .resource-row-availability"));
            });
        }
 
        var traces = await repository.GetTracesAsync(GetTracesRequest.ForResourceKey(new("cache", null)), CancellationToken.None);
        Assert.Single(traces.PagedResult.Items);
        await repository.ClearSelectedSignalsAsync(new() { ["api-TestId"] = [AspireDataType.Traces] });
        cut.WaitForAssertion(() => Assert.Equal(Resources.Layout.ResourcePaneNoTracesLabel,
            cut.Find("[data-resource-name='cache'] .resource-row-availability").TextContent));
        Assert.Equal(["cache"], cut.Instance.SelectedResourceNames);
 
        Task AddPeerSignalAsync()
        {
            var peer = CreateResource("cache");
            peer.Attributes.Remove(peer.Attributes.Single(attribute => attribute.Key == "service.instance.id"));
            return reportedSignal switch
            {
                AspireDataType.StructuredLogs => repository.AddLogsAsync(new(),
                [
                    new ResourceLogs
                    {
                        Resource = peer,
                        ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord(message: "Peer log") } } }
                    }
                ]),
                AspireDataType.Metrics => repository.AddMetricsAsync(new(),
                [
                    new ResourceMetrics
                    {
                        Resource = peer,
                        ScopeMetrics = { new ScopeMetrics { Scope = CreateScope(), Metrics = { CreateSumMetric("peer.requests", DateTime.UnixEpoch) } } }
                    }
                ]),
                _ => throw new InvalidOperationException("The test must specify a peer signal.")
            };
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task AmbiguousCompositeNames_DoNotBreakLayoutOrAssociateAnArbitrarySource(bool appModelEnabled, bool addCollisionAfterRender)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: appModelEnabled,
            initialResources: [ModelTestHelpers.CreateResource("api-a-1")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await AddSourceAsync("api-a", "1");
        if (!addCollisionAfterRender)
        {
            await AddSourceAsync("api", "a-1");
        }
        Services.GetRequiredService<NavigationManager>().NavigateTo("/structuredlogs");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
        if (appModelEnabled)
        {
            await cut.Find(".resource-kind-toggle[aria-label='Telemetry sources']").ClickAsync();
        }
        if (addCollisionAfterRender)
        {
            await AddSourceAsync("api", "a-1");
        }
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(2, repository.GetResources().Count);
            Assert.Equal(2, cut.FindAll(".resource-row-kind-telemetry").Count);
            if (appModelEnabled)
            {
                Assert.Equal(Resources.Layout.ResourcePaneNoStructuredLogsLabel,
                    cut.Find(".resource-row-kind-resource[data-resource-name='api-a-1'] .resource-row-availability").TextContent);
                Assert.Empty(cut.Instance.GetSelectionTelemetryKeys(["api-a-1"])!);
            }
        });
 
        Task AddSourceAsync(string name, string instanceId) => repository.AddLogsAsync(new(),
        [
            new ResourceLogs
            {
                Resource = CreateResource(name, instanceId),
                ScopeLogs = { new ScopeLogs { Scope = CreateScope(), LogRecords = { CreateLogRecord(message: name) } } }
            }
        ]);
    }
 
    [Fact]
    public async Task OtherSignals_DoNotMakeStructuredLogsAvailableForTelemetryOnlyRows()
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: false));
        var repository = Services.GetRequiredService<SqliteTelemetryRepository>();
        await TelemetryHelpSetupHelpers.AddSignalAsync(repository, AspireDataType.Metrics);
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("/structuredlogs");
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(Resources.Layout.ResourcePaneNoStructuredLogs, cut.Find(".resource-row-kind-telemetry .resource-row-link").GetAttribute("data-tooltip"));
        Assert.Equal(Resources.Layout.ResourcePaneNoStructuredLogsLabel, cut.Find(".resource-row-kind-telemetry .resource-row-availability").TextContent);
        await cut.Find(".resource-row-kind-telemetry .resource-row-link").ClickAsync();
        cut.Render();
        Assert.Equal(["test"], cut.Instance.SelectedResourceNames);
        Assert.Equal("structuredlogs/resource/test", navigation.ToBaseRelativePath(navigation.Uri));
 
        await TelemetryHelpSetupHelpers.AddSignalAsync(repository, AspireDataType.StructuredLogs);
        cut.WaitForAssertion(() => Assert.Null(cut.Find(".resource-row-kind-telemetry .resource-row-link").GetAttribute("data-tooltip")));
        Assert.Empty(cut.FindAll(".resource-row-kind-telemetry .resource-row-availability"));
        Assert.Equal(["test"], cut.Instance.SelectedResourceNames);
    }
 
    [Theory]
    [InlineData("/structuredlogs", false)]
    [InlineData("/traces", true)]
    [InlineData("/metrics", true)]
    [InlineData("/consolelogs", false)]
    [InlineData("/terminals", true)]
    public async Task AvailableLogs_DoNotImplyOtherSignalsOrTerminalCapability(string url, bool unavailable)
    {
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        ResourceSetupHelpers.SetupResourcesLayout(this, viewport, new TestDashboardClient(isEnabled: true,
            initialResources: [ModelTestHelpers.CreateResource("test-TestId")],
            resourceChannelProvider: Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>));
        await TelemetryHelpSetupHelpers.AddSignalAsync(Services.GetRequiredService<SqliteTelemetryRepository>(), AspireDataType.StructuredLogs);
        Services.GetRequiredService<NavigationManager>().NavigateTo(url);
        var cut = Render<ResourcesLayout>(p => p.AddCascadingValue(viewport));
        cut.WaitForAssertion(() => Assert.True(cut.Instance.IsLoaded));
 
        Assert.Equal(unavailable, cut.Find("[data-resource-name='test-TestId']").ClassList.Contains("resource-row-unavailable"));
    }
}