Skip to content

Commit

Permalink
Show all resource endpoints on resource grid (#2413)
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK authored Feb 27, 2024
1 parent 246a61a commit aa497c2
Show file tree
Hide file tree
Showing 11 changed files with 375 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@
<TemplateColumn Title="@ControlStringsLoc[nameof(ControlsStrings.PropertyGridValueColumnHeader)]" Class="valueColumn">
<GridValue Value="@context.Address" MaxDisplayLength="0">
<ContentAfterValue>
@if (context.IsHttp)
@if (context.Url != null)
{
<a href="@context.Address" target="_blank">@context.Address</a>
<a href="@context.Url" target="_blank">@context.Url</a>
}
else
{
@context.Address
@context.Text
}
</ContentAfterValue>
</GridValue>
Expand Down
26 changes: 7 additions & 19 deletions src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public partial class ResourceDetails
[Inject]
public required IStringLocalizer<Resources.Resources> Loc { get; init; }

[Inject]
public required ILogger<ResourceDetails> Logger { get; init; }

private bool IsSpecOnlyToggleDisabled => !Resource.Environment.All(i => !i.FromSpec) && !GetResourceValues().Any(v => v.KnownProperty == null);

private bool _showAll;
Expand All @@ -33,8 +36,8 @@ public partial class ResourceDetails
vm.Value?.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) == true)
).AsQueryable();

private IQueryable<Endpoint> FilteredEndpoints => GetEndpoints()
.Where(v => v.Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) || v.Address?.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) == true)
private IQueryable<DisplayedEndpoint> FilteredEndpoints => GetEndpoints()
.Where(v => v.Name.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) || v.Text.Contains(_filter, StringComparison.CurrentCultureIgnoreCase) == true)
.AsQueryable();

private IQueryable<SummaryValue> FilteredResourceValues => GetResourceValues()
Expand Down Expand Up @@ -96,17 +99,9 @@ protected override void OnParametersSet()
}
}

private IEnumerable<Endpoint> GetEndpoints()
private IEnumerable<DisplayedEndpoint> GetEndpoints()
{
foreach (var endpoint in Resource.Endpoints)
{
yield return new Endpoint { Name = Loc[Resources.Resources.ResourceDetailsEndpointUrl], IsHttp = true, Address = endpoint.EndpointUrl };
yield return new Endpoint { Name = Loc[Resources.Resources.ResourceDetailsProxyUrl], IsHttp = true, Address = endpoint.ProxyUrl };
}
foreach (var service in Resource.Services)
{
yield return new Endpoint { Name = service.Name, IsHttp = false, Address = service.AddressAndPort };
}
return ResourceEndpointHelpers.GetEndpoints(Logger, Resource, excludeServices: false, includeEndpointUrl: true);
}

private IEnumerable<SummaryValue> GetResourceValues()
Expand Down Expand Up @@ -207,13 +202,6 @@ private void CheckAllMaskStates()
};
}

private sealed class Endpoint
{
public bool IsHttp { get; init; }
public required string Name { get; init; }
public string? Address { get; init; }
}

