diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index 1b72e8cd290..735aebb190a 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -67,6 +67,10 @@
+
+
+
+
diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs
new file mode 100644
index 00000000000..a844a43b052
--- /dev/null
+++ b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs
@@ -0,0 +1,97 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol;
+
+namespace Aspire.Cli.Backchannel;
+
+///
+/// Provides helper methods for working with AppHost connections.
+///
+internal static class AppHostConnectionHelper
+{
+ ///
+ /// Gets the appropriate AppHost connection based on the selection logic:
+ /// 1. If a specific AppHost is selected via select_apphost, use that
+ /// 2. Otherwise, look for in-scope connections (AppHosts within the working directory)
+ /// 3. If exactly one in-scope connection exists, use it
+ /// 4. If multiple in-scope connections exist, throw an error listing them
+ /// 5. If no in-scope connections exist, fall back to the first available connection
+ ///
+ /// The backchannel monitor to get connections from.
+ /// Logger for debug output.
+ /// Cancellation token.
+ /// The selected connection, or null if none available.
+ public static async Task GetSelectedConnectionAsync(
+ IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
+ ILogger logger,
+ CancellationToken cancellationToken = default)
+ {
+ var connections = auxiliaryBackchannelMonitor.Connections.ToList();
+
+ if (connections.Count == 0)
+ {
+ await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
+ connections = auxiliaryBackchannelMonitor.Connections.ToList();
+ if (connections.Count == 0)
+ {
+ return null;
+ }
+ }
+
+ // Check if a specific AppHost was selected
+ var selectedPath = auxiliaryBackchannelMonitor.SelectedAppHostPath;
+ if (!string.IsNullOrEmpty(selectedPath))
+ {
+ var selectedConnection = connections.FirstOrDefault(c =>
+ c.AppHostInfo?.AppHostPath != null &&
+ string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase));
+
+ if (selectedConnection != null)
+ {
+ logger.LogDebug("Using explicitly selected AppHost: {AppHostPath}", selectedPath);
+ return selectedConnection;
+ }
+
+ logger.LogWarning("Selected AppHost at '{SelectedPath}' is no longer running, falling back to selection logic", selectedPath);
+ // Clear the selection since the AppHost is no longer available
+ auxiliaryBackchannelMonitor.SelectedAppHostPath = null;
+ }
+
+ // Get in-scope connections
+ var inScopeConnections = connections.Where(c => c.IsInScope).ToList();
+
+ if (inScopeConnections.Count == 1)
+ {
+ logger.LogDebug("Using single in-scope AppHost: {AppHostPath}", inScopeConnections[0].AppHostInfo?.AppHostPath ?? "N/A");
+ return inScopeConnections[0];
+ }
+
+ if (inScopeConnections.Count > 1)
+ {
+ var paths = inScopeConnections
+ .Where(c => c.AppHostInfo?.AppHostPath != null)
+ .Select(c => c.AppHostInfo!.AppHostPath)
+ .ToList();
+
+ var pathsList = string.Join("\n", paths.Select(p => $" - {p}"));
+
+ throw new McpProtocolException(
+ $"Multiple Aspire AppHosts are running in the scope of the MCP server's working directory. " +
+ $"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}",
+ McpErrorCode.InternalError);
+ }
+
+ var fallback = connections
+ .OrderBy(c => c.AppHostInfo?.AppHostPath ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(c => c.AppHostInfo?.ProcessId ?? int.MaxValue)
+ .FirstOrDefault();
+
+ logger.LogDebug(
+ "No in-scope AppHosts found. Falling back to first available AppHost: {AppHostPath}",
+ fallback?.AppHostInfo?.AppHostPath ?? "N/A");
+
+ return fallback;
+ }
+}
diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
new file mode 100644
index 00000000000..c06c192717b
--- /dev/null
+++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
@@ -0,0 +1,188 @@
+// 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.Utils;
+using Aspire.Shared.Model;
+using Aspire.Shared.Model.Serialization;
+
+namespace Aspire.Cli.Backchannel;
+
+///
+/// Maps to for serialization.
+///
+internal static class ResourceSnapshotMapper
+{
+ ///
+ /// Maps a list of to a list of .
+ ///
+ /// The resource snapshots to map.
+ /// Optional base URL of the Aspire Dashboard for generating resource URLs.
+ public static List MapToResourceJsonList(IEnumerable snapshots, string? dashboardBaseUrl = null)
+ {
+ var snapshotList = snapshots.ToList();
+ return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl)).ToList();
+ }
+
+ ///
+ /// Maps a to .
+ ///
+ /// The resource snapshot to map.
+ /// All resource snapshots for resolving relationships.
+ /// Optional base URL of the Aspire Dashboard for generating resource URLs.
+ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null)
+ {
+ var urls = snapshot.Urls
+ .Select(u => new ResourceUrlJson
+ {
+ Name = u.Name,
+ DisplayName = u.DisplayProperties?.DisplayName,
+ Url = u.Url,
+ IsInternal = u.IsInternal
+ })
+ .ToArray();
+
+ var volumes = snapshot.Volumes
+ .Select(v => new ResourceVolumeJson
+ {
+ Source = v.Source,
+ Target = v.Target,
+ MountType = v.MountType,
+ IsReadOnly = v.IsReadOnly
+ })
+ .ToArray();
+
+ var healthReports = snapshot.HealthReports
+ .Select(h => new ResourceHealthReportJson
+ {
+ Name = h.Name,
+ Status = h.Status,
+ Description = h.Description,
+ ExceptionMessage = h.ExceptionText
+ })
+ .ToArray();
+
+ var environment = snapshot.EnvironmentVariables
+ .Where(e => e.IsFromSpec)
+ .Select(e => new ResourceEnvironmentVariableJson
+ {
+ Name = e.Name,
+ Value = e.Value
+ })
+ .ToArray();
+
+ var properties = snapshot.Properties
+ .Select(p => new ResourcePropertyJson
+ {
+ Name = p.Key,
+ Value = p.Value
+ })
+ .ToArray();
+
+ // Build relationships by matching DisplayName
+ var relationships = new List();
+ foreach (var relationship in snapshot.Relationships)
+ {
+ var matches = allSnapshots
+ .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName))
+ .ToList();
+
+ foreach (var match in matches)
+ {
+ relationships.Add(new ResourceRelationshipJson
+ {
+ Type = relationship.Type,
+ ResourceName = match.Name
+ });
+ }
+ }
+
+ // Only include enabled commands
+ var commands = snapshot.Commands
+ .Where(c => string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase))
+ .Select(c => new ResourceCommandJson
+ {
+ Name = c.Name,
+ Description = c.Description
+ })
+ .ToArray();
+
+ // Get source information using the shared ResourceSourceViewModel
+ var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties);
+
+ // Generate dashboard URL for this resource if a base URL is provided
+ string? dashboardUrl = null;
+ if (!string.IsNullOrEmpty(dashboardBaseUrl))
+ {
+ var resourcePath = DashboardUrls.ResourcesUrl(snapshot.Name);
+ dashboardUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, resourcePath);
+ }
+
+ return new ResourceJson
+ {
+ Name = snapshot.Name,
+ DisplayName = snapshot.DisplayName,
+ ResourceType = snapshot.ResourceType,
+ State = snapshot.State,
+ StateStyle = snapshot.StateStyle,
+ HealthStatus = snapshot.HealthStatus,
+ Source = sourceViewModel?.Value,
+ ExitCode = snapshot.ExitCode,
+ CreationTimestamp = snapshot.CreatedAt,
+ StartTimestamp = snapshot.StartedAt,
+ StopTimestamp = snapshot.StoppedAt,
+ DashboardUrl = dashboardUrl,
+ Urls = urls,
+ Volumes = volumes,
+ Environment = environment,
+ HealthReports = healthReports,
+ Properties = properties,
+ Relationships = relationships.ToArray(),
+ Commands = commands
+ };
+ }
+
+ ///
+ /// Gets the display name for a resource, returning the unique name if there are multiple resources
+ /// with the same display name (replicas).
+ ///
+ /// The resource to get the name for.
+ /// All resources to check for duplicates.
+ /// The display name if unique, otherwise the unique resource name.
+ public static string GetResourceName(ResourceSnapshot resource, IDictionary allResources)
+ {
+ return GetResourceName(resource, allResources.Values);
+ }
+
+ ///
+ /// Gets the display name for a resource, returning the unique name if there are multiple resources
+ /// with the same display name (replicas).
+ ///
+ /// The resource to get the name for.
+ /// All resources to check for duplicates.
+ /// The display name if unique, otherwise the unique resource name.
+ public static string GetResourceName(ResourceSnapshot resource, IEnumerable allResources)
+ {
+ var count = 0;
+ foreach (var item in allResources)
+ {
+ // Skip hidden resources
+ if (string.Equals(item.State, "Hidden", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName))
+ {
+ count++;
+ if (count >= 2)
+ {
+ // There are multiple resources with the same display name so they're part of a replica set.
+ // Need to use the name which has a unique ID to tell them apart.
+ return resource.Name;
+ }
+ }
+ }
+
+ return resource.DisplayName ?? resource.Name;
+ }
+}
diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
index e708247d33a..dae7a9eda62 100644
--- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs
+++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs
@@ -63,7 +63,7 @@ public AgentMcpCommand(
_docsIndexService = docsIndexService;
_knownTools = new Dictionary
{
- [KnownMcpTools.ListResources] = new ListResourcesTool(),
+ [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()),
[KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(),
[KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(),
[KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(),
@@ -269,21 +269,13 @@ private async ValueTask CallDashboardToolAsync(
if (connection is null)
{
_logger.LogWarning("No Aspire AppHost is currently running");
- throw new McpProtocolException(
- "No Aspire AppHost is currently running. " +
- "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " +
- "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands.",
- McpErrorCode.InternalError);
+ throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
}
if (connection.McpInfo is null)
{
_logger.LogWarning("Dashboard is not available in the running AppHost");
- throw new McpProtocolException(
- "The Aspire Dashboard is not available in the running AppHost. " +
- "The dashboard must be enabled to use MCP tools. " +
- "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration).",
- McpErrorCode.InternalError);
+ throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError);
}
_logger.LogInformation(
@@ -404,80 +396,10 @@ private async Task RefreshResourceToolMapAsync(CancellationToken cancellati
}
///
- /// Gets the appropriate AppHost connection based on the selection logic:
- /// 1. If a specific AppHost is selected via select_apphost, use that
- /// 2. Otherwise, look for in-scope connections (AppHosts within the working directory)
- /// 3. If exactly one in-scope connection exists, use it
- /// 4. If multiple in-scope connections exist, throw an error listing them
- /// 5. If no in-scope connections exist, fall back to the first available connection
+ /// Gets the appropriate AppHost connection based on the selection logic.
///
- private async Task GetSelectedConnectionAsync(CancellationToken cancellationToken)
+ private Task GetSelectedConnectionAsync(CancellationToken cancellationToken)
{
- var connections = _auxiliaryBackchannelMonitor.Connections.ToList();
-
- if (connections.Count == 0)
- {
- await _auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
- connections = _auxiliaryBackchannelMonitor.Connections.ToList();
- if (connections.Count == 0)
- {
- return null;
- }
- }
-
- // Check if a specific AppHost was selected
- var selectedPath = _auxiliaryBackchannelMonitor.SelectedAppHostPath;
- if (!string.IsNullOrEmpty(selectedPath))
- {
- var selectedConnection = connections.FirstOrDefault(c =>
- c.AppHostInfo?.AppHostPath != null &&
- string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase));
-
- if (selectedConnection != null)
- {
- _logger.LogDebug("Using explicitly selected AppHost: {AppHostPath}", selectedPath);
- return selectedConnection;
- }
-
- _logger.LogWarning("Selected AppHost at '{SelectedPath}' is no longer running, falling back to selection logic", selectedPath);
- // Clear the selection since the AppHost is no longer available
- _auxiliaryBackchannelMonitor.SelectedAppHostPath = null;
- }
-
- // Get in-scope connections
- var inScopeConnections = connections.Where(c => c.IsInScope).ToList();
-
- if (inScopeConnections.Count == 1)
- {
- _logger.LogDebug("Using single in-scope AppHost: {AppHostPath}", inScopeConnections[0].AppHostInfo?.AppHostPath ?? "N/A");
- return inScopeConnections[0];
- }
-
- if (inScopeConnections.Count > 1)
- {
- var paths = inScopeConnections
- .Where(c => c.AppHostInfo?.AppHostPath != null)
- .Select(c => c.AppHostInfo!.AppHostPath)
- .ToList();
-
- var pathsList = string.Join("\n", paths.Select(p => $" - {p}"));
-
- throw new McpProtocolException(
- $"Multiple Aspire AppHosts are running in the scope of the MCP server's working directory. " +
- $"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}",
- McpErrorCode.InternalError);
- }
-
- var fallback = connections
- .OrderBy(c => c.AppHostInfo?.AppHostPath ?? string.Empty, StringComparer.OrdinalIgnoreCase)
- .ThenBy(c => c.AppHostInfo?.ProcessId ?? int.MaxValue)
- .FirstOrDefault();
-
- _logger.LogDebug(
- "No in-scope AppHosts found for working directory {WorkingDirectory}. Falling back to first available AppHost: {AppHostPath}",
- _executionContext.WorkingDirectory,
- fallback?.AppHostInfo?.AppHostPath ?? "N/A");
-
- return fallback;
+ return AppHostConnectionHelper.GetSelectedConnectionAsync(_auxiliaryBackchannelMonitor, _logger, cancellationToken);
}
}
diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs
index 473af283d02..553c3e5ba1c 100644
--- a/src/Aspire.Cli/Commands/McpStartCommand.cs
+++ b/src/Aspire.Cli/Commands/McpStartCommand.cs
@@ -60,7 +60,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT
{
// Display deprecation warning to stderr (all MCP logging goes to stderr)
InteractionService.DisplayMarkupLine($"[yellow]⚠ {McpCommandStrings.DeprecatedCommandWarning}[/]");
-
+
// Delegate to the new AgentMcpCommand
return _agentMcpCommand.ExecuteCommandAsync(parseResult, cancellationToken);
}
diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs
index 9a8b93f7bb6..a203c5c748b 100644
--- a/src/Aspire.Cli/Commands/ResourcesCommand.cs
+++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs
@@ -32,6 +32,7 @@ internal sealed class ResourcesOutput
[JsonSerializable(typeof(ResourceHealthReportJson))]
[JsonSerializable(typeof(ResourcePropertyJson))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
+[JsonSerializable(typeof(ResourceCommandJson))]
[JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
@@ -148,8 +149,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell
private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
{
- // Get current resource snapshots using the dedicated RPC method
- var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false);
+ // Get dashboard URL and resource snapshots in parallel
+ var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
+ var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);
+
+ await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);
+
+ var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false);
+ var snapshots = await snapshotsTask.ConfigureAwait(false);
// Filter by resource name if specified
if (resourceName is not null)
@@ -164,7 +171,9 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect
return ExitCodeConstants.FailedToFindProject;
}
- var resourceList = snapshots.Select(MapToResourceJson).ToList();
+ // Use the dashboard base URL if available
+ var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
+ var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl);
if (format == OutputFormat.Json)
{
@@ -174,7 +183,7 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect
}
else
{
- DisplayResourcesTable(resourceList);
+ DisplayResourcesTable(snapshots);
}
return ExitCodeConstants.Success;
@@ -182,16 +191,26 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect
private async Task ExecuteWatchAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken)
{
+ // Get dashboard URL first for generating resource links
+ var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
+ var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
+
+ // Maintain a dictionary of all resources seen so far for relationship resolution
+ var allResources = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
// Stream resource snapshots
await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false))
{
+ // Update the dictionary with the latest snapshot for this resource
+ allResources[snapshot.Name] = snapshot;
+
// Filter by resource name if specified
if (resourceName is not null && !string.Equals(snapshot.Name, resourceName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
- var resourceJson = MapToResourceJson(snapshot);
+ var resourceJson = ResourceSnapshotMapper.MapToResourceJson(snapshot, allResources.Values.ToList(), dashboardBaseUrl);
if (format == OutputFormat.Json)
{
@@ -202,26 +221,31 @@ private async Task ExecuteWatchAsync(AppHostAuxiliaryBackchannel connection
else
{
// Human-readable update
- DisplayResourceUpdate(resourceJson);
+ DisplayResourceUpdate(snapshot, allResources);
}
}
return ExitCodeConstants.Success;
}
- private void DisplayResourcesTable(List resources)
+ private void DisplayResourcesTable(IReadOnlyList snapshots)
{
- if (resources.Count == 0)
+ if (snapshots.Count == 0)
{
_interactionService.DisplayPlainText("No resources found.");
return;
}
+ // Get display names for all resources
+ var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots)))
+ .OrderBy(x => x.DisplayName)
+ .ToList();;
+
// Calculate column widths based on data
- var nameWidth = Math.Max("NAME".Length, resources.Max(r => r.Name?.Length ?? 0));
- var typeWidth = Math.Max("TYPE".Length, resources.Max(r => r.ResourceType?.Length ?? 0));
- var stateWidth = Math.Max("STATE".Length, resources.Max(r => r.State?.Length ?? "Unknown".Length));
- var healthWidth = Math.Max("HEALTH".Length, resources.Max(r => r.HealthStatus?.Length ?? 1));
+ var nameWidth = Math.Max("NAME".Length, orderedItems.Max(i => i.DisplayName.Length));
+ var typeWidth = Math.Max("TYPE".Length, orderedItems.Max(i => i.Snapshot.ResourceType?.Length ?? 0));
+ var stateWidth = Math.Max("STATE".Length, orderedItems.Max(i => i.Snapshot.State?.Length ?? "Unknown".Length));
+ var healthWidth = Math.Max("HEALTH".Length, orderedItems.Max(i => i.Snapshot.HealthStatus?.Length ?? 1));
var totalWidth = nameWidth + typeWidth + stateWidth + healthWidth + 12 + 20; // 12 for spacing, 20 for endpoints min
@@ -230,89 +254,33 @@ private void DisplayResourcesTable(List resources)
_interactionService.DisplayPlainText($"{"NAME".PadRight(nameWidth)} {"TYPE".PadRight(typeWidth)} {"STATE".PadRight(stateWidth)} {"HEALTH".PadRight(healthWidth)} {"ENDPOINTS"}");
_interactionService.DisplayPlainText(new string('-', totalWidth));
- foreach (var resource in resources.OrderBy(r => r.Name))
+ foreach (var (snapshot, displayName) in orderedItems)
{
- var endpoints = resource.Urls?.Length > 0
- ? string.Join(", ", resource.Urls.Where(u => !u.IsInternal).Select(u => u.Url))
+ var endpoints = snapshot.Urls.Length > 0
+ ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
: "-";
- var name = resource.Name ?? "-";
- var type = resource.ResourceType ?? "-";
- var state = resource.State ?? "Unknown";
- var health = resource.HealthStatus ?? "-";
+ var type = snapshot.ResourceType ?? "-";
+ var state = snapshot.State ?? "Unknown";
+ var health = snapshot.HealthStatus ?? "-";
- _interactionService.DisplayPlainText($"{name.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}");
+ _interactionService.DisplayPlainText($"{displayName.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}");
}
_interactionService.DisplayPlainText("");
}
- private void DisplayResourceUpdate(ResourceJson resource)
+ private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary allResources)
{
- var endpoints = resource.Urls?.Length > 0
- ? string.Join(", ", resource.Urls.Where(u => !u.IsInternal).Select(u => u.Url))
+ var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, allResources);
+
+ var endpoints = snapshot.Urls.Length > 0
+ ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url))
: "";
- var health = !string.IsNullOrEmpty(resource.HealthStatus) ? $" ({resource.HealthStatus})" : "";
+ var health = !string.IsNullOrEmpty(snapshot.HealthStatus) ? $" ({snapshot.HealthStatus})" : "";
var endpointsStr = !string.IsNullOrEmpty(endpoints) ? $" - {endpoints}" : "";
- _interactionService.DisplayPlainText($"[{resource.Name}] {resource.State ?? "Unknown"}{health}{endpointsStr}");
- }
-
- private static ResourceJson MapToResourceJson(ResourceSnapshot snapshot)
- {
- return new ResourceJson
- {
- Name = snapshot.Name,
- DisplayName = snapshot.Name, // Use name as display name for now
- ResourceType = snapshot.Type,
- State = snapshot.State,
- StateStyle = snapshot.StateStyle,
- CreationTimestamp = snapshot.CreatedAt,
- StartTimestamp = snapshot.StartedAt,
- StopTimestamp = snapshot.StoppedAt,
- ExitCode = snapshot.ExitCode,
- HealthStatus = snapshot.HealthStatus,
- Urls = snapshot.Endpoints is { Length: > 0 }
- ? snapshot.Endpoints.Select(e => new ResourceUrlJson
- {
- Name = e.Name,
- Url = e.Url,
- IsInternal = e.IsInternal
- }).ToArray()
- : null,
- Volumes = snapshot.Volumes is { Length: > 0 }
- ? snapshot.Volumes.Select(v => new ResourceVolumeJson
- {
- Source = v.Source,
- Target = v.Target,
- MountType = v.MountType,
- IsReadOnly = v.IsReadOnly
- }).ToArray()
- : null,
- HealthReports = snapshot.HealthReports is { Length: > 0 }
- ? snapshot.HealthReports.Select(h => new ResourceHealthReportJson
- {
- Name = h.Name,
- Status = h.Status,
- Description = h.Description,
- ExceptionMessage = h.ExceptionText
- }).ToArray()
- : null,
- Properties = snapshot.Properties is { Count: > 0 }
- ? snapshot.Properties.Select(p => new ResourcePropertyJson
- {
- Name = p.Key,
- Value = p.Value
- }).ToArray()
- : null,
- Relationships = snapshot.Relationships is { Length: > 0 }
- ? snapshot.Relationships.Select(r => new ResourceRelationshipJson
- {
- Type = r.Type,
- ResourceName = r.ResourceName
- }).ToArray()
- : null
- };
+ _interactionService.DisplayPlainText($"[{displayName}] {snapshot.State ?? "Unknown"}{health}{endpointsStr}");
}
}
diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs
index f8fe6bf388b..3e90912de4d 100644
--- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs
+++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs
@@ -32,10 +32,10 @@ KnownMcpTools.Doctor or
KnownMcpTools.RefreshTools or
KnownMcpTools.ListDocs or
KnownMcpTools.SearchDocs or
- KnownMcpTools.GetDoc;
+ KnownMcpTools.GetDoc or
+ KnownMcpTools.ListResources;
public static bool IsDashboardTool(string toolName) => toolName is
- KnownMcpTools.ListResources or
KnownMcpTools.ListConsoleLogs or
KnownMcpTools.ExecuteResourceCommand or
KnownMcpTools.ListStructuredLogs or
diff --git a/src/Aspire.Cli/Mcp/McpErrorMessages.cs b/src/Aspire.Cli/Mcp/McpErrorMessages.cs
new file mode 100644
index 00000000000..3f722db771a
--- /dev/null
+++ b/src/Aspire.Cli/Mcp/McpErrorMessages.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Cli.Mcp;
+
+///
+/// Provides common error messages used by MCP tools.
+///
+internal static class McpErrorMessages
+{
+ ///
+ /// Error message when no Aspire AppHost is currently running.
+ ///
+ public const string NoAppHostRunning =
+ "No Aspire AppHost is currently running. " +
+ "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " +
+ "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands.";
+
+ ///
+ /// Error message when the dashboard is not available in the running AppHost.
+ ///
+ public const string DashboardNotAvailable =
+ "The Aspire Dashboard is not available in the running AppHost. " +
+ "The dashboard must be enabled to use MCP tools. " +
+ "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration).";
+}
diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
index 5c6df184196..508f5cce451 100644
--- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
+++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs
@@ -1,13 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Text.Encodings.Web;
using System.Text.Json;
+using System.Text.Json.Serialization;
+using Aspire.Cli.Backchannel;
+using Aspire.Shared.Model.Serialization;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
namespace Aspire.Cli.Mcp.Tools;
-internal sealed class ListResourcesTool : CliMcpTool
+[JsonSerializable(typeof(ResourceJson[]))]
+[JsonSerializable(typeof(ResourceUrlJson))]
+[JsonSerializable(typeof(ResourceVolumeJson))]
+[JsonSerializable(typeof(ResourceEnvironmentVariableJson))]
+[JsonSerializable(typeof(ResourceHealthReportJson))]
+[JsonSerializable(typeof(ResourcePropertyJson))]
+[JsonSerializable(typeof(ResourceRelationshipJson))]
+[JsonSerializable(typeof(ResourceCommandJson))]
+[JsonSourceGenerationOptions(
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+internal sealed partial class ListResourcesToolJsonContext : JsonSerializerContext
+{
+ private static ListResourcesToolJsonContext? s_relaxedEscaping;
+
+ ///
+ /// Gets a context with relaxed JSON escaping for non-ASCII character support (pretty-printed).
+ ///
+ public static ListResourcesToolJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ });
+}
+
+///
+/// MCP tool for listing application resources.
+/// Gets resource data directly from the AppHost backchannel instead of forwarding to the dashboard.
+///
+internal sealed class ListResourcesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger) : CliMcpTool
{
public override string Name => KnownMcpTools.ListResources;
@@ -20,22 +57,62 @@ public override JsonElement GetInputSchema()
public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken)
{
- // Convert JsonElement arguments to Dictionary
- Dictionary? convertedArgs = null;
- if (arguments != null)
+ // This tool does not use the MCP client as it operates via backchannel
+ _ = mcpClient;
+ _ = arguments;
+
+ var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false);
+ if (connection is null)
{
- convertedArgs = new Dictionary();
- foreach (var kvp in arguments)
+ logger.LogWarning("No Aspire AppHost is currently running");
+ throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError);
+ }
+
+ try
+ {
+ // Get dashboard URL and resource snapshots in parallel
+ var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken);
+ var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken);
+
+ await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false);
+
+ var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false);
+ var snapshots = await snapshotsTask.ConfigureAwait(false);
+
+ if (snapshots.Count == 0)
{
- convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value;
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "No resources found." }]
+ };
}
- }
- // Forward the call to the dashboard's MCP server
- return await mcpClient.CallToolAsync(
- Name,
- convertedArgs,
- serializerOptions: McpJsonUtilities.DefaultOptions,
- cancellationToken: cancellationToken);
+ // Use the dashboard base URL if available
+ var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken;
+ var resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl);
+ var resourceGraphData = JsonSerializer.Serialize(resources.ToArray(), ListResourcesToolJsonContext.RelaxedEscaping.ResourceJsonArray);
+
+ var response = $"""
+ resource_name is the identifier of resources.
+ environment_variables is a list of environment variables configured for the resource. Environment variable values aren't provided because they could contain sensitive information.
+ Console logs for a resource can provide more information about why a resource is not in a running state.
+
+ # RESOURCE DATA
+
+ {resourceGraphData}
+ """;
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = response }]
+ };
+ }
+ catch
+ {
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "No resources found." }]
+ };
+ }
}
}
diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json
index 2edabdd16eb..238071565f1 100644
--- a/src/Aspire.Cli/Properties/launchSettings.json
+++ b/src/Aspire.Cli/Properties/launchSettings.json
@@ -62,6 +62,14 @@
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
},
+ "get-resources": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "commandLineArgs": "resources --project ../../../../../playground/TestShop/TestShop.AppHost/TestShop.AppHost.csproj",
+ "environmentVariables": {
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
+ }
+ },
"run-singlefileapphost": {
"commandName": "Project",
"dotnetRunMessages": true,
diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj
index 6fea19731b9..13a9dd237d5 100644
--- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj
+++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj
@@ -288,6 +288,7 @@
+
@@ -302,6 +303,7 @@
+
diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs
index 0ede50f0d25..606f25ef39a 100644
--- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs
+++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs
@@ -44,9 +44,6 @@ public partial class ResourceActions : ComponentBase
[Parameter]
public required ResourceViewModel Resource { get; set; }
- [Parameter]
- public required Func GetResourceName { get; set; }
-
[Parameter]
public required int MaxHighlightedCount { get; set; }
@@ -67,7 +64,7 @@ protected override void OnParametersSet()
ResourceMenuBuilder.AddMenuItems(
_menuItems,
Resource,
- GetResourceName,
+ ResourceByName,
EventCallback.Factory.Create(this, () => OnViewDetails.InvokeAsync(_menuButton?.MenuButtonId)),
CommandSelected,
IsCommandExecuting,
diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs
index 58ca6f26577..c858271d12b 100644
--- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs
+++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs
@@ -268,7 +268,7 @@ private void UpdateResourceActionsMenu()
ResourceMenuBuilder.AddMenuItems(
_resourceActionsMenuItems,
Resource,
- FormatName,
+ ResourceByName,
EventCallback.Empty, // View details not shown since we're already in the details view
CommandSelected,
IsCommandExecuting,
diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
index 69bb7a917d9..34f7e3b3f86 100644
--- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs
@@ -505,7 +505,7 @@ private void UpdateMenuButtons()
ResourceMenuBuilder.AddMenuItems(
_resourceMenuItems,
selectedResource,
- GetResourceName,
+ _resourceByName,
EventCallback.Factory.Create(this, () =>
{
NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(resource: selectedResource.Name));
@@ -778,7 +778,7 @@ private void LoadLogsForResource(ConsoleLogsSubscription subscription)
}
}
- var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName, _showHiddenResources);
+ var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName);
var logParser = new LogParser(ConsoleColor.Black);
await foreach (var batch in logSubscription.ConfigureAwait(false))
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor
index 9697ce7d3a4..be0cd0c3569 100644
--- a/src/Aspire.Dashboard/Components/Pages/Resources.razor
+++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor
@@ -205,7 +205,6 @@
IsCommandExecuting="@((resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name))"
OnViewDetails="@((buttonId) => ShowResourceDetailsAsync(context.Resource, buttonId))"
Resource="context.Resource"
- GetResourceName="GetResourceName"
MaxHighlightedCount="_maxHighlightedCount"
ResourceByName="@_resourceByName" />
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
index 72323735beb..e7341da6899 100644
--- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
+++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs
@@ -643,7 +643,7 @@ private async Task ShowContextMenuAsync(ResourceViewModel resource, int screenWi
ResourceMenuBuilder.AddMenuItems(
_contextMenuItems,
resource,
- GetResourceName,
+ _resourceByName,
EventCallback.Factory.Create(this, () => ShowResourceDetailsAsync(resource, buttonId: null)),
EventCallback.Factory.Create(this, (command) => ExecuteResourceCommandAsync(resource, command)),
(resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name),
@@ -734,7 +734,7 @@ private async Task ClearSelectedResourceAsync(bool causedByUserAction = false)
_elementIdBeforeDetailsViewOpened = null;
}
- private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName, _showHiddenResources);
+ private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName);
private bool HasMultipleReplicas(ResourceViewModel resource)
{
diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs
index dc1bd142f9a..5b14dc3aa5d 100644
--- a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs
+++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs
@@ -84,7 +84,7 @@ public static ChatMessage CreateRecentActivityMessage()
Summarize recent traces and structured logs for all resources.
Investigate the root cause of any errors in traces or structured logs.
""";
-
+
return new(ChatRole.User, prompt);
}
diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs
index 923beaa86c4..12d5e277cab 100644
--- a/src/Aspire.Dashboard/Model/ExportHelpers.cs
+++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs
@@ -62,12 +62,12 @@ public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository t
/// Gets a resource as a JSON export result.
///
/// The resource to convert.
- /// A function to resolve the resource name for the file name.
+ /// All resources for resolving relationships and resource names.
/// A result containing the JSON representation and suggested file name.
- public static ExportResult GetResourceAsJson(ResourceViewModel resource, Func getResourceName)
+ public static ExportResult GetResourceAsJson(ResourceViewModel resource, IDictionary resourceByName)
{
- var json = TelemetryExportService.ConvertResourceToJson(resource);
- var fileName = $"{getResourceName(resource)}.json";
+ var json = TelemetryExportService.ConvertResourceToJson(resource, resourceByName.Values.ToList());
+ var fileName = $"{ResourceViewModel.GetResourceName(resource, resourceByName)}.json";
return new ExportResult(json, fileName);
}
@@ -75,12 +75,12 @@ public static ExportResult GetResourceAsJson(ResourceViewModel resource, Func
/// The resource containing environment variables.
- /// A function to resolve the resource name for the file name.
+ /// All resources for resolving resource names.
/// A result containing the .env file content and suggested file name.
- public static ExportResult GetEnvironmentVariablesAsEnvFile(ResourceViewModel resource, Func getResourceName)
+ public static ExportResult GetEnvironmentVariablesAsEnvFile(ResourceViewModel resource, IDictionary resourceByName)
{
var envContent = EnvHelpers.ConvertToEnvFormat(resource.Environment.Select(e => new KeyValuePair(e.Name, e.Value)));
- var fileName = $"{getResourceName(resource)}.env";
+ var fileName = $"{ResourceViewModel.GetResourceName(resource, resourceByName)}.env";
return new ExportResult(envContent, fileName);
}
}
diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs
index 2c19194b7ed..325386ac05e 100644
--- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs
+++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs
@@ -73,7 +73,7 @@ public ResourceMenuBuilder(
public void AddMenuItems(
List menuItems,
ResourceViewModel resource,
- Func getResourceName,
+ IDictionary resourceByName,
EventCallback onViewDetails,
EventCallback commandSelected,
Func isCommandExecuting,
@@ -99,7 +99,7 @@ public void AddMenuItems(
Icon = s_consoleLogsIcon,
OnClick = () =>
{
- _navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource)));
+ _navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName)));
return Task.CompletedTask;
}
});
@@ -111,7 +111,7 @@ public void AddMenuItems(
Icon = s_bracesIcon,
OnClick = async () =>
{
- var result = ExportHelpers.GetResourceAsJson(resource, getResourceName);
+ var result = ExportHelpers.GetResourceAsJson(resource, resourceByName);
await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
{
DialogService = _dialogService,
@@ -132,7 +132,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
Icon = s_exportEnvIcon,
OnClick = async () =>
{
- var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, getResourceName);
+ var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, resourceByName);
await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions
{
DialogService = _dialogService,
@@ -163,7 +163,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync(
});
}
- AddTelemetryMenuItems(menuItems, resource, getResourceName);
+ AddTelemetryMenuItems(menuItems, resource, resourceByName);
AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting);
@@ -230,7 +230,7 @@ private static MenuButtonItem CreateUrlMenuItem(DisplayedUrl url)
};
}
- private void AddTelemetryMenuItems(List menuItems, ResourceViewModel resource, Func getResourceName)
+ private void AddTelemetryMenuItems(List menuItems, ResourceViewModel resource, IDictionary resourceByName)
{
// Show telemetry menu items if there is telemetry for the resource.
var telemetryResource = _telemetryRepository.GetResourceByCompositeName(resource.Name);
@@ -247,7 +247,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM
Icon = s_structuredLogsIcon,
OnClick = () =>
{
- _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(resource: getResourceName(resource)));
+ _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName)));
return Task.CompletedTask;
}
});
@@ -260,7 +260,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM
Icon = s_tracesIcon,
OnClick = () =>
{
- _navigationManager.NavigateTo(DashboardUrls.TracesUrl(resource: getResourceName(resource)));
+ _navigationManager.NavigateTo(DashboardUrls.TracesUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName)));
return Task.CompletedTask;
}
});
@@ -274,7 +274,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM
Icon = s_metricsIcon,
OnClick = () =>
{
- _navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: getResourceName(resource)));
+ _navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName)));
return Task.CompletedTask;
}
});
diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
index 624fc5b5d93..7abf69f5310 100644
--- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
+++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
@@ -2,10 +2,13 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Dashboard.Utils;
+using Aspire.Shared.Model;
namespace Aspire.Dashboard.Model;
-public class ResourceSourceViewModel(string value, List? contentAfterValue, string valueToVisualize, string tooltip)
+public record LaunchArgument(string Value, bool IsShown);
+
+internal sealed record ResourceSourceViewModel(string value, List? contentAfterValue, string valueToVisualize, string tooltip)
{
public string Value { get; } = value;
public List? ContentAfterValue { get; } = contentAfterValue;
@@ -14,84 +17,70 @@ public class ResourceSourceViewModel(string value, List? content
internal static ResourceSourceViewModel? GetSourceViewModel(ResourceViewModel resource)
{
- var commandLineInfo = GetCommandLineInfo(resource);
-
- // NOTE project and tools are also executables, so check for those first
- if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath))
- {
- return CreateResourceSourceViewModel(Path.GetFileName(projectPath), projectPath, commandLineInfo);
- }
- if (resource.IsTool() && resource.TryGetToolPackage(out var toolPackage))
- {
- return CreateResourceSourceViewModel(toolPackage, toolPackage, commandLineInfo);
- }
-
- if (resource.TryGetExecutablePath(out var executablePath))
- {
- return CreateResourceSourceViewModel(Path.GetFileName(executablePath), executablePath, commandLineInfo);
- }
+ var properties = resource.GetPropertiesAsDictionary();
- if (resource.TryGetContainerImage(out var containerImage))
+ var source = ResourceSource.GetSourceModel(resource.ResourceType, properties);
+ if (source is null)
{
- return CreateResourceSourceViewModel(containerImage, containerImage, commandLineInfo);
+ return null;
}
- if (resource.Properties.TryGetValue(KnownProperties.Resource.Source, out var property) && property.Value is { HasStringValue: true, StringValue: var value })
+ var commandLineInfo = GetCommandLineInfo(resource);
+ if (commandLineInfo is null)
{
- return new ResourceSourceViewModel(value, contentAfterValue: null, valueToVisualize: value, tooltip: value);
+ return new ResourceSourceViewModel(
+ value: source.Value,
+ contentAfterValue: null,
+ valueToVisualize: source.OriginalValue,
+ tooltip: source.OriginalValue);
}
- return null;
+ return new ResourceSourceViewModel(
+ value: source.Value,
+ contentAfterValue: commandLineInfo.Arguments,
+ valueToVisualize: $"{source.OriginalValue} {commandLineInfo.ArgumentsString}",
+ tooltip: $"{source.OriginalValue} {commandLineInfo.TooltipString}");
+ }
- static CommandLineInfo? GetCommandLineInfo(ResourceViewModel resourceViewModel)
+ private static CommandLineInfo? GetCommandLineInfo(ResourceViewModel resourceViewModel)
+ {
+ // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments,
+ // which include args added by the app host
+ if (resourceViewModel.TryGetAppArgs(out var launchArguments))
{
- // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments,
- // which include args added by the app host
- if (resourceViewModel.TryGetAppArgs(out var launchArguments))
+ if (launchArguments.IsDefaultOrEmpty)
{
- if (launchArguments.IsDefaultOrEmpty)
- {
- return null;
- }
-
- var argumentsString = string.Join(" ", launchArguments);
- if (resourceViewModel.TryGetAppArgsSensitivity(out var areArgumentsSensitive))
- {
- var arguments = launchArguments
- .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i]))
- .ToList();
-
- return new CommandLineInfo(
- Arguments: arguments,
- ArgumentsString: argumentsString,
- TooltipString: string.Join(" ", arguments.Select(arg => arg.IsShown
- ? arg.Value
- : DashboardUIHelpers.GetMaskingText(6).Text)));
- }
-
- return new CommandLineInfo(Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), ArgumentsString: argumentsString, TooltipString: argumentsString);
+ return null;
}
- if (resourceViewModel.TryGetExecutableArguments(out var executableArguments) && !resourceViewModel.IsProject())
+ var argumentsString = string.Join(" ", launchArguments);
+ if (resourceViewModel.TryGetAppArgsSensitivity(out var areArgumentsSensitive))
{
- var arguments = executableArguments.IsDefaultOrEmpty ? [] : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList();
- var argumentsString = string.Join(" ", executableArguments);
-
- return new CommandLineInfo(Arguments: arguments, ArgumentsString: argumentsString, TooltipString: argumentsString);
+ var arguments = launchArguments
+ .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i]))
+ .ToList();
+
+ return new CommandLineInfo(
+ Arguments: arguments,
+ ArgumentsString: argumentsString,
+ TooltipString: string.Join(" ", arguments.Select(arg => arg.IsShown
+ ? arg.Value
+ : DashboardUIHelpers.GetMaskingText(6).Text)));
}
- return null;
+ return new CommandLineInfo(Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), ArgumentsString: argumentsString, TooltipString: argumentsString);
}
- static ResourceSourceViewModel CreateResourceSourceViewModel(string value, string path, CommandLineInfo? commandLineInfo)
+ if (resourceViewModel.TryGetExecutableArguments(out var executableArguments) && !resourceViewModel.IsProject())
{
- return commandLineInfo is not null
- ? new ResourceSourceViewModel(value: value, contentAfterValue: commandLineInfo.Arguments, valueToVisualize: $"{path} {commandLineInfo.ArgumentsString}", tooltip: $"{path} {commandLineInfo.TooltipString}")
- : new ResourceSourceViewModel(value: value, contentAfterValue: null, valueToVisualize: path, tooltip: path);
+ var arguments = executableArguments.IsDefaultOrEmpty ? [] : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList();
+ var argumentsString = string.Join(" ", executableArguments);
+
+ return new CommandLineInfo(Arguments: arguments, ArgumentsString: argumentsString, TooltipString: argumentsString);
}
+
+ return null;
}
private record CommandLineInfo(List Arguments, string ArgumentsString, string TooltipString);
}
-
-public record LaunchArgument(string Value, bool IsShown);
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
index fbe671c39ad..c9c371ae6eb 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs
@@ -154,21 +154,16 @@ public bool IsResourceHidden(bool showHiddenResources)
?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy;
}
- public static string GetResourceName(ResourceViewModel resource, IDictionary allResources, bool showHiddenResources = false)
+ public static string GetResourceName(ResourceViewModel resource, IDictionary allResources)
{
return GetResourceName(resource, allResources.Values);
}
- public static string GetResourceName(ResourceViewModel resource, IEnumerable allResources, bool showHiddenResources = false)
+ public static string GetResourceName(ResourceViewModel resource, IEnumerable allResources)
{
var count = 0;
foreach (var item in allResources)
{
- if (item.IsResourceHidden(showHiddenResources))
- {
- continue;
- }
-
if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName))
{
count++;
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
index 5e7b62992b5..6684e71f2dd 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
@@ -9,6 +9,26 @@ namespace Aspire.Dashboard.Model;
internal static class ResourceViewModelExtensions
{
+ ///
+ /// Converts the resource properties to a dictionary of string values.
+ /// This is used to provide a consistent interface for code that works with both
+ /// ResourceViewModel (Dashboard) and ResourceSnapshot (CLI).
+ ///
+ public static IReadOnlyDictionary GetPropertiesAsDictionary(this ResourceViewModel resource)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var (key, property) in resource.Properties)
+ {
+ if (property.Value.TryConvertToString(out var stringValue))
+ {
+ result[key] = stringValue;
+ }
+ }
+
+ return result;
+ }
+
public static bool IsContainer(this ResourceViewModel resource)
{
return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Container);
diff --git a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs
index 0239624a88c..ee006d8c3bf 100644
--- a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs
+++ b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs
@@ -23,6 +23,7 @@ namespace Aspire.Dashboard.Model.Serialization;
[JsonSerializable(typeof(ResourceHealthReportJson))]
[JsonSerializable(typeof(ResourcePropertyJson))]
[JsonSerializable(typeof(ResourceRelationshipJson))]
+[JsonSerializable(typeof(ResourceCommandJson))]
internal sealed partial class ResourceJsonSerializerContext : JsonSerializerContext
{
///
diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs
index dc52d23c3fc..f8f3e396a0e 100644
--- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs
+++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs
@@ -143,7 +143,7 @@ private static void ExportResources(ZipArchive archive, List
foreach (var resource in resources)
{
var resourceName = ResourceViewModel.GetResourceName(resource, resources);
- var resourceJson = ConvertResourceToJson(resource);
+ var resourceJson = ConvertResourceToJson(resource, resources);
var entry = archive.CreateEntry($"resources/{SanitizeFileName(resourceName)}.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
@@ -676,8 +676,32 @@ private static string SanitizeFileName(string name)
return sanitized.ToString();
}
- internal static string ConvertResourceToJson(ResourceViewModel resource)
+ internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOnlyList allResources)
{
+ // Build relationships by matching DisplayName and filtering out hidden resources
+ ResourceRelationshipJson[]? relationshipsJson = null;
+ if (resource.Relationships.Length > 0)
+ {
+ var relationships = new List();
+ foreach (var relationship in resource.Relationships)
+ {
+ var matches = allResources
+ .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName))
+ .Where(r => r.KnownState != KnownResourceState.Hidden)
+ .ToList();
+
+ foreach (var match in matches)
+ {
+ relationships.Add(new ResourceRelationshipJson
+ {
+ Type = relationship.Type,
+ ResourceName = ResourceViewModel.GetResourceName(match, allResources)
+ });
+ }
+ }
+ relationshipsJson = relationships.ToArray();
+ }
+
var resourceJson = new ResourceJson
{
Name = resource.Name,
@@ -707,7 +731,7 @@ internal static string ConvertResourceToJson(ResourceViewModel resource)
}).ToArray()
: null,
Environment = resource.Environment.Length > 0
- ? resource.Environment.Select(e => new ResourceEnvironmentVariableJson
+ ? resource.Environment.Where(e => e.FromSpec).Select(e => new ResourceEnvironmentVariableJson
{
Name = e.Name,
Value = e.Value
@@ -729,13 +753,17 @@ internal static string ConvertResourceToJson(ResourceViewModel resource)
Value = p.Value.Value.TryConvertToString(out var value) ? value : null
}).ToArray()
: null,
- Relationships = resource.Relationships.Length > 0
- ? resource.Relationships.Select(r => new ResourceRelationshipJson
- {
- Type = r.Type,
- ResourceName = r.ResourceName
- }).ToArray()
- : null
+ Relationships = relationshipsJson,
+ Commands = resource.Commands.Length > 0
+ ? resource.Commands
+ .Where(c => c.State == CommandViewModelState.Enabled)
+ .Select(c => new ResourceCommandJson
+ {
+ Name = c.Name,
+ Description = c.GetDisplayDescription()
+ }).ToArray()
+ : null,
+ Source = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value
};
return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedOptions);
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
index 30b49118b3a..4836d473b9a 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Aspire.Dashboard.Model;
using Microsoft.Extensions.DependencyInjection;
@@ -1061,6 +1062,24 @@ internal static string GetResolvedResourceName(this IResource resource)
return names[0];
}
+ ///
+ /// Attempts to get the DCP instances for the specified resource.
+ ///
+ /// The resource to get the DCP instances from.
+ /// When this method returns, contains the DCP instances if found and not empty; otherwise, an empty array.
+ /// if the resource has a non-empty DCP instances annotation; otherwise, .
+ internal static bool TryGetInstances(this IResource resource, out ImmutableArray instances)
+ {
+ if (resource.TryGetLastAnnotation(out var annotation) && !annotation.Instances.IsEmpty)
+ {
+ instances = annotation.Instances;
+ return true;
+ }
+
+ instances = [];
+ return false;
+ }
+
///
/// Gets resolved names for the specified resource.
/// DCP resources are given a unique suffix as part of the complete name. We want to use that value.
@@ -1068,9 +1087,9 @@ internal static string GetResolvedResourceName(this IResource resource)
///
internal static string[] GetResolvedResourceNames(this IResource resource)
{
- if (resource.TryGetLastAnnotation(out var replicaAnnotation) && !replicaAnnotation.Instances.IsEmpty)
+ if (resource.TryGetInstances(out var instances))
{
- return replicaAnnotation.Instances.Select(i => i.Name).ToArray();
+ return instances.Select(i => i.Name).ToArray();
}
else
{
diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
index c52cd8741a0..267731d0777 100644
--- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
+++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs
@@ -339,7 +339,17 @@ public async Task> GetResourceSnapshotsAsync(Cancellation
continue;
}
- if (notificationService.TryGetCurrentState(resource.Name, out var resourceEvent))
+ foreach (var instanceName in resource.GetResolvedResourceNames())
+ {
+ await AddResult(instanceName).ConfigureAwait(false);
+ }
+ }
+
+ return results;
+
+ async Task AddResult(string resourceName)
+ {
+ if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent))
{
var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
@@ -348,8 +358,6 @@ public async Task> GetResourceSnapshotsAsync(Cancellation
}
}
}
-
- return results;
}
///
@@ -406,14 +414,19 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu
}
}
- // Build endpoints from URLs
- var endpoints = snapshot.Urls
+ // Build URLs
+ var urls = snapshot.Urls
.Where(u => !u.IsInactive && !string.IsNullOrEmpty(u.Url))
- .Select(u => new ResourceSnapshotEndpoint
+ .Select(u => new ResourceSnapshotUrl
{
Name = u.Name ?? "default",
Url = u.Url,
- IsInternal = u.IsInternal
+ IsInternal = u.IsInternal,
+ DisplayProperties = new ResourceSnapshotUrlDisplayProperties
+ {
+ DisplayName = string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? null : u.DisplayProperties.DisplayName,
+ SortOrder = u.DisplayProperties.SortOrder
+ }
})
.ToArray();
@@ -448,6 +461,16 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu
})
.ToArray();
+ // Build environment variables
+ var environmentVariables = snapshot.EnvironmentVariables
+ .Select(e => new ResourceSnapshotEnvironmentVariable
+ {
+ Name = e.Name,
+ Value = e.Value,
+ IsFromSpec = e.IsFromSpec
+ })
+ .ToArray();
+
// Build properties dictionary from ResourcePropertySnapshot
// Redact sensitive property values to avoid leaking secrets
var properties = new Dictionary();
@@ -472,10 +495,22 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu
properties[prop.Name] = stringValue;
}
+ // Build commands
+ var commands = snapshot.Commands
+ .Select(c => new ResourceSnapshotCommand
+ {
+ Name = c.Name,
+ DisplayName = c.DisplayName,
+ Description = c.DisplayDescription,
+ State = c.State.ToString()
+ })
+ .ToArray();
+
return new ResourceSnapshot
{
- Name = resource.Name,
- Type = snapshot.ResourceType,
+ Name = resourceEvent.ResourceId,
+ DisplayName = resource.Name,
+ ResourceType = snapshot.ResourceType,
State = snapshot.State?.Text,
StateStyle = snapshot.State?.Style,
HealthStatus = snapshot.HealthStatus?.ToString(),
@@ -483,12 +518,14 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu
CreatedAt = snapshot.CreationTimeStamp,
StartedAt = snapshot.StartTimeStamp,
StoppedAt = snapshot.StopTimeStamp,
- Endpoints = endpoints,
+ Urls = urls,
Relationships = relationships,
HealthReports = healthReports,
Volumes = volumes,
+ EnvironmentVariables = environmentVariables,
Properties = properties,
- McpServer = mcpServer
+ McpServer = mcpServer,
+ Commands = commands
};
}
diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
index ddef85facb2..bf89a5ee320 100644
--- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
+++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs
@@ -9,6 +9,7 @@ namespace Aspire.Cli.Backchannel;
namespace Aspire.Hosting.Backchannel;
#endif
+using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
@@ -516,6 +517,7 @@ internal sealed class DashboardMcpConnectionInfo
/// Represents a snapshot of a resource in the application model, suitable for RPC communication.
/// Designed to be extensible - new fields can be added without breaking existing consumers.
///
+[DebuggerDisplay("Name = {Name}, ResourceType = {ResourceType}, State = {State}, Properties = {Properties.Count}")]
internal sealed class ResourceSnapshot
{
///
@@ -523,10 +525,26 @@ internal sealed class ResourceSnapshot
///
public required string Name { get; init; }
+ ///
+ /// Gets the display name of the resource.
+ ///
+ public string? DisplayName { get; init; }
+
+ // ResourceType can't be required because older versions of the backchannel may not set it.
///
/// Gets the type of the resource (e.g., "Project", "Container", "Executable").
///
- public required string Type { get; init; }
+ public string? ResourceType { get; init; }
+
+ ///
+ /// Gets the type of the resource (e.g., "Project", "Container", "Executable").
+ ///
+ [Obsolete("Use ResourceType property instead.")]
+ public string? Type
+ {
+ get => ResourceType;
+ init => ResourceType = value;
+ }
///
/// Gets the current state of the resource (e.g., "Running", "Stopped", "Starting").
@@ -564,9 +582,9 @@ internal sealed class ResourceSnapshot
public DateTimeOffset? StoppedAt { get; init; }
///
- /// Gets the endpoints exposed by this resource.
+ /// Gets the URLs exposed by this resource.
///
- public ResourceSnapshotEndpoint[] Endpoints { get; init; } = [];
+ public ResourceSnapshotUrl[] Urls { get; init; } = [];
///
/// Gets the relationships to other resources.
@@ -583,6 +601,11 @@ internal sealed class ResourceSnapshot
///
public ResourceSnapshotVolume[] Volumes { get; init; } = [];
+ ///
+ /// Gets the environment variables for this resource.
+ ///
+ public ResourceSnapshotEnvironmentVariable[] EnvironmentVariables { get; init; } = [];
+
///
/// Gets additional properties as key-value pairs.
/// This allows for extensibility without changing the schema.
@@ -593,15 +616,48 @@ internal sealed class ResourceSnapshot
/// Gets the MCP server information if the resource exposes an MCP endpoint.
///
public ResourceSnapshotMcpServer? McpServer { get; init; }
+
+ ///
+ /// Gets the commands available for this resource.
+ ///
+ public ResourceSnapshotCommand[] Commands { get; init; } = [];
}
///
-/// Represents an endpoint exposed by a resource.
+/// Represents a command available for a resource.
///
-internal sealed class ResourceSnapshotEndpoint
+[DebuggerDisplay("Name = {Name}, State = {State}")]
+internal sealed class ResourceSnapshotCommand
{
///
- /// Gets the endpoint name (e.g., "http", "https", "tcp").
+ /// Gets the command name (e.g., "resource-start", "resource-stop", "resource-restart").
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the display name of the command.
+ ///
+ public string? DisplayName { get; init; }
+
+ ///
+ /// Gets the description of the command.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Gets the state of the command (e.g., "Enabled", "Disabled", "Hidden").
+ ///
+ public required string State { get; init; }
+}
+
+///
+/// Represents a URL exposed by a resource.
+///
+[DebuggerDisplay("Name = {Name}, Url = {Url}")]
+internal sealed class ResourceSnapshotUrl
+{
+ ///
+ /// Gets the URL name (e.g., "http", "https", "tcp").
///
public required string Name { get; init; }
@@ -611,14 +667,37 @@ internal sealed class ResourceSnapshotEndpoint
public required string Url { get; init; }
///
- /// Gets whether this is an internal endpoint.
+ /// Gets whether this is an internal URL.
///
public bool IsInternal { get; init; }
+
+ ///
+ /// Gets the display properties for the URL.
+ ///
+ public ResourceSnapshotUrlDisplayProperties? DisplayProperties { get; init; }
+}
+
+///
+/// Represents display properties for a URL.
+///
+[DebuggerDisplay("DisplayName = {DisplayName}, SortOrder = {SortOrder}")]
+internal sealed class ResourceSnapshotUrlDisplayProperties
+{
+ ///
+ /// Gets the display name of the URL.
+ ///
+ public string? DisplayName { get; init; }
+
+ ///
+ /// Gets the sort order for display. Higher numbers are displayed first.
+ ///
+ public int SortOrder { get; init; }
}
///
/// Represents a relationship to another resource.
///
+[DebuggerDisplay("ResourceName = {ResourceName}, Type = {Type}")]
internal sealed class ResourceSnapshotRelationship
{
///
@@ -635,6 +714,7 @@ internal sealed class ResourceSnapshotRelationship
///
/// Represents a health report for a resource.
///
+[DebuggerDisplay("Name = {Name}, Status = {Status}")]
internal sealed class ResourceSnapshotHealthReport
{
///
@@ -661,6 +741,7 @@ internal sealed class ResourceSnapshotHealthReport
///
/// Represents a volume mounted to a resource.
///
+[DebuggerDisplay("Source = {Source}, Target = {Target}")]
internal sealed class ResourceSnapshotVolume
{
///
@@ -684,9 +765,32 @@ internal sealed class ResourceSnapshotVolume
public bool IsReadOnly { get; init; }
}
+///
+/// Represents an environment variable for a resource.
+///
+[DebuggerDisplay("Name = {Name}, Value = {Value}")]
+internal sealed class ResourceSnapshotEnvironmentVariable
+{
+ ///
+ /// Gets the name of the environment variable.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the value of the environment variable.
+ ///
+ public string? Value { get; init; }
+
+ ///
+ /// Gets whether this environment variable is from the resource specification.
+ ///
+ public bool IsFromSpec { get; init; }
+}
+
///
/// Represents MCP server information for a resource.
///
+[DebuggerDisplay("EndpointUrl = {EndpointUrl}")]
internal sealed class ResourceSnapshotMcpServer
{
///
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index 88a90c892c2..1d6b253359c 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -1867,12 +1867,12 @@ private void PrepareContainers()
///
private static DcpInstance GetDcpInstance(IResource resource, int instanceIndex)
{
- if (!resource.TryGetLastAnnotation(out var replicaAnnotation))
+ if (!resource.TryGetInstances(out var instances))
{
throw new DistributedApplicationException($"Couldn't find required {nameof(DcpInstancesAnnotation)} annotation on resource {resource.Name}.");
}
- foreach (var instance in replicaAnnotation.Instances)
+ foreach (var instance in instances)
{
if (instance.Index == instanceIndex)
{
diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
index d810e9e662e..bf24f738865 100644
--- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
+++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs
@@ -27,7 +27,7 @@ public DcpNameGenerator(IConfiguration configuration, IOptions optio
public void EnsureDcpInstancesPopulated(IResource resource)
{
- if (resource.TryGetLastAnnotation(out _))
+ if (resource.TryGetInstances(out _))
{
return;
}
diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs
index 03333885f8d..af4597eb1bc 100644
--- a/src/Aspire.Hosting/DistributedApplication.cs
+++ b/src/Aspire.Hosting/DistributedApplication.cs
@@ -609,18 +609,9 @@ public List Resources
var results = new List(app._model.Resources.Count);
foreach (var resource in app._model.Resources)
{
- resource.TryGetLastAnnotation(out var dcpInstancesAnnotation);
- if (dcpInstancesAnnotation is not null)
+ foreach (var instanceName in resource.GetResolvedResourceNames())
{
- foreach (var instance in dcpInstancesAnnotation.Instances)
- {
- app.ResourceNotifications.TryGetCurrentState(instance.Name, out var resourceEvent);
- results.Add(new() { Resource = resource, Snapshot = resourceEvent?.Snapshot });
- }
- }
- else
- {
- app.ResourceNotifications.TryGetCurrentState(resource.Name, out var resourceEvent);
+ app.ResourceNotifications.TryGetCurrentState(instanceName, out var resourceEvent);
results.Add(new() { Resource = resource, Snapshot = resourceEvent?.Snapshot });
}
}
diff --git a/src/Aspire.Dashboard/Utils/DashboardUrls.cs b/src/Shared/DashboardUrls.cs
similarity index 63%
rename from src/Aspire.Dashboard/Utils/DashboardUrls.cs
rename to src/Shared/DashboardUrls.cs
index 73f530a2dee..91c02259f14 100644
--- a/src/Aspire.Dashboard/Utils/DashboardUrls.cs
+++ b/src/Shared/DashboardUrls.cs
@@ -2,7 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
-using Microsoft.AspNetCore.WebUtilities;
+using System.Text.Encodings.Web;
namespace Aspire.Dashboard.Utils;
@@ -21,23 +21,23 @@ public static string ResourcesUrl(string? resource = null, string? view = null,
var url = $"/{ResourcesBasePath}";
if (resource != null)
{
- url = QueryHelpers.AddQueryString(url, "resource", resource);
+ url = AddQueryString(url, "resource", resource);
}
if (view != null)
{
- url = QueryHelpers.AddQueryString(url, "view", view);
+ url = AddQueryString(url, "view", view);
}
if (hiddenTypes != null)
{
- url = QueryHelpers.AddQueryString(url, "hiddenTypes", hiddenTypes);
+ url = AddQueryString(url, "hiddenTypes", hiddenTypes);
}
if (hiddenStates != null)
{
- url = QueryHelpers.AddQueryString(url, "hiddenStates", hiddenStates);
+ url = AddQueryString(url, "hiddenStates", hiddenStates);
}
if (hiddenHealthStates != null)
{
- url = QueryHelpers.AddQueryString(url, "hiddenHealthStates", hiddenHealthStates);
+ url = AddQueryString(url, "hiddenHealthStates", hiddenHealthStates);
}
return url;
@@ -64,19 +64,19 @@ public static string MetricsUrl(string? resource = null, string? meter = null, s
if (meter is not null)
{
// Meter and instrument must be querystring parameters because it's valid for the name to contain forward slashes.
- url = QueryHelpers.AddQueryString(url, "meter", meter);
+ url = AddQueryString(url, "meter", meter);
if (instrument is not null)
{
- url = QueryHelpers.AddQueryString(url, "instrument", instrument);
+ url = AddQueryString(url, "instrument", instrument);
}
}
if (duration != null)
{
- url = QueryHelpers.AddQueryString(url, "duration", duration.Value.ToString(CultureInfo.InvariantCulture));
+ url = AddQueryString(url, "duration", duration.Value.ToString(CultureInfo.InvariantCulture));
}
if (view != null)
{
- url = QueryHelpers.AddQueryString(url, "view", view);
+ url = AddQueryString(url, "view", view);
}
return url;
@@ -91,26 +91,26 @@ public static string StructuredLogsUrl(string? resource = null, string? logLevel
}
if (logLevel != null)
{
- url = QueryHelpers.AddQueryString(url, "logLevel", logLevel);
+ url = AddQueryString(url, "logLevel", logLevel);
}
if (filters != null)
{
// Filters contains : and + characters. These are escaped when they're not needed to,
// which makes the URL harder to read. Consider having a custom method for appending
// query string here that uses an encoder that doesn't encode those characters.
- url = QueryHelpers.AddQueryString(url, "filters", filters);
+ url = AddQueryString(url, "filters", filters);
}
if (traceId != null)
{
- url = QueryHelpers.AddQueryString(url, "traceId", traceId);
+ url = AddQueryString(url, "traceId", traceId);
}
if (spanId != null)
{
- url = QueryHelpers.AddQueryString(url, "spanId", spanId);
+ url = AddQueryString(url, "spanId", spanId);
}
if (logEntryId != null)
{
- url = QueryHelpers.AddQueryString(url, "logEntryId", logEntryId.Value.ToString(CultureInfo.InvariantCulture));
+ url = AddQueryString(url, "logEntryId", logEntryId.Value.ToString(CultureInfo.InvariantCulture));
}
return url;
@@ -125,14 +125,14 @@ public static string TracesUrl(string? resource = null, string? type = null, str
}
if (type != null)
{
- url = QueryHelpers.AddQueryString(url, "type", type);
+ url = AddQueryString(url, "type", type);
}
if (filters != null)
{
// Filters contains : and + characters. These are escaped when they're not needed to,
// which makes the URL harder to read. Consider having a custom method for appending
// query string here that uses an encoder that doesn't encode those characters.
- url = QueryHelpers.AddQueryString(url, "filters", filters);
+ url = AddQueryString(url, "filters", filters);
}
return url;
@@ -143,7 +143,7 @@ public static string TraceDetailUrl(string traceId, string? spanId = null)
var url = $"/{TracesBasePath}/detail/{Uri.EscapeDataString(traceId)}";
if (spanId != null)
{
- url = QueryHelpers.AddQueryString(url, "spanId", spanId);
+ url = AddQueryString(url, "spanId", spanId);
}
return url;
@@ -154,11 +154,11 @@ public static string LoginUrl(string? returnUrl = null, string? token = null)
var url = $"/{LoginBasePath}";
if (returnUrl != null)
{
- url = QueryHelpers.AddQueryString(url, "returnUrl", returnUrl);
+ url = AddQueryString(url, "returnUrl", returnUrl);
}
if (token != null)
{
- url = QueryHelpers.AddQueryString(url, "t", token);
+ url = AddQueryString(url, "t", token);
}
return url;
@@ -167,9 +167,35 @@ public static string LoginUrl(string? returnUrl = null, string? token = null)
public static string SetLanguageUrl(string language, string redirectUrl)
{
var url = "/api/set-language";
- url = QueryHelpers.AddQueryString(url, "language", language);
- url = QueryHelpers.AddQueryString(url, "redirectUrl", redirectUrl);
+ url = AddQueryString(url, "language", language);
+ url = AddQueryString(url, "redirectUrl", redirectUrl);
return url;
}
+
+ ///
+ /// Combines a base URL with a path.
+ ///
+ /// The base URL (e.g., "https://localhost:5000").
+ /// The path (e.g., "/?resource=myapp").
+ /// The combined URL.
+ public static string CombineUrl(string baseUrl, string path)
+ {
+ // Remove trailing slash from base URL and leading slash from path to avoid double slashes
+ var trimmedBase = baseUrl.TrimEnd('/');
+ var trimmedPath = path.TrimStart('/');
+
+ return $"{trimmedBase}/{trimmedPath}";
+ }
+
+ ///
+ /// Adds a query string parameter to a URL.
+ /// This implementation matches the behavior of QueryHelpers.AddQueryString from ASP.NET Core,
+ /// which uses UrlEncoder.Default that doesn't encode certain characters like ! and @.
+ ///
+ private static string AddQueryString(string url, string name, string value)
+ {
+ var separator = url.Contains('?') ? '&' : '?';
+ return $"{url}{separator}{UrlEncoder.Default.Encode(name)}={UrlEncoder.Default.Encode(value)}";
+ }
}
diff --git a/src/Shared/Model/ResourceSourceViewModel.cs b/src/Shared/Model/ResourceSourceViewModel.cs
new file mode 100644
index 00000000000..7b572a8e99b
--- /dev/null
+++ b/src/Shared/Model/ResourceSourceViewModel.cs
@@ -0,0 +1,47 @@
+// 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;
+
+namespace Aspire.Shared.Model;
+
+internal record ResourceSource(string Value, string OriginalValue)
+{
+ public static ResourceSource? GetSourceModel(string? resourceType, IReadOnlyDictionary properties)
+ {
+ // NOTE project and tools are also executables, so check for those first
+ if (StringComparers.ResourceType.Equals(resourceType, KnownResourceTypes.Project) &&
+ properties.TryGetValue(KnownProperties.Project.Path, out var projectPath) &&
+ !string.IsNullOrEmpty(projectPath))
+ {
+ return new ResourceSource(Path.GetFileName(projectPath), projectPath);
+ }
+
+ if (StringComparers.ResourceType.Equals(resourceType, KnownResourceTypes.Tool) &&
+ properties.TryGetValue(KnownProperties.Tool.Package, out var toolPackage) &&
+ !string.IsNullOrEmpty(toolPackage))
+ {
+ return new ResourceSource(toolPackage, toolPackage);
+ }
+
+ if (properties.TryGetValue(KnownProperties.Executable.Path, out var executablePath) &&
+ !string.IsNullOrEmpty(executablePath))
+ {
+ return new ResourceSource(Path.GetFileName(executablePath), executablePath);
+ }
+
+ if (properties.TryGetValue(KnownProperties.Container.Image, out var containerImage) &&
+ !string.IsNullOrEmpty(containerImage))
+ {
+ return new ResourceSource(containerImage, containerImage);
+ }
+
+ if (properties.TryGetValue(KnownProperties.Resource.Source, out var source) &&
+ !string.IsNullOrEmpty(source))
+ {
+ return new ResourceSource(source, source);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs
index 89247f103f0..fc59ee520fa 100644
--- a/src/Shared/Model/Serialization/ResourceJson.cs
+++ b/src/Shared/Model/Serialization/ResourceJson.cs
@@ -56,6 +56,11 @@ internal sealed class ResourceJson
///
public DateTimeOffset? StopTimestamp { get; set; }
+ ///
+ /// The source of the resource (e.g., project path, container image, executable path).
+ ///
+ public string? Source { get; set; }
+
///
/// The exit code if the resource has exited.
///
@@ -66,6 +71,11 @@ internal sealed class ResourceJson
///
public string? HealthStatus { get; set; }
+ ///
+ /// The URL to the resource in the Aspire Dashboard.
+ ///
+ public string? DashboardUrl { get; set; }
+
///
/// The URLs/endpoints associated with the resource.
///
@@ -95,6 +105,11 @@ internal sealed class ResourceJson
/// The relationships of the resource.
///
public ResourceRelationshipJson[]? Relationships { get; set; }
+
+ ///
+ /// The commands available for the resource.
+ ///
+ public ResourceCommandJson[]? Commands { get; set; }
}
///
@@ -232,3 +247,19 @@ internal sealed class ResourceRelationshipJson
///
public string? ResourceName { get; set; }
}
+
+///
+/// Represents a command in JSON format.
+///
+internal sealed class ResourceCommandJson
+{
+ ///
+ /// The name of the command.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// The description of the command.
+ ///
+ public string? Description { get; set; }
+}
diff --git a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs
index 8f3b3455115..500242982aa 100644
--- a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs
@@ -22,11 +22,13 @@ public void GetResourceAsJson_ReturnsExpectedJson()
environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false)],
relationships: [new RelationshipViewModel("dependency", "Reference")]);
+ var resourceByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource };
+
// Act
- var result = ExportHelpers.GetResourceAsJson(resource, r => r.Name);
+ var result = ExportHelpers.GetResourceAsJson(resource, resourceByName);
// Assert
- Assert.Equal("test-resource.json", result.FileName);
+ Assert.Equal("Test Resource.json", result.FileName);
Assert.NotNull(result.Content);
}
@@ -43,11 +45,13 @@ public void GetEnvironmentVariablesAsEnvFile_ReturnsExpectedResult()
new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false)
]);
+ var resourceByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource };
+
// Act
- var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, r => r.Name);
+ var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, resourceByName);
// Assert
- Assert.Equal("test-resource.env", result.FileName);
+ Assert.Equal("Test Resource.env", result.FileName);
Assert.Contains("MY_VAR=my-value", result.Content);
}
}
diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs
index 65b7b78de7e..3decb32518c 100644
--- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs
@@ -62,7 +62,7 @@ public void AddMenuItems_NoTelemetry_NoTelemetryItems()
resourceMenuBuilder.AddMenuItems(
menuItems,
resource,
- r => r.Name,
+ new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
EventCallback.Empty,
EventCallback.Empty,
(_, _) => false,
@@ -113,7 +113,7 @@ public void AddMenuItems_UninstrumentedPeer_TraceItem()
resourceMenuBuilder.AddMenuItems(
menuItems,
resource,
- r => r.Name,
+ new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
EventCallback.Empty,
EventCallback.Empty,
(_, _) => false,
@@ -164,7 +164,7 @@ public void AddMenuItems_HasTelemetry_TelemetryItems()
resourceMenuBuilder.AddMenuItems(
menuItems,
resource,
- r => r.Name,
+ new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource },
EventCallback.Empty,
EventCallback.Empty,
(_, _) => false,
diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs
index 93d5ee40955..e99812b80d2 100644
--- a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs
@@ -9,11 +9,11 @@
namespace Aspire.Dashboard.Tests.Model;
-public class ResourceSourceViewModelTests
+public sealed class ResourceSourceViewModelTests
{
[Theory]
[MemberData(nameof(ResourceSourceViewModel_ReturnsCorrectValue_TestData))]
- public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, ResourceSourceViewModel? expected)
+ public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, ExpectedData? expected)
{
var properties = new Dictionary();
AddStringProperty(KnownProperties.Executable.Path, testData.ExecutablePath);
@@ -49,9 +49,23 @@ public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, Resou
{
Assert.NotNull(actual);
Assert.Equal(expected.Value, actual.Value);
- Assert.Equal(expected.ContentAfterValue, actual.ContentAfterValue);
Assert.Equal(expected.ValueToVisualize, actual.ValueToVisualize);
Assert.Equal(expected.Tooltip, actual.Tooltip);
+
+ if (expected.ContentAfterValue is null)
+ {
+ Assert.Null(actual.ContentAfterValue);
+ }
+ else
+ {
+ Assert.NotNull(actual.ContentAfterValue);
+ Assert.Equal(expected.ContentAfterValue.Count, actual.ContentAfterValue.Count);
+ for (var i = 0; i < expected.ContentAfterValue.Count; i++)
+ {
+ Assert.Equal(expected.ContentAfterValue[i].Value, actual.ContentAfterValue[i].Value);
+ Assert.Equal(expected.ContentAfterValue[i].IsShown, actual.ContentAfterValue[i].IsShown);
+ }
+ }
}
void AddStringProperty(string propertyName, string? propertyValue)
@@ -60,9 +74,9 @@ void AddStringProperty(string propertyName, string? propertyValue)
}
}
- public static TheoryData ResourceSourceViewModel_ReturnsCorrectValue_TestData()
+ public static TheoryData ResourceSourceViewModel_ReturnsCorrectValue_TestData()
{
- var data = new TheoryData();
+ var data = new TheoryData();
// Project with app arguments
data.Add(new TestData(
@@ -74,11 +88,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: "path/to/project",
ContainerImage: null,
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "project",
- contentAfterValue: [new LaunchArgument("arg2", true)],
- valueToVisualize: "path/to/project arg2",
- tooltip: "path/to/project arg2"));
+ new ExpectedData(
+ Value: "project",
+ ContentAfterValue: [new ExpectedLaunchArgument("arg2", true)],
+ ValueToVisualize: "path/to/project arg2",
+ Tooltip: "path/to/project arg2"));
var maskingText = DashboardUIHelpers.GetMaskingText(6).Text;
// Project with app arguments, as well as a secret (format argument)
@@ -91,11 +105,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: "path/to/project",
ContainerImage: null,
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "project",
- contentAfterValue: [new LaunchArgument("arg2", true), new LaunchArgument("--key", true), new LaunchArgument("secret", false), new LaunchArgument("secret2", false), new LaunchArgument("notsecret", true)],
- valueToVisualize: "path/to/project arg2 --key secret secret2 notsecret",
- tooltip: $"path/to/project arg2 --key {maskingText} {maskingText} notsecret"));
+ new ExpectedData(
+ Value: "project",
+ ContentAfterValue: [new ExpectedLaunchArgument("arg2", true), new ExpectedLaunchArgument("--key", true), new ExpectedLaunchArgument("secret", false), new ExpectedLaunchArgument("secret2", false), new ExpectedLaunchArgument("notsecret", true)],
+ ValueToVisualize: "path/to/project arg2 --key secret secret2 notsecret",
+ Tooltip: $"path/to/project arg2 --key {maskingText} {maskingText} notsecret"));
// Project without executable arguments
data.Add(new TestData(
@@ -107,11 +121,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: "path/to/project",
ContainerImage: null,
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "project",
- contentAfterValue: null,
- valueToVisualize: "path/to/project",
- tooltip: "path/to/project"));
+ new ExpectedData(
+ Value: "project",
+ ContentAfterValue: null,
+ ValueToVisualize: "path/to/project",
+ Tooltip: "path/to/project"));
// Executable with arguments
data.Add(new TestData(
@@ -123,11 +137,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: null,
ContainerImage: null,
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "executable",
- contentAfterValue: [new LaunchArgument("arg1", true), new LaunchArgument("arg2", true)],
- valueToVisualize: "path/to/executable arg1 arg2",
- tooltip: "path/to/executable arg1 arg2"));
+ new ExpectedData(
+ Value: "executable",
+ ContentAfterValue: [new ExpectedLaunchArgument("arg1", true), new ExpectedLaunchArgument("arg2", true)],
+ ValueToVisualize: "path/to/executable arg1 arg2",
+ Tooltip: "path/to/executable arg1 arg2"));
// Container image
data.Add(new TestData(
@@ -139,11 +153,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: null,
ContainerImage: "my-container-image",
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "my-container-image",
- contentAfterValue: null,
- valueToVisualize: "my-container-image",
- tooltip: "my-container-image"));
+ new ExpectedData(
+ Value: "my-container-image",
+ ContentAfterValue: null,
+ ValueToVisualize: "my-container-image",
+ Tooltip: "my-container-image"));
// Resource source property
data.Add(new TestData(
@@ -155,11 +169,11 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: null,
ContainerImage: null,
SourceProperty: "source-value"),
- new ResourceSourceViewModel(
- value: "source-value",
- contentAfterValue: null,
- valueToVisualize: "source-value",
- tooltip: "source-value"));
+ new ExpectedData(
+ Value: "source-value",
+ ContentAfterValue: null,
+ ValueToVisualize: "source-value",
+ Tooltip: "source-value"));
// Executable path without arguments
data.Add(new TestData(
@@ -171,16 +185,16 @@ void AddStringProperty(string propertyName, string? propertyValue)
ProjectPath: null,
ContainerImage: null,
SourceProperty: null),
- new ResourceSourceViewModel(
- value: "executable",
- contentAfterValue: null,
- valueToVisualize: "path/to/executable",
- tooltip: "path/to/executable"));
+ new ExpectedData(
+ Value: "executable",
+ ContentAfterValue: null,
+ ValueToVisualize: "path/to/executable",
+ Tooltip: "path/to/executable"));
return data;
}
- public record TestData(
+ public sealed record TestData(
string ResourceType,
string? ExecutablePath,
string[]? ExecutableArguments,
@@ -189,4 +203,12 @@ public record TestData(
string? ProjectPath,
string? ContainerImage,
string? SourceProperty);
+
+ public sealed record ExpectedLaunchArgument(string Value, bool IsShown);
+
+ public sealed record ExpectedData(
+ string Value,
+ List? ContentAfterValue,
+ string ValueToVisualize,
+ string Tooltip);
}
diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs
index ee2d8906dac..e3f7c282a81 100644
--- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs
+++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs
@@ -1039,17 +1039,25 @@ private static void AddTestData(TelemetryRepository repository, string resourceN
public void ConvertResourceToJson_ReturnsExpectedJson()
{
// Arrange
+ var dependencyResource = ModelTestHelpers.CreateResource(
+ resourceName: "dependency-resource",
+ displayName: "dependency",
+ resourceType: "Container",
+ state: KnownResourceState.Running);
+
var resource = ModelTestHelpers.CreateResource(
resourceName: "test-resource",
displayName: "Test Resource",
resourceType: "Container",
state: KnownResourceState.Running,
urls: [new UrlViewModel("http", new Uri("http://localhost:5000"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)],
- environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false)],
+ environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: true)],
relationships: [new RelationshipViewModel("dependency", "Reference")]);
+ var allResources = new[] { resource, dependencyResource };
+
// Act
- var json = TelemetryExportService.ConvertResourceToJson(resource);
+ var json = TelemetryExportService.ConvertResourceToJson(resource, allResources);
// Assert
var deserialized = JsonSerializer.Deserialize(json, ResourceJsonSerializerContext.Default.ResourceJson);
@@ -1067,12 +1075,43 @@ public void ConvertResourceToJson_ReturnsExpectedJson()
Assert.Single(deserialized.Environment);
Assert.Equal("MY_VAR", deserialized.Environment[0].Name);
+ // Relationships are resolved by matching DisplayName. Since there's only one resource
+ // with that display name (not a replica), the display name is used as the resource name.
Assert.NotNull(deserialized.Relationships);
Assert.Single(deserialized.Relationships);
Assert.Equal("dependency", deserialized.Relationships[0].ResourceName);
Assert.Equal("Reference", deserialized.Relationships[0].Type);
}
+ [Fact]
+ public void ConvertResourceToJson_OnlyIncludesFromSpecEnvironmentVariables()
+ {
+ // Arrange
+ var resource = ModelTestHelpers.CreateResource(
+ resourceName: "test-resource",
+ displayName: "Test Resource",
+ resourceType: "Container",
+ state: KnownResourceState.Running,
+ environment:
+ [
+ new EnvironmentVariableViewModel("FROM_SPEC_VAR", "spec-value", fromSpec: true),
+ new EnvironmentVariableViewModel("NOT_FROM_SPEC_VAR", "other-value", fromSpec: false),
+ new EnvironmentVariableViewModel("ANOTHER_SPEC_VAR", "another-spec-value", fromSpec: true)
+ ]);
+
+ // Act
+ var json = TelemetryExportService.ConvertResourceToJson(resource, [resource]);
+
+ // Assert
+ var deserialized = JsonSerializer.Deserialize(json, ResourceJsonSerializerContext.Default.ResourceJson);
+ Assert.NotNull(deserialized);
+ Assert.NotNull(deserialized.Environment);
+ Assert.Equal(2, deserialized.Environment.Length);
+ Assert.Contains(deserialized.Environment, e => e.Name == "FROM_SPEC_VAR" && e.Value == "spec-value");
+ Assert.Contains(deserialized.Environment, e => e.Name == "ANOTHER_SPEC_VAR" && e.Value == "another-spec-value");
+ Assert.DoesNotContain(deserialized.Environment, e => e.Name == "NOT_FROM_SPEC_VAR");
+ }
+
[Fact]
public void ConvertResourceToJson_NonAsciiContent_IsNotEscaped()
{
@@ -1086,10 +1125,10 @@ public void ConvertResourceToJson_NonAsciiContent_IsNotEscaped()
displayName: japaneseDisplayName,
resourceType: "Container",
state: KnownResourceState.Running,
- environment: [new EnvironmentVariableViewModel("JAPANESE_VAR", japaneseEnvValue, fromSpec: false)]);
+ environment: [new EnvironmentVariableViewModel("JAPANESE_VAR", japaneseEnvValue, fromSpec: true)]);
// Act
- var json = TelemetryExportService.ConvertResourceToJson(resource);
+ var json = TelemetryExportService.ConvertResourceToJson(resource, [resource]);
// Assert - Verify Japanese characters appear directly in JSON (not Unicode-escaped)
Assert.Contains(japaneseName, json);
diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs
new file mode 100644
index 00000000000..ac59779d551
--- /dev/null
+++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs
@@ -0,0 +1,200 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.Tests.Utils;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.Backchannel;
+
+public class AuxiliaryBackchannelRpcTargetTests(ITestOutputHelper outputHelper)
+{
+ [Fact]
+ public async Task GetResourceSnapshotsAsync_ReturnsEmptyList_WhenAppModelIsNull()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(ResourceNotificationServiceTestHelpers.Create());
+ var serviceProvider = services.BuildServiceProvider();
+
+ var target = new AuxiliaryBackchannelRpcTarget(
+ NullLogger.Instance,
+ serviceProvider);
+
+ var result = await target.GetResourceSnapshotsAsync();
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task GetResourceSnapshotsAsync_EnumeratesResources()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(outputHelper);
+
+ builder.AddParameter("myparam");
+ builder.AddResource(new CustomResource(KnownResourceNames.AspireDashboard));
+
+ var resourceWithReplicas = builder.AddResource(new CustomResource("myresource"));
+ resourceWithReplicas.WithAnnotation(new DcpInstancesAnnotation([
+ new DcpInstance("myresource-abc123", "abc123", 0),
+ new DcpInstance("myresource-def456", "def456", 1)
+ ]));
+
+ using var app = builder.Build();
+ await app.StartAsync();
+
+ var notificationService = app.Services.GetRequiredService();
+ await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with
+ {
+ State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success)
+ });
+ await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-def456", s => s with
+ {
+ State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success)
+ });
+
+ var target = new AuxiliaryBackchannelRpcTarget(
+ NullLogger.Instance,
+ app.Services);
+
+ var result = await target.GetResourceSnapshotsAsync();
+
+ // Dashboard resource should be skipped
+ Assert.DoesNotContain(result, r => r.Name == KnownResourceNames.AspireDashboard);
+
+ // Parameter resource (no replicas) should be returned with matching Name/DisplayName
+ var paramSnapshot = Assert.Single(result, r => r.Name == "myparam");
+ Assert.Equal("myparam", paramSnapshot.DisplayName);
+ Assert.Equal("Parameter", paramSnapshot.ResourceType);
+
+ // Resource with DcpInstancesAnnotation should return multiple instances
+ Assert.Contains(result, r => r.Name == "myresource-abc123");
+ Assert.Contains(result, r => r.Name == "myresource-def456");
+ Assert.All(result.Where(r => r.Name.StartsWith("myresource-")), r => Assert.Equal("myresource", r.DisplayName));
+
+ await app.StopAsync();
+ }
+
+ [Fact]
+ public async Task GetResourceSnapshotsAsync_MapsSnapshotData()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(outputHelper);
+
+ var custom = builder.AddResource(new CustomResource("myresource"));
+
+ using var app = builder.Build();
+ await app.StartAsync();
+
+ var createdAt = DateTime.UtcNow.AddMinutes(-5);
+ var startedAt = DateTime.UtcNow.AddMinutes(-4);
+
+ var notificationService = app.Services.GetRequiredService();
+ await notificationService.PublishUpdateAsync(custom.Resource, s => s with
+ {
+ State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success),
+ CreationTimeStamp = createdAt,
+ StartTimeStamp = startedAt,
+ Urls = [
+ new UrlSnapshot("http", "http://localhost:5000", false) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTP Endpoint", 1) },
+ new UrlSnapshot("https", "https://localhost:5001", true) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTPS Endpoint", 2) },
+ new UrlSnapshot("inactive", "http://localhost:5002", false) { IsInactive = true }
+ ],
+ Relationships = [
+ new RelationshipSnapshot("dependency1", "Reference"),
+ new RelationshipSnapshot("dependency2", "WaitFor")
+ ],
+ HealthReports = [
+ new HealthReportSnapshot("check1", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, "All good", null),
+ new HealthReportSnapshot("check2", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, "Failed", "Exception occurred")
+ ],
+ Volumes = [
+ new VolumeSnapshot("/host/path", "/container/path", "bind", false),
+ new VolumeSnapshot("myvolume", "/data", "volume", true)
+ ],
+ EnvironmentVariables = [
+ new EnvironmentVariableSnapshot("MY_VAR", "my-value", false),
+ new EnvironmentVariableSnapshot("ANOTHER_VAR", "another-value", true)
+ ],
+ Commands = [
+ new ResourceCommandSnapshot("resource-start", ResourceCommandState.Enabled, "Start", "Start the resource", null, null, null, null, false),
+ new ResourceCommandSnapshot("resource-stop", ResourceCommandState.Disabled, "Stop", "Stop the resource", null, null, null, null, false),
+ new ResourceCommandSnapshot("resource-restart", ResourceCommandState.Hidden, "Restart", null, null, null, null, null, true)
+ ],
+ Properties = [
+ new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, "normal-value"),
+ new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true }
+ ]
+ });
+
+ var target = new AuxiliaryBackchannelRpcTarget(
+ NullLogger.Instance,
+ app.Services);
+
+ var result = await target.GetResourceSnapshotsAsync();
+
+ var snapshot = Assert.Single(result);
+
+ // State
+ Assert.Equal("Running", snapshot.State);
+ Assert.Equal(KnownResourceStateStyles.Success, snapshot.StateStyle);
+
+ // Timestamps
+ Assert.Equal(createdAt, snapshot.CreatedAt);
+ Assert.Equal(startedAt, snapshot.StartedAt);
+
+ // URLs (inactive URLs should be excluded)
+ Assert.Equal(2, snapshot.Urls.Length);
+ Assert.Contains(snapshot.Urls, u => u.Name == "http" && u.Url == "http://localhost:5000" && !u.IsInternal);
+ Assert.Contains(snapshot.Urls, u => u.Name == "https" && u.Url == "https://localhost:5001" && u.IsInternal);
+ Assert.DoesNotContain(snapshot.Urls, u => u.Name == "inactive");
+
+ // URL display properties
+ var httpUrl = snapshot.Urls.Single(u => u.Name == "http");
+ Assert.NotNull(httpUrl.DisplayProperties);
+ Assert.Equal("HTTP Endpoint", httpUrl.DisplayProperties.DisplayName);
+ Assert.Equal(1, httpUrl.DisplayProperties.SortOrder);
+
+ var httpsUrl = snapshot.Urls.Single(u => u.Name == "https");
+ Assert.NotNull(httpsUrl.DisplayProperties);
+ Assert.Equal("HTTPS Endpoint", httpsUrl.DisplayProperties.DisplayName);
+ Assert.Equal(2, httpsUrl.DisplayProperties.SortOrder);
+
+ // Relationships
+ Assert.Equal(2, snapshot.Relationships.Length);
+ Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency1" && r.Type == "Reference");
+ Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency2" && r.Type == "WaitFor");
+
+ // Health reports
+ Assert.Equal(2, snapshot.HealthReports.Length);
+ Assert.Contains(snapshot.HealthReports, h => h.Name == "check1" && h.Status == "Healthy");
+ Assert.Contains(snapshot.HealthReports, h => h.Name == "check2" && h.Status == "Unhealthy" && h.ExceptionText == "Exception occurred");
+
+ // Volumes
+ Assert.Equal(2, snapshot.Volumes.Length);
+ Assert.Contains(snapshot.Volumes, v => v.Source == "/host/path" && v.Target == "/container/path" && !v.IsReadOnly);
+ Assert.Contains(snapshot.Volumes, v => v.Source == "myvolume" && v.Target == "/data" && v.IsReadOnly);
+
+ // Environment variables
+ Assert.Equal(2, snapshot.EnvironmentVariables.Length);
+ Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "MY_VAR" && e.Value == "my-value" && !e.IsFromSpec);
+ Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "ANOTHER_VAR" && e.Value == "another-value" && e.IsFromSpec);
+
+ // Commands
+ Assert.Equal(3, snapshot.Commands.Length);
+ Assert.Contains(snapshot.Commands, c => c.Name == "resource-start" && c.DisplayName == "Start" && c.Description == "Start the resource" && c.State == "Enabled");
+ Assert.Contains(snapshot.Commands, c => c.Name == "resource-stop" && c.DisplayName == "Stop" && c.Description == "Stop the resource" && c.State == "Disabled");
+ Assert.Contains(snapshot.Commands, c => c.Name == "resource-restart" && c.DisplayName == "Restart" && c.Description == null && c.State == "Hidden");
+
+ // Properties (sensitive values should be redacted)
+ Assert.True(snapshot.Properties.TryGetValue(CustomResourceKnownProperties.Source, out var normalValue));
+ Assert.Equal("normal-value", normalValue);
+ Assert.True(snapshot.Properties.TryGetValue("ConnectionString", out var sensitiveValue));
+ Assert.Null(sensitiveValue);
+
+ await app.StopAsync();
+ }
+
+ private sealed class CustomResource(string name) : Resource(name)
+ {
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs
index 6a57d919326..4c33cd5861b 100644
--- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs
+++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs
@@ -30,10 +30,12 @@ public class BackchannelContractTests
typeof(StopAppHostRequest),
typeof(StopAppHostResponse),
typeof(ResourceSnapshot),
- typeof(ResourceSnapshotEndpoint),
+ typeof(ResourceSnapshotUrl),
+ typeof(ResourceSnapshotUrlDisplayProperties),
typeof(ResourceSnapshotRelationship),
typeof(ResourceSnapshotHealthReport),
typeof(ResourceSnapshotVolume),
+ typeof(ResourceSnapshotEnvironmentVariable),
typeof(ResourceSnapshotMcpServer),
typeof(ResourceLogLine),
];
diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs
index 2a612a4bdd3..1f027781538 100644
--- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs
@@ -509,4 +509,74 @@ private sealed class AnotherDummyAnnotation : IResourceAnnotation
private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles
{
}
+
+ [Theory]
+ [InlineData(false)] // No annotation
+ [InlineData(true)] // Empty annotation
+ public void TryGetInstances_ReturnsFalse_WhenNoInstances(bool addEmptyAnnotation)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddResource(new ParentResource("test"));
+
+ if (addEmptyAnnotation)
+ {
+ resource.WithAnnotation(new DcpInstancesAnnotation([]));
+ }
+
+ var result = resource.Resource.TryGetInstances(out var instances);
+
+ Assert.False(result);
+ Assert.Empty(instances);
+ }
+
+ [Fact]
+ public void TryGetInstances_ReturnsTrue_WhenAnnotationHasInstances()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddResource(new ParentResource("test"))
+ .WithAnnotation(new DcpInstancesAnnotation([
+ new DcpInstance("test-abc123", "abc123", 0),
+ new DcpInstance("test-def456", "def456", 1)
+ ]));
+
+ var result = resource.Resource.TryGetInstances(out var instances);
+
+ Assert.True(result);
+ Assert.Equal(2, instances.Length);
+ Assert.Equal("test-abc123", instances[0].Name);
+ Assert.Equal("test-def456", instances[1].Name);
+ }
+
+ [Theory]
+ [InlineData(false)] // No annotation
+ [InlineData(true)] // Empty annotation
+ public void GetResolvedResourceNames_ReturnsResourceName_WhenNoInstances(bool addEmptyAnnotation)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddResource(new ParentResource("test"));
+
+ if (addEmptyAnnotation)
+ {
+ resource.WithAnnotation(new DcpInstancesAnnotation([]));
+ }
+
+ var result = resource.Resource.GetResolvedResourceNames();
+
+ Assert.Equal(["test"], result);
+ }
+
+ [Fact]
+ public void GetResolvedResourceNames_ReturnsInstanceNames_WhenAnnotationHasInstances()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddResource(new ParentResource("test"))
+ .WithAnnotation(new DcpInstancesAnnotation([
+ new DcpInstance("test-abc123", "abc123", 0),
+ new DcpInstance("test-def456", "def456", 1)
+ ]));
+
+ var result = resource.Resource.GetResolvedResourceNames();
+
+ Assert.Equal(["test-abc123", "test-def456"], result);
+ }
}