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); + } }