private sealed class SummaryValue
{
public required string Key { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,54 @@
@using Aspire.Dashboard.Model
@using Aspire.Dashboard.Resources
@namespace Aspire.Dashboard.Components
@inject IStringLocalizer<Columns> Loc

<FluentStack Orientation="Orientation.Vertical">
@* If we have no endpoints, and the app isn't running anymore or we're not expecting any, then just say None *@
@if (Resource.Endpoints.Length == 0 && (Resource.State == ResourceStates.FinishedState || Resource.ExpectedEndpointsCount == 0))
@{
List<DisplayedEndpoint> displayedEndpoints;
string? additionalMessage = null;
if (Resource.Endpoints.Length == 0 && (Resource.State == ResourceStates.FinishedState || Resource.ExpectedEndpointsCount == 0))
{
<span class="long-inner-content">@Loc[nameof(Columns.EndpointsColumnDisplayNone)]</span>
if (Resource.Services.Length == 0)
{
// If we have no endpoints, and the app isn't running anymore or we're not expecting any, then just say None
additionalMessage = Loc[nameof(Columns.EndpointsColumnDisplayNone)];
displayedEndpoints = [];
}
else
{
displayedEndpoints = GetEndpoints(Resource);
}
}
else if (Resource.State != ResourceStates.FinishedState
&& (Resource.ExpectedEndpointsCount is null || Resource.ExpectedEndpointsCount > Resource.Endpoints.Length))
{
// If we're expecting more endpoints, say Starting..., unless the app isn't running anymore.
// Don't include service only endpoints in the list until finished loading endpoints.
displayedEndpoints = GetEndpoints(Resource, excludeServices: true);
additionalMessage = Loc[nameof(Columns.EndpointsColumnDisplayPlaceholder)];
}
else
{
@* If we have any, regardless of the state, go ahead and display them *@
foreach (var endpoint in Resource.Endpoints.OrderBy(e => e.ProxyUrl))
{
if (HasMultipleReplicas)
displayedEndpoints = GetEndpoints(Resource);
}
}

<ul class="endpoint-list">
@foreach (var displayedEndpoint in displayedEndpoints)
{
<li>
@if (displayedEndpoint.Url != null)
{
<a href="@endpoint.ProxyUrl" target="_blank" class="long-inner-content">@endpoint.ProxyUrl</a>
<span class="long-inner-content">(<a href="@endpoint.EndpointUrl" target="_blank">@endpoint.EndpointUrl</a>)</span>
<a href="@displayedEndpoint.Url" target="_blank">@displayedEndpoint.Url</a>
}
else
{
<a href="@endpoint.ProxyUrl" target="_blank" class="long-inner-content">@endpoint.ProxyUrl</a>
@displayedEndpoint.Text
}
}
@* If we're expecting more, say Starting..., unless the app isn't running anymore *@
if (Resource.State != ResourceStates.FinishedState
&& (Resource.ExpectedEndpointsCount is null || Resource.ExpectedEndpointsCount > Resource.Endpoints.Length))
{
<span class="long-inner-content">@Loc[nameof(Columns.EndpointsColumnDisplayPlaceholder)]</span>
}
</li>
}
</FluentStack>

@code {
[Parameter, EditorRequired]
public required ResourceViewModel Resource { get; set; }

[Parameter, EditorRequired]
public required bool HasMultipleReplicas { get; set; }
}
@if (!string.IsNullOrEmpty(additionalMessage))
{
<li>@additionalMessage</li>
}
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Model;
using Microsoft.AspNetCore.Components;

namespace Aspire.Dashboard.Components;

public partial class EndpointsColumnDisplay
{
[Parameter, EditorRequired]
public required ResourceViewModel Resource { get; set; }

[Parameter, EditorRequired]
public required bool HasMultipleReplicas { get; set; }

[Inject]
public required ILogger<EndpointsColumnDisplay> Logger { get; init; }

/// <summary>
/// A resource has services and endpoints. These can overlap. This method attempts to return a single list without duplicates.
/// </summary>
private List<DisplayedEndpoint> GetEndpoints(ResourceViewModel resource, bool excludeServices = false)
{
return ResourceEndpointHelpers.GetEndpoints(Logger, resource, excludeServices, includeEndpointUrl: false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
::deep.endpoint-list {
margin: 0;
padding: 0;
}

::deep.endpoint-list li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ private static void ConfigureListenAddresses(KestrelServerOptions kestrelOptions
{
foreach (var uri in uris)
{
if (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
if (string.Equals(uri.Host, "localhost", StringComparisons.UrlHost))
{
kestrelOptions.ListenLocalhost(uri.Port, ConfigureListenOptions);
}
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Dashboard/Model/BrowserLinkOutgoingPeerResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ public bool TryResolvePeerName(KeyValuePair<string, string>[] attributes, [NotNu
var url = OtlpHelpers.GetValue(attributes, "url.full") ?? OtlpHelpers.GetValue(attributes, "http.url");

// Quick check of URL with EndsWith before more expensive Uri parsing.
if (url != null && url.EndsWith(lastSegment, StringComparison.OrdinalIgnoreCase))
if (url != null && url.EndsWith(lastSegment, StringComparisons.UrlPath))
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && string.Equals(uri.Host, "localhost", StringComparisons.UrlHost))
{
var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2)
{
if (Guid.TryParse(parts[0], out _) && string.Equals(parts[1], lastSegment, StringComparison.OrdinalIgnoreCase))
if (Guid.TryParse(parts[0], out _) && string.Equals(parts[1], lastSegment, StringComparisons.UrlPath))
{
name = "Browser Link";
return true;
Expand Down
90 changes: 90 additions & 0 deletions src/Aspire.Dashboard/Model/ResourceEndpointHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;

namespace Aspire.Dashboard.Model;

internal static class ResourceEndpointHelpers
{
/// <summary>
/// A resource has services and endpoints. These can overlap. This method attempts to return a single list without duplicates.
/// </summary>
public static List<DisplayedEndpoint> GetEndpoints(ILogger logger, ResourceViewModel resource, bool excludeServices = false, bool includeEndpointUrl = false)
{
var displayedEndpoints = new List<DisplayedEndpoint>();

if (!excludeServices)
{
foreach (var service in resource.Services)
{
displayedEndpoints.Add(new DisplayedEndpoint
{
Name = service.Name,
Text = service.AddressAndPort,
Address = service.AllocatedAddress,
Port = service.AllocatedPort
});
}
}

foreach (var endpoint in resource.Endpoints)
{
ProcessUrl(logger, resource, displayedEndpoints, endpoint.ProxyUrl, "ProxyUrl");
if (includeEndpointUrl)
{
ProcessUrl(logger, resource, displayedEndpoints, endpoint.EndpointUrl, "EndpointUrl");
}
}

// Display endpoints with a URL first, then by address and port.
return displayedEndpoints.OrderBy(e => e.Url == null).ThenBy(e => e.Address).ThenBy(e => e.Port).ToList();
}

private static void ProcessUrl(ILogger logger, ResourceViewModel resource, List<DisplayedEndpoint> displayedEndpoints, string url, string name)
{
Uri uri;
try
{
uri = new Uri(url, UriKind.Absolute);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Couldn't parse '{Url}' to a URI for resource {ResourceName}.", url, resource.Name);
return;
}

// There isn't a good way to match services and endpoints other than by address and port.
var existingMatches = displayedEndpoints.Where(e => string.Equals(e.Address, uri.Host, StringComparisons.UrlHost) && e.Port == uri.Port).ToList();

if (existingMatches.Count > 0)
{
foreach (var e in existingMatches)
{
e.Url = uri.OriginalString;
e.Text = uri.OriginalString;
}
}
else
{
displayedEndpoints.Add(new DisplayedEndpoint
{
Name = name,
Text = url,
Address = uri.Host,
Port = uri.Port,
Url = uri.OriginalString
});
}
}
}

[DebuggerDisplay("Name = {Name}, Text = {Text}, Address = {Address}:{Port}, Url = {Url}")]
public sealed class DisplayedEndpoint
{
public required string Name { get; set; }
public required string Text { get; set; }
public string? Address { get; set; }
public int? Port { get; set; }
public string? Url { get; set; }
}
2 changes: 2 additions & 0 deletions src/Aspire.Dashboard/Model/ResourceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Diagnostics;
using Google.Protobuf.WellKnownTypes;

namespace Aspire.Dashboard.Model;

[DebuggerDisplay("Name = {Name}, ResourceType = {ResourceType}, State = {State}, Properties = {Properties.Count}")]
public sealed class ResourceViewModel
{
public required string Name { get; init; }
Expand Down
2 changes: 2 additions & 0 deletions src/Shared/StringComparers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal static class StringComparers
public static StringComparer UserTextSearch => StringComparer.CurrentCultureIgnoreCase;
public static StringComparer EnvironmentVariableName => StringComparer.InvariantCultureIgnoreCase;
public static StringComparer UrlPath => StringComparer.OrdinalIgnoreCase;
public static StringComparer UrlHost => StringComparer.OrdinalIgnoreCase;
}

internal static class StringComparisons
Expand All @@ -23,4 +24,5 @@ internal static class StringComparisons
public static StringComparison UserTextSearch => StringComparison.CurrentCultureIgnoreCase;
public static StringComparison EnvironmentVariableName => StringComparison.InvariantCultureIgnoreCase;
public static StringComparison UrlPath => StringComparison.OrdinalIgnoreCase;
public static StringComparison UrlHost => StringComparison.OrdinalIgnoreCase;
}
Loading

0 comments on commit aa497c2

Please sign in to comment.