@*
Tab content isn't nested inside FluentTab elements. The tab control is just used to display the tabs.
@@ -177,8 +177,7 @@
-
+
+
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
index 2d4a0504fe0..5d87f0012e8 100644
--- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
@@ -99,6 +99,11 @@ public partial class Resources : ComponentBase, IAsyncDisposable, IPageWithSessi
private bool _graphInitialized;
private AspirePageContentLayout? _contentLayout;
+ private AspireMenu? _contextMenu;
+ private bool _contextMenuOpen;
+ private readonly List _contextMenuItems = new();
+ private TaskCompletionSource? _contextMenuClosedTcs;
+
private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default;
private ColumnSortLabels _sortLabels = ColumnSortLabels.Default;
private bool _showResourceTypeColumn;
@@ -346,6 +351,18 @@ await resources.InvokeAsync(async () =>
});
}
}
+
+ [JSInvokable]
+ public async Task ResourceContextMenu(string id, int clientX, int clientY)
+ {
+ if (resources._resourceByName.TryGetValue(id, out var resource))
+ {
+ await resources.InvokeAsync(async () =>
+ {
+ await resources.ShowContextMenuAsync(resource, clientX, clientY);
+ });
+ }
+ }
}
internal IEnumerable GetFilteredResources()
@@ -494,6 +511,41 @@ private bool ApplicationErrorCountsChanged(Dictionary newAp
return false;
}
+ private async Task ShowContextMenuAsync(ResourceViewModel resource, int clientX, int clientY)
+ {
+ // This is called when the browser requests to show the context menu for a resource.
+ // The method doesn't complete until the context menu is closed so the browser can await
+ // it and perform clean up when the context menu is closed.
+ if (_contextMenu is { } contextMenu)
+ {
+ _contextMenuItems.Clear();
+ ResourceMenuItems.AddMenuItems(
+ _contextMenuItems,
+ openingMenuButtonId: null,
+ resource,
+ NavigationManager,
+ TelemetryRepository,
+ GetResourceName,
+ ControlsStringsLoc,
+ Loc,
+ (buttonId) => ShowResourceDetailsAsync(resource, buttonId),
+ (command) => ExecuteResourceCommandAsync(resource, command),
+ (resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name),
+ showConsoleLogsItem: true);
+
+ // The previous context menu should always be closed by this point but complete just in case.
+ _contextMenuClosedTcs?.TrySetResult();
+
+ _contextMenuClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await contextMenu.OpenAsync(clientX, clientY);
+ StateHasChanged();
+
+ // Completed when the overlay closes.
+ await _contextMenuClosedTcs.Task;
+ }
+ }
+
private async Task ShowResourceDetailsAsync(ResourceViewModel resource, string? buttonId)
{
_elementIdBeforeDetailsViewOpened = buttonId;
@@ -753,4 +805,15 @@ public async ValueTask DisposeAsync()
await TaskHelpers.WaitIgnoreCancelAsync(_resourceSubscriptionTask);
}
+
+ private async Task ContextMenuClosed(Microsoft.AspNetCore.Components.Web.MouseEventArgs args)
+ {
+ if (_contextMenu is { } menu)
+ {
+ await menu.CloseAsync();
+ }
+
+ _contextMenuClosedTcs?.TrySetResult();
+ _contextMenuClosedTcs = null;
+ }
}
diff --git a/src/Aspire.Dashboard/Model/ResourceMenuItems.cs b/src/Aspire.Dashboard/Model/ResourceMenuItems.cs
new file mode 100644
index 00000000000..6d8cafac0c7
--- /dev/null
+++ b/src/Aspire.Dashboard/Model/ResourceMenuItems.cs
@@ -0,0 +1,119 @@
+// 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.Otlp.Storage;
+using Aspire.Dashboard.Utils;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.Localization;
+using Microsoft.FluentUI.AspNetCore.Components;
+using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
+
+namespace Aspire.Dashboard.Model;
+
+public static class ResourceMenuItems
+{
+ private static readonly Icon s_viewDetailsIcon = new Icons.Regular.Size16.Info();
+ private static readonly Icon s_consoleLogsIcon = new Icons.Regular.Size16.SlideText();
+ private static readonly Icon s_structuredLogsIcon = new Icons.Regular.Size16.SlideTextSparkle();
+ private static readonly Icon s_tracesIcon = new Icons.Regular.Size16.GanttChart();
+ private static readonly Icon s_metricsIcon = new Icons.Regular.Size16.ChartMultiple();
+
+ public static void AddMenuItems(
+ List menuItems,
+ string? openingMenuButtonId,
+ ResourceViewModel resource,
+ NavigationManager navigationManager,
+ TelemetryRepository telemetryRepository,
+ Func getResourceName,
+ IStringLocalizer controlLoc,
+ IStringLocalizer loc,
+ Func onViewDetails,
+ Func commandSelected,
+ Func isCommandExecuting,
+ bool showConsoleLogsItem)
+ {
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = controlLoc[nameof(Resources.ControlsStrings.ActionViewDetailsText)],
+ Icon = s_viewDetailsIcon,
+ OnClick = () => onViewDetails(openingMenuButtonId)
+ });
+
+ if (showConsoleLogsItem)
+ {
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = loc[nameof(Resources.Resources.ResourceActionConsoleLogsText)],
+ Icon = s_consoleLogsIcon,
+ OnClick = () =>
+ {
+ navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: resource.Name));
+ return Task.CompletedTask;
+ }
+ });
+ }
+
+ // Show telemetry menu items if there is telemetry for the resource.
+ var hasTelemetryApplication = telemetryRepository.GetApplicationByCompositeName(resource.Name) != null;
+ if (hasTelemetryApplication)
+ {
+ menuItems.Add(new MenuButtonItem { IsDivider = true });
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = loc[nameof(Resources.Resources.ResourceActionStructuredLogsText)],
+ Tooltip = loc[nameof(Resources.Resources.ResourceActionStructuredLogsText)],
+ Icon = s_structuredLogsIcon,
+ OnClick = () =>
+ {
+ navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(resource: getResourceName(resource)));
+ return Task.CompletedTask;
+ }
+ });
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = loc[nameof(Resources.Resources.ResourceActionTracesText)],
+ Tooltip = loc[nameof(Resources.Resources.ResourceActionTracesText)],
+ Icon = s_tracesIcon,
+ OnClick = () =>
+ {
+ navigationManager.NavigateTo(DashboardUrls.TracesUrl(resource: getResourceName(resource)));
+ return Task.CompletedTask;
+ }
+ });
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = loc[nameof(Resources.Resources.ResourceActionMetricsText)],
+ Tooltip = loc[nameof(Resources.Resources.ResourceActionMetricsText)],
+ Icon = s_metricsIcon,
+ OnClick = () =>
+ {
+ navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: getResourceName(resource)));
+ return Task.CompletedTask;
+ }
+ });
+ }
+
+ var menuCommands = resource.Commands
+ .Where(c => c.State != CommandViewModelState.Hidden)
+ .OrderBy(c => !c.IsHighlighted)
+ .ToList();
+ if (menuCommands.Count > 0)
+ {
+ menuItems.Add(new MenuButtonItem { IsDivider = true });
+
+ foreach (var command in menuCommands)
+ {
+ var icon = (!string.IsNullOrEmpty(command.IconName) && IconResolver.ResolveIconName(command.IconName, IconSize.Size16, command.IconVariant) is { } i) ? i : null;
+
+ menuItems.Add(new MenuButtonItem
+ {
+ Text = command.DisplayName,
+ Tooltip = command.DisplayDescription,
+ Icon = icon,
+ OnClick = () => commandSelected(command),
+ IsDisabled = command.State == CommandViewModelState.Disabled || isCommandExecuting(resource, command)
+ });
+ }
+ }
+ }
+}
diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
index 230345f3056..16eadd0740c 100644
--- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
+++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs
@@ -151,11 +151,11 @@ public static string ConsoleLogsPauseDetails {
}
///
- /// Looks up a localized string similar to Resource commands.
+ /// Looks up a localized string similar to Resource actions.
///
- public static string ConsoleLogsResourceCommands {
+ public static string ConsoleLogsResourceActions {
get {
- return ResourceManager.GetString("ConsoleLogsResourceCommands", resourceCulture);
+ return ResourceManager.GetString("ConsoleLogsResourceActions", resourceCulture);
}
}
diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
index 0560ea925fb..1baee31e471 100644
--- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
+++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx
@@ -160,8 +160,8 @@
Console logs settings
-
- Resource commands
+
+ Resource actions
Show timestamps
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
index e7237d26380..8e38f9b9606 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf
@@ -52,9 +52,9 @@
<Zachytávání protokolů bylo pozastaveno mezi {0} a {1}, odfiltrované protokoly: {2} >
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Příkazy prostředku
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf
index 053803343bb..55233793de9 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf
@@ -52,9 +52,9 @@
<Protokollerfassung wurde zwischen {0} und {1} angehalten, {2} Protokoll(e) herausgefiltert>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Ressourcenbefehle
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf
index e7e829f0c81..9feeebf78c2 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf
@@ -52,9 +52,9 @@
< Captura de registro en pausa entre {0} y {1}, {2} registros filtrados>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Comandos de recursos
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf
index e0e39b83395..4b5435682a9 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf
@@ -52,9 +52,9 @@
<Capture de journal suspendue entre le ou les journaux {0} et {1}, {2} filtrés>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Commandes de ressource
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf
index d43a2e194a1..bee1937e4a4 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf
@@ -52,9 +52,9 @@
<L'acquisizione dei log è stata sospesa tra {0} e {1}, {2} log filtrati>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Comandi risorsa
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf
index e073e012dfa..0254c67e242 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf
@@ -52,9 +52,9 @@
<ログ キャプチャが {0} と {1} の間で一時停止されました。{2} 件のログがフィルターで除外されました>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- リソース コマンド
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf
index 2b21feab61a..926447845c9 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf
@@ -52,9 +52,9 @@
<로그 캡처가 {0} 및 {1}간에 일시 중지되었습니다. {2} 로그가 필터링되었습니다>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- 리소스 명령
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf
index 67c717d6750..0bf0bc7fcac 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf
@@ -52,9 +52,9 @@
<Przechwytywanie dziennika zostało wstrzymane między {0} a {1}, liczba odfiltrowanych dzienników: {2}>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Polecenia zasobów
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf
index 90b0547e47b..aace822da21 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf
@@ -52,9 +52,9 @@
<Captura de log em pausa entre {0} e {1}, {2} logs filtrados>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Comandos de recursos
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf
index a18363e8c2f..ce67e837680 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf
@@ -52,9 +52,9 @@
<Запись журнала приостановлена между {0} и {1}, отфильтровано журналов: {2}>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Команды ресурсов
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf
index c8427486460..80562ce80c7 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf
@@ -52,9 +52,9 @@
<Günlük yakalama, {0} ile {1} arasında duraklatıldı, {2} günlük filtrelendi>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- Kaynak komutları
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf
index 5e6279c3dbc..5e3ab9aeeff 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf
@@ -52,9 +52,9 @@
<日志捕获已在 {0} 和 {1} 之间暂停,筛选掉 {2} 条日志>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- 资源命令
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf
index c6183911ad8..da4dad0ca14 100644
--- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf
+++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf
@@ -52,9 +52,9 @@
<{0} 與 {1} 之間的記錄擷取已暫停,篩選出了 {2} 筆記錄>
{0} is a date, {1} is a date, {2} is a number.
-
- Resource commands
- 資源命令
+
+ Resource actions
+ Resource actions
diff --git a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js
index 36e973cf3aa..7f15ffae5d7 100644
--- a/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js
+++ b/src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js
@@ -31,6 +31,7 @@ class ResourceGraph {
constructor(resourcesInterop) {
this.resources = [];
this.resourcesInterop = resourcesInterop;
+ this.openContextMenu = false;
this.nodes = [];
this.links = [];
@@ -311,6 +312,7 @@ class ResourceGraph {
.append("g")
.attr("class", "resource-scale")
.on('click', this.selectNode)
+ .on('contextmenu', this.nodeContextMenu)
.on('mouseover', this.hoverNode)
.on('mouseout', this.unHoverNode);
newNodesContainer
@@ -358,12 +360,10 @@ class ResourceGraph {
var resourceNameGroup = newNodesContainer
.append("g")
.attr("transform", "translate(0,71)")
- .attr("class", "resource-name")
- .on('click', this.selectNode);
+ .attr("class", "resource-name");
resourceNameGroup
.append("text")
- .text(n => trimText(n.label, 30))
- .on('click', this.selectNode);
+ .text(n => trimText(n.label, 30));
resourceNameGroup
.append("title")
.text(n => n.label);
@@ -473,6 +473,25 @@ class ResourceGraph {
return 'resource-link';
}
+ nodeContextMenu = async (event) => {
+ var data = event.target.__data__;
+
+ // Prevent default browser context menu.
+ event.preventDefault();
+
+ this.openContextMenu = true;
+
+ try {
+ // Wait for method completion. It completes when the context menu is closed.
+ await this.resourcesInterop.invokeMethodAsync('ResourceContextMenu', data.id, event.clientX, event.clientY);
+ } finally {
+ this.openContextMenu = false;
+
+ // Unselect the node when the context menu is closed to reset mouseover state.
+ this.updateNodeHighlights(null);
+ }
+ };
+
selectNode = (event) => {
var data = event.target.__data__;
@@ -518,7 +537,11 @@ class ResourceGraph {
}
unHoverNode = (event) => {
- this.updateNodeHighlights(null);
+ // Don't unhover the selected node when the context menu is open.
+ // This is done to keep the node selected until the context menu is closed.
+ if (!this.openContextMenu) {
+ this.updateNodeHighlights(null);
+ }
};
nodeEquals(resource1, resource2) {
diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs
index 6c27ea552f4..234cb620c3f 100644
--- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs
+++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs
@@ -8,6 +8,7 @@
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.BrowserStorage;
+using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Aspire.Hosting.ConsoleLogs;
using Aspire.Tests.Shared.DashboardModel;
@@ -538,6 +539,7 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul
Services.AddSingleton();
Services.AddSingleton>(Options.Create(new DashboardOptions()));
Services.AddSingleton();
+ Services.AddSingleton();
Services.AddSingleton();
Services.AddSingleton();
Services.AddSingleton();