Skip to content

Improve resources action menu #10869

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions src/Aspire.Dashboard/Components/Controls/AspireMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@
<FluentMenu Class="aspire-menu-container" @ref="_menu" Anchor="@Anchor" Anchored="@Anchored" Open="@Open" OpenChanged="OnOpenChanged" Style="@Style" VerticalThreshold="@VerticalThreshold" HorizontalThreshold="200">
@foreach (var item in Items)
{
@if (item.IsDivider)
@RenderMenuItem(item)
}
</FluentMenu>

@code {
private RenderFragment RenderMenuItem(MenuButtonItem item)
{
if (item.IsDivider)
{
<FluentDivider />
return @<FluentDivider />;
}
else
{
Expand All @@ -17,15 +24,30 @@
{ "title", !string.IsNullOrEmpty(item.Tooltip) ? item.Tooltip : item.Text ?? string.Empty }
};

<FluentMenuItem Id="@item.Id" Class="@item.Class" OnClick="() => HandleItemClicked(item)" Disabled="@item.IsDisabled" AdditionalAttributes="@additionalMenuItemAttributes">
@item.Text
return @<FluentMenuItem Id="@item.Id" Class="@item.Class" OnClick="() => HandleItemClicked(item)" Disabled="@item.IsDisabled" AdditionalAttributes="@additionalMenuItemAttributes" Label="@item.Text" MenuItems="@RenderNestedItems(item)">
@if (item.Icon != null)
{
<span slot="start">
<FluentIcon Value="@item.Icon" Style="vertical-align: text-bottom;" Width="16px" />
</span>
}
</FluentMenuItem>
</FluentMenuItem>;
}
}
</FluentMenu>

private RenderFragment? RenderNestedItems(MenuButtonItem item)
{
if (item.NestedMenuItems is null || item.NestedMenuItems.Count == 0)
{
return null;
}

// div is required because a single element has to be returned.
return @<div>
@foreach (var nestedItem in item.NestedMenuItems)
{
@RenderMenuItem(nestedItem)
}
</div>;
}
}
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/MenuButtonItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Aspire.Dashboard.Model;
public class MenuButtonItem
{
public bool IsDivider { get; set; }
public List<MenuButtonItem>? NestedMenuItems { get; set; }
public string? Text { get; set; }
public string? Tooltip { get; set; }
public Icon? Icon { get; set; }
Expand Down
158 changes: 120 additions & 38 deletions src/Aspire.Dashboard/Model/ResourceMenuItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public static class ResourceMenuItems
private static readonly Icon s_tracesIcon = new Icons.Regular.Size16.GanttChart();
private static readonly Icon s_metricsIcon = new Icons.Regular.Size16.ChartMultiple();
private static readonly Icon s_linkIcon = new Icons.Regular.Size16.Link();
private static readonly Icon s_toolboxIcon = new Icons.Regular.Size16.Toolbox();
private static readonly Icon s_linkMultipleIcon = new Icons.Regular.Size16.LinkMultiple();

public static void AddMenuItems(
List<MenuButtonItem> menuItems,
Expand Down Expand Up @@ -56,6 +58,75 @@ public static void AddMenuItems(
});
}

AddTelemetryMenuItems(menuItems, resource, navigationManager, telemetryRepository, getResourceName, loc);

AddCommandMenuItems(menuItems, resource, loc, commandsLoc, commandSelected, isCommandExecuting);

if (showUrls)
{
AddUrlMenuItems(menuItems, resource, loc);
}
}

private static void AddUrlMenuItems(List<MenuButtonItem> menuItems, ResourceViewModel resource, IStringLocalizer<Resources.Resources> loc)
{
var urls = ResourceUrlHelpers.GetUrls(resource, includeInternalUrls: false, includeNonEndpointUrls: true)
.Where(u => !string.IsNullOrEmpty(u.Url))
.ToList();

if (urls.Count == 0)
{
return;
}

menuItems.Add(new MenuButtonItem { IsDivider = true });

if (urls.Count > 5)
{
var urlItems = new List<MenuButtonItem>();

foreach (var url in urls)
{
urlItems.Add(CreateUrlMenuItem(url));
}

menuItems.Add(new MenuButtonItem
{
Text = loc[nameof(Resources.Resources.ResourceActionUrlsText)],
Tooltip = "", // No tooltip for the commands menu item.
Icon = s_linkMultipleIcon,
NestedMenuItems = urlItems
});
}
else
{
foreach (var url in urls)
{
menuItems.Add(CreateUrlMenuItem(url));
}
}
}

private static MenuButtonItem CreateUrlMenuItem(DisplayedUrl url)
{
// Opens the URL in a new window when clicked.
// It's important that this is done in the onclick event so the browser popup allows it.
return new MenuButtonItem
{
Text = url.Text,
Tooltip = url.Url,
Icon = s_linkIcon,
AdditionalAttributes = new Dictionary<string, object>
{
["data-openbutton"] = "true",
["data-url"] = url.Url!,
["data-target"] = "_blank"
}
};
}

private static void AddTelemetryMenuItems(List<MenuButtonItem> menuItems, ResourceViewModel resource, NavigationManager navigationManager, TelemetryRepository telemetryRepository, Func<ResourceViewModel, string> getResourceName, IStringLocalizer<Resources.Resources> loc)
{
// Show telemetry menu items if there is telemetry for the resource.
var telemetryApplication = telemetryRepository.GetApplicationByCompositeName(resource.Name);
if (telemetryApplication != null)
Expand Down Expand Up @@ -104,58 +175,69 @@ public static void AddMenuItems(
});
}
}
}

private static void AddCommandMenuItems(List<MenuButtonItem> menuItems, ResourceViewModel resource, IStringLocalizer<Resources.Resources> loc, IStringLocalizer<Commands> commandsLoc, EventCallback<CommandViewModel> commandSelected, Func<ResourceViewModel, CommandViewModel, bool> isCommandExecuting)
{
var menuCommands = resource.Commands
.Where(c => c.State != CommandViewModelState.Hidden)
.OrderBy(c => !c.IsHighlighted)
.ToList();
if (menuCommands.Count > 0)
.Where(c => c.State != CommandViewModelState.Hidden)
.ToList();

if (menuCommands.Count == 0)
{
menuItems.Add(new MenuButtonItem { IsDivider = true });
return;
}

foreach (var command in menuCommands)
{
var icon = (!string.IsNullOrEmpty(command.IconName) && IconResolver.ResolveIconName(command.IconName, IconSize.Size16, command.IconVariant) is { } i) ? i : null;
var highlightedMenuCommands = menuCommands.Where(c => c.IsHighlighted).ToList();
var otherMenuCommands = menuCommands.Where(c => !c.IsHighlighted).ToList();

menuItems.Add(new MenuButtonItem
{
Text = command.GetDisplayName(commandsLoc),
Tooltip = command.GetDisplayDescription(commandsLoc),
Icon = icon,
OnClick = () => commandSelected.InvokeAsync(command),
IsDisabled = command.State == CommandViewModelState.Disabled || isCommandExecuting(resource, command)
});
}
menuItems.Add(new MenuButtonItem { IsDivider = true });

// Always show the highlighted commands first and not in a sub-menu.
foreach (var highlightedCommand in highlightedMenuCommands)
{
menuItems.Add(CreateMenuItem(highlightedCommand));
}

if (showUrls)
// If there are more than 5 commands, we group them under a "Commands" menu item. This is done to avoid the menu going off the end of the screen.
// A scenario where this could happen is viewing the menu for a resource and the resource is in the middle of the screen.
if (highlightedMenuCommands.Count + otherMenuCommands.Count > 5)
{
var urls = ResourceUrlHelpers.GetUrls(resource, includeInternalUrls: false, includeNonEndpointUrls: true)
.Where(u => !string.IsNullOrEmpty(u.Url))
.ToList();
var commands = new List<MenuButtonItem>();

if (urls.Count > 0)
foreach (var command in otherMenuCommands)
{
menuItems.Add(new MenuButtonItem { IsDivider = true });
commands.Add(CreateMenuItem(command));
}

foreach (var url in urls)
menuItems.Add(new MenuButtonItem
{
// Opens the URL in a new window when clicked.
// It's important that this is done in the onclick event so the browser popup allows it.
menuItems.Add(new MenuButtonItem
{
Text = url.Text,
Tooltip = url.Url,
Icon = s_linkIcon,
AdditionalAttributes = new Dictionary<string, object>
{
["data-openbutton"] = "true",
["data-url"] = url.Url!,
["data-target"] = "_blank"
}
});
Text = loc[nameof(Resources.Resources.ResourceActionCommandsText)],
Tooltip = "", // No tooltip for the commands menu item.
Icon = s_toolboxIcon,
NestedMenuItems = commands
});
}
else
{
foreach (var command in otherMenuCommands)
{
menuItems.Add(CreateMenuItem(command));
}
}

MenuButtonItem CreateMenuItem(CommandViewModel command)
{
var icon = (!string.IsNullOrEmpty(command.IconName) && IconResolver.ResolveIconName(command.IconName, IconSize.Size16, command.IconVariant) is { } i) ? i : null;

return new MenuButtonItem
{
Text = command.GetDisplayName(commandsLoc),
Tooltip = command.GetDisplayDescription(commandsLoc),
Icon = icon,
OnClick = () => commandSelected.InvokeAsync(command),
IsDisabled = command.State == CommandViewModelState.Disabled || isCommandExecuting(resource, command)
};
}
}
}
4 changes: 2 additions & 2 deletions src/Aspire.Dashboard/Model/ResourceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,14 @@ public string GetDisplayName(IStringLocalizer<Commands> loc)
};
}

public string GetDisplayDescription(IStringLocalizer<Commands> loc)
public string? GetDisplayDescription(IStringLocalizer<Commands> loc)
{
return Name switch
{
KnownResourceCommands.StartCommand => loc[nameof(Commands.StartCommandDisplayDescription)],
KnownResourceCommands.StopCommand => loc[nameof(Commands.StopCommandDisplayDescription)],
KnownResourceCommands.RestartCommand => loc[nameof(Commands.RestartCommandDisplayDescription)],
_ => DisplayDescription
_ => DisplayDescription is { Length : > 0 } ? DisplayDescription : null
};
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/Aspire.Dashboard/Resources/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading