diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index b1dfa951ae3..530e5e207b1 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -256,7 +256,7 @@ await rpc.InvokeWithCancellationAsync( } /// - public async Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) + public async Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -269,10 +269,13 @@ public async Task> GetResourceSnapshotsAsync(Cancellation [], cancellationToken).ConfigureAwait(false) ?? []; - // Sort resources by name for consistent ordering. - snapshots.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + if (!includeHidden) + { + snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList(); + } - return snapshots; + // Sort resources by name for consistent ordering. + return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); } catch (RemoteMethodNotFoundException ex) { @@ -282,7 +285,7 @@ public async Task> GetResourceSnapshotsAsync(Cancellation } /// - public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -309,6 +312,11 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu await foreach (var snapshot in snapshots.WithCancellation(cancellationToken).ConfigureAwait(false)) { + if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot)) + { + continue; + } + yield return snapshot; } } @@ -463,7 +471,7 @@ public async Task GetResourcesV2Async(GetResourcesRequest? if (!SupportsV2) { // Fall back to v1 - var snapshots = await GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var snapshots = await GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false); // Apply filter if specified if (!string.IsNullOrEmpty(request?.Filter)) @@ -503,7 +511,7 @@ public async IAsyncEnumerable WatchResourcesV2Async( { // Fall back to v1 var filter = request?.Filter; - await foreach (var snapshot in WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var snapshot in WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false)) { if (!string.IsNullOrEmpty(filter) && !snapshot.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs index b97adaf08fe..3e48ff9d251 100644 --- a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -51,16 +51,18 @@ internal interface IAppHostAuxiliaryBackchannel : IDisposable /// /// Gets the current resource snapshots from the AppHost. /// + /// When , includes resources with hidden state. /// Cancellation token. /// A list of resource snapshots representing current state. - Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default); + Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default); /// /// Watches for resource snapshot changes and streams them from the AppHost. /// + /// When , includes resources with hidden state. /// Cancellation token. /// An async enumerable of resource snapshots as they change. - IAsyncEnumerable WatchResourceSnapshotsAsync(CancellationToken cancellationToken = default); + IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default); /// /// Gets resource log lines from the AppHost. diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index 9fd0cb212ae..58739420189 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -221,4 +221,42 @@ public static string GetResourceName(ResourceSnapshot resource, IEnumerable + /// Determines whether a resource snapshot represents a hidden resource. + /// A resource is hidden if its flag is set + /// or its is "Hidden". + /// + internal static bool IsHiddenResource(ResourceSnapshot snapshot) + { + return snapshot.IsHidden || string.Equals(snapshot.State, "Hidden", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Filters a list of all resource snapshots based on hidden-resource visibility, + /// returning the visible snapshot list and a set of hidden resource names for log filtering. + /// When is or + /// is specified, all resources are included and the hidden set is empty. + /// + /// All resource snapshots (including hidden). + /// Whether the user explicitly requested hidden resources. + /// The specific resource name requested, or for all. + /// The effective include-hidden flag, the filtered snapshot list, and the set of hidden resource names. + internal static (bool EffectiveIncludeHidden, List Snapshots, HashSet HiddenResourceNames) FilterHiddenResources( + IReadOnlyList allSnapshots, + bool includeHidden, + string? resourceName) + { + var effectiveIncludeHidden = includeHidden || resourceName is not null; + + var hiddenResourceNames = effectiveIncludeHidden + ? new HashSet(StringComparers.ResourceName) + : new HashSet(allSnapshots.Where(IsHiddenResource).Select(s => s.Name), StringComparers.ResourceName); + + var snapshots = effectiveIncludeHidden + ? allSnapshots.ToList() + : allSnapshots.Where(s => !IsHiddenResource(s)).ToList(); + + return (effectiveIncludeHidden, snapshots, hiddenResourceNames); + } } diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotWatcher.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotWatcher.cs new file mode 100644 index 00000000000..cc8003b3d40 --- /dev/null +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotWatcher.cs @@ -0,0 +1,132 @@ +// 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.Concurrent; + +namespace Aspire.Cli.Backchannel; + +/// +/// Watches for resource snapshot changes from an AppHost backchannel connection +/// and maintains an up-to-date collection of resources. +/// +internal sealed class ResourceSnapshotWatcher : IDisposable +{ + private readonly IAppHostAuxiliaryBackchannel _connection; + private readonly ConcurrentDictionary _resources = new(StringComparers.ResourceName); + private readonly CancellationTokenSource _cts = new(); + private readonly TaskCompletionSource _initialLoadTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _watchTask; + private volatile Exception? _watchException; + + public ResourceSnapshotWatcher(IAppHostAuxiliaryBackchannel connection, bool includeHidden = false) + { + _connection = connection; + IncludeHidden = includeHidden; + _watchTask = WatchAsync(_cts.Token); + } + + /// + /// Gets a value indicating whether hidden resources are included by default in . + /// + public bool IncludeHidden { get; } + + /// + /// Waits until the initial resource snapshot load is complete. + /// + public Task WaitForInitialLoadAsync(CancellationToken cancellationToken = default) + { + return _initialLoadTcs.Task.WaitAsync(cancellationToken); + } + + private async Task WatchAsync(CancellationToken cancellationToken) + { + try + { + var snapshots = await _connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false); + + foreach (var snapshot in snapshots) + { + _resources[snapshot.Name] = snapshot; + } + + _initialLoadTcs.TrySetResult(); + + await foreach (var snapshot in _connection.WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false)) + { + _resources[snapshot.Name] = snapshot; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _initialLoadTcs.TrySetCanceled(cancellationToken); + } + catch (Exception ex) + { + if (!_initialLoadTcs.TrySetException(ex)) + { + // Initial load already completed; store for callers to detect. + _watchException = ex; + } + } + } + + /// + /// Gets the exception that terminated the watch loop after the initial load, or if the watch is still running. + /// + public Exception? WatchException => _watchException; + + private void EnsureInitialLoadComplete() + { + if (!_initialLoadTcs.Task.IsCompletedSuccessfully) + { + throw new InvalidOperationException("Initial resource snapshot load has not completed. Call WaitForInitialLoadAsync first."); + } + } + + /// + /// Gets a resource snapshot by name, or if not found. + /// + public ResourceSnapshot? GetResource(string name) + { + EnsureInitialLoadComplete(); + return _resources.GetValueOrDefault(name); + } + + /// + /// Gets all current resource snapshots, using to determine visibility. + /// + /// Resource snapshots, ordered by name. + public IEnumerable GetResources() + { + return GetResources(IncludeHidden); + } + + /// + /// Gets all current resource snapshots, including hidden resources. + /// + /// All resource snapshots, ordered by name. + public IEnumerable GetAllResources() + { + return GetResources(includeHidden: true); + } + + private IEnumerable GetResources(bool includeHidden) + { + EnsureInitialLoadComplete(); + + var snapshots = _resources.Values.AsEnumerable(); + + if (!includeHidden) + { + snapshots = snapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)); + } + + return snapshots.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase); + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 564db60ea06..056fdc5f32a 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -89,6 +89,10 @@ internal sealed class DescribeCommand : BaseCommand { Description = DescribeCommandStrings.JsonOptionDescription }; + private static readonly Option s_includeHiddenOption = new("--include-hidden") + { + Description = DescribeCommandStrings.IncludeHiddenOptionDescription + }; public DescribeCommand( IInteractionService interactionService, @@ -110,6 +114,7 @@ public DescribeCommand( Options.Add(s_appHostOption); Options.Add(s_followOption); Options.Add(s_formatOption); + Options.Add(s_includeHiddenOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -120,6 +125,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var follow = parseResult.GetValue(s_followOption); var format = parseResult.GetValue(s_formatOption); + var includeHidden = parseResult.GetValue(s_includeHiddenOption); var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, @@ -137,27 +143,28 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var connection = result.Connection!; - // Get dashboard URL and resource snapshots in parallel before - // dispatching to the snapshot or watch path. + // Get dashboard URL while the watcher loads initial snapshots. + // When a specific resource is requested, always include hidden resources + // so the user can describe any resource by name. + var effectiveIncludeHidden = includeHidden || resourceName is not null; var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); - var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); - - await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); + using var resourceWatcher = new ResourceSnapshotWatcher(connection, effectiveIncludeHidden); + await resourceWatcher.WaitForInitialLoadAsync(cancellationToken).ConfigureAwait(false); var dashboardBaseUrl = TelemetryCommandHelpers.ExtractDashboardBaseUrl((await dashboardUrlsTask.ConfigureAwait(false))?.BaseUrlWithLoginToken); - var snapshots = await snapshotsTask.ConfigureAwait(false); // Pre-resolve colors for all resource names so that assignment is // deterministic regardless of which resources are displayed. - _resourceColorMap.ResolveAll(snapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, snapshots))); + var allSnapshots = resourceWatcher.GetAllResources(); + _resourceColorMap.ResolveAll(allSnapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, allSnapshots))); if (follow) { - return await ExecuteWatchAsync(connection, snapshots, dashboardBaseUrl, resourceName, format, cancellationToken); + return await ExecuteWatchAsync(connection, resourceWatcher, dashboardBaseUrl, resourceName, format, cancellationToken); } else { - return ExecuteSnapshot(snapshots, dashboardBaseUrl, resourceName, format); + return ExecuteSnapshot(resourceWatcher.GetResources().ToList(), dashboardBaseUrl, resourceName, format); } } @@ -193,28 +200,23 @@ private int ExecuteSnapshot(IReadOnlyList snapshots, string? d return ExitCodeConstants.Success; } - private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, IReadOnlyList initialSnapshots, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken) + private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, ResourceSnapshotWatcher resourceWatcher, string? dashboardBaseUrl, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { - // Maintain a dictionary of the current state per resource for relationship resolution - // and display name deduplication. Keyed by snapshot.Name so each resource has exactly - // one entry representing its latest state. - var allResources = new Dictionary(StringComparers.ResourceName); - foreach (var snapshot in initialSnapshots) - { - allResources[snapshot.Name] = snapshot; - } - // Cache the last displayed content per resource to avoid duplicate output. // Values are either a string (JSON mode) or a ResourceDisplayState (non-JSON mode). var lastDisplayedContent = new Dictionary(StringComparers.ResourceName); - // Stream resource snapshots - await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) + // Stream resource snapshots. The watcher keeps its dictionary up to date in the + // background, so we use it for relationship resolution and display name deduplication. + await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false)) { - // Update the dictionary with the latest state for this resource - allResources[snapshot.Name] = snapshot; + // Skip hidden resources when not included + if (!resourceWatcher.IncludeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot)) + { + continue; + } - var currentSnapshots = allResources.Values.ToList(); + var currentSnapshots = resourceWatcher.GetAllResources().ToList(); // Filter by resource name if specified if (resourceName is not null) diff --git a/src/Aspire.Cli/Commands/ExportCommand.cs b/src/Aspire.Cli/Commands/ExportCommand.cs index e635f4b4a22..9da2c7d83a8 100644 --- a/src/Aspire.Cli/Commands/ExportCommand.cs +++ b/src/Aspire.Cli/Commands/ExportCommand.cs @@ -41,6 +41,11 @@ internal sealed class ExportCommand : BaseCommand private static readonly Option s_dashboardUrlOption = TelemetryCommandHelpers.CreateDashboardUrlOption(); private static readonly Option s_apiKeyOption = TelemetryCommandHelpers.CreateApiKeyOption(); + private static readonly Option s_includeHiddenOption = new("--include-hidden") + { + Description = ExportCommandStrings.IncludeHiddenOptionDescription + }; + private static readonly Argument s_resourceArgument = new("resource") { Description = ExportCommandStrings.ResourceOptionDescription, @@ -70,6 +75,7 @@ public ExportCommand( Options.Add(s_outputOption); Options.Add(s_dashboardUrlOption); Options.Add(s_apiKeyOption); + Options.Add(s_includeHiddenOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -81,6 +87,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var outputPath = parseResult.GetValue(s_outputOption); var dashboardUrl = parseResult.GetValue(s_dashboardUrlOption); var apiKey = parseResult.GetValue(s_apiKeyOption); + var includeHidden = parseResult.GetValue(s_includeHiddenOption); // Validate mutual exclusivity of --apphost and --dashboard-url if (passedAppHostProjectFile is not null && dashboardUrl is not null) @@ -118,7 +125,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell try { - return await ExportDataAsync(resourceName, dashboardApi.Connection, dashboardApi.BaseUrl, dashboardApi.ApiToken, outputPath, cancellationToken); + return await ExportDataAsync(resourceName, includeHidden, dashboardApi.Connection, dashboardApi.BaseUrl, dashboardApi.ApiToken, outputPath, cancellationToken); } catch (HttpRequestException ex) when (dashboardUrl is not null) { @@ -137,6 +144,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell private async Task ExportDataAsync( string? resourceName, + bool includeHidden, IAppHostAuxiliaryBackchannel? connection, string? baseUrl, string? apiToken, @@ -147,18 +155,23 @@ private async Task ExportDataAsync( using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null; + // Always fetch all snapshots so we know which resources are hidden. + // Get telemetry resources and resource snapshots - var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => + var (telemetryResources, allSnapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () => { var resources = isDashboardAvailable ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false) : []; IReadOnlyList snaps = connection is not null - ? await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false) + ? await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken).ConfigureAwait(false) : []; return (resources, snaps); }); + // Filter hidden resources, deriving the visible list and the hidden set for log filtering. + var (_, snapshots, hiddenResourceNames) = ResourceSnapshotMapper.FilterHiddenResources(allSnapshots, includeHidden, resourceName); + // Validate resource name exists (match by Name or DisplayName since users may pass either) if (resourceName is not null && snapshots.Count > 0) { @@ -197,7 +210,7 @@ private async Task ExportDataAsync( { await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringConsoleLogs, async () => { - await AddConsoleLogsAsync(exportArchive, connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); + await AddConsoleLogsAsync(exportArchive, connection, resourceName, snapshots, hiddenResourceNames, cancellationToken).ConfigureAwait(false); return true; }); } @@ -250,12 +263,18 @@ private static async Task AddConsoleLogsAsync( IAppHostAuxiliaryBackchannel connection, string? resourceName, IReadOnlyList snapshots, + HashSet hiddenResourceNames, CancellationToken cancellationToken) { var logLinesByResource = new Dictionary>(); await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false)) { + // When exporting all resources, skip logs from hidden resources + if (resourceName is null && hiddenResourceNames.Contains(logLine.ResourceName)) + { + continue; + } if (!logLinesByResource.TryGetValue(logLine.ResourceName, out var lines)) { lines = []; diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 0c88ab4e875..7bc248fc010 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -100,6 +100,10 @@ internal sealed class LogsCommand : BaseCommand { Description = LogsCommandStrings.TimestampsOptionDescription }; + private static readonly Option s_includeHiddenOption = new("--include-hidden") + { + Description = LogsCommandStrings.IncludeHiddenOptionDescription + }; private readonly ResourceColorMap _resourceColorMap; @@ -125,6 +129,7 @@ public LogsCommand( Options.Add(s_formatOption); Options.Add(s_tailOption); Options.Add(s_timestampsOption); + Options.Add(s_includeHiddenOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -137,6 +142,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var format = parseResult.GetValue(s_formatOption); var tail = parseResult.GetValue(s_tailOption); var timestamps = parseResult.GetValue(s_timestampsOption); + var includeHidden = parseResult.GetValue(s_includeHiddenOption); // Validate --tail value if (tail.HasValue && tail.Value < 1) @@ -160,18 +166,19 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var connection = result.Connection!; - - // Fetch snapshots for resource name resolution - var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var effectiveIncludeHidden = includeHidden || resourceName is not null; + using var resourceWatcher = new ResourceSnapshotWatcher(connection, effectiveIncludeHidden); + await resourceWatcher.WaitForInitialLoadAsync(cancellationToken).ConfigureAwait(false); // Pre-resolve colors for all resource names so that assignment is // deterministic regardless of which resources are displayed. - _resourceColorMap.ResolveAll(snapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, snapshots))); + var allSnapshots = resourceWatcher.GetAllResources(); + _resourceColorMap.ResolveAll(allSnapshots.Select(s => ResourceSnapshotMapper.GetResourceName(s, allSnapshots))); // Validate resource name exists (match by Name or DisplayName since users may pass either) if (resourceName is not null) { - if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any()) + if (!ResourceSnapshotMapper.WhereMatchesResourceName(resourceWatcher.GetAllResources(), resourceName).Any()) { _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, LogsCommandStrings.ResourceNotFound, resourceName)); return ExitCodeConstants.InvalidCommand; @@ -179,7 +186,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } else { - if (snapshots.Count == 0) + if (!resourceWatcher.GetResources().Any()) { _interactionService.DisplayMessage(KnownEmojis.Information, LogsCommandStrings.NoResourcesFound); return ExitCodeConstants.Success; @@ -188,27 +195,27 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (follow) { - return await ExecuteWatchAsync(connection, resourceName, format, tail, timestamps, snapshots, cancellationToken); + return await ExecuteWatchAsync(connection, resourceWatcher, resourceName, format, tail, timestamps, cancellationToken); } else { - return await ExecuteGetAsync(connection, resourceName, format, tail, timestamps, snapshots, cancellationToken); + return await ExecuteGetAsync(connection, resourceWatcher, resourceName, format, tail, timestamps, cancellationToken); } } private async Task ExecuteGetAsync( IAppHostAuxiliaryBackchannel connection, + ResourceSnapshotWatcher resourceWatcher, string? resourceName, OutputFormat format, int? tail, bool timestamps, - IReadOnlyList snapshots, CancellationToken cancellationToken) { // Collect all logs, parsing into LogEntry with resolved resource names sorted by timestamp var entries = await _interactionService.ShowStatusAsync( LogsCommandStrings.GettingLogs, - async () => await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false)); + async () => await CollectLogsAsync(connection, resourceWatcher, resourceName, cancellationToken).ConfigureAwait(false)); // Apply tail filter (tail.Value is guaranteed >= 1 by earlier validation) if (tail.HasValue && entries.Count > tail.Value) @@ -247,11 +254,11 @@ private async Task ExecuteGetAsync( private async Task ExecuteWatchAsync( IAppHostAuxiliaryBackchannel connection, + ResourceSnapshotWatcher resourceWatcher, string? resourceName, OutputFormat format, int? tail, bool timestamps, - IReadOnlyList snapshots, CancellationToken cancellationToken) { var logParser = new LogParser(ConsoleColor.Black); @@ -261,7 +268,7 @@ private async Task ExecuteWatchAsync( { var entries = await _interactionService.ShowStatusAsync( LogsCommandStrings.GettingLogs, - async () => await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false)); + async () => await CollectLogsAsync(connection, resourceWatcher, resourceName, cancellationToken).ConfigureAwait(false)); // Output last N lines var tailedEntries = entries.Count > tail.Value @@ -277,7 +284,19 @@ private async Task ExecuteWatchAsync( // Now stream new logs await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: true, cancellationToken).ConfigureAwait(false)) { - var entry = ParseLogLine(logLine, logParser, snapshots); + // When streaming all resources, skip logs from hidden resources. + // We filter by exclusion so that new resources appearing after the + // initial snapshot are included by default. + if (resourceName is null && !resourceWatcher.IncludeHidden) + { + var resource = resourceWatcher.GetResource(logLine.ResourceName); + if (resource is not null && ResourceSnapshotMapper.IsHiddenResource(resource)) + { + continue; + } + } + + var entry = ParseLogLine(logLine, logParser, resourceWatcher.GetAllResources()); OutputLogLine(entry, format, timestamps); } @@ -291,15 +310,27 @@ private async Task ExecuteWatchAsync( /// private static async Task> CollectLogsAsync( IAppHostAuxiliaryBackchannel connection, + ResourceSnapshotWatcher resourceWatcher, string? resourceName, - IReadOnlyList snapshots, CancellationToken cancellationToken) { var logParser = new LogParser(ConsoleColor.Black); var logEntries = new LogEntries(int.MaxValue) { BaseLineNumber = 1 }; + // Snapshot the resource list once for the non-follow path since it doesn't change. + var allSnapshots = resourceWatcher.GetAllResources().ToList(); await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false)) { - logEntries.InsertSorted(ParseLogLine(logLine, logParser, snapshots)); + // When streaming all resources, skip logs from hidden resources + if (resourceName is null && !resourceWatcher.IncludeHidden) + { + var resource = resourceWatcher.GetResource(logLine.ResourceName); + if (resource is not null && ResourceSnapshotMapper.IsHiddenResource(resource)) + { + continue; + } + } + + logEntries.InsertSorted(ParseLogLine(logLine, logParser, allSnapshots)); } return logEntries.GetEntries(); } @@ -308,7 +339,7 @@ private static async Task> CollectLogsAsync( /// Parses a into a with the resolved resource name /// set on . /// - private static LogEntry ParseLogLine(ResourceLogLine logLine, LogParser logParser, IReadOnlyList snapshots) + private static LogEntry ParseLogLine(ResourceLogLine logLine, LogParser logParser, IEnumerable snapshots) { var resolvedName = ResolveResourceName(logLine.ResourceName, snapshots); return logParser.CreateLogEntry(logLine.Content, logLine.IsError, resolvedName); @@ -348,7 +379,7 @@ private static string FormatTimestamp(DateTime timestamp) return timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture); } - private static string ResolveResourceName(string resourceName, IReadOnlyList snapshots) + private static string ResolveResourceName(string resourceName, IEnumerable snapshots) { var snapshot = snapshots.FirstOrDefault(s => string.Equals(s.Name, resourceName, StringComparisons.ResourceName)); if (snapshot is not null) diff --git a/src/Aspire.Cli/Commands/McpToolsCommand.cs b/src/Aspire.Cli/Commands/McpToolsCommand.cs index 155fc2737cf..183a12b1286 100644 --- a/src/Aspire.Cli/Commands/McpToolsCommand.cs +++ b/src/Aspire.Cli/Commands/McpToolsCommand.cs @@ -67,7 +67,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var connection = result.Connection!; - var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken); + var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken); var resourcesWithTools = snapshots.Where(r => r.McpServer is not null).ToList(); if (resourcesWithTools.Count == 0) diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index f62fb8057cb..fac791217f0 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -175,7 +175,7 @@ private async Task> GatherAppHostInfosAsync(List r.McpServer is not null).ToList(); _logger.LogDebug("Resources with MCP tools received: {Count}", resourcesWithTools.Count); diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 8ba6db60122..6020ffc9707 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -67,7 +67,7 @@ public override async ValueTask CallToolAsync(CallToolContext co { // Get dashboard URL and resource snapshots in parallel var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); - var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); + var snapshotsTask = connection.GetResourceSnapshotsAsync(includeHidden: true, cancellationToken); await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs index 02fe13cb375..0e032be3849 100644 --- a/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs @@ -111,6 +111,12 @@ public static string HeaderHealth { } } + public static string IncludeHiddenOptionDescription { + get { + return ResourceManager.GetString("IncludeHiddenOptionDescription", resourceCulture); + } + } + public static string HeaderURLs { get { return ResourceManager.GetString("HeaderURLs", resourceCulture); diff --git a/src/Aspire.Cli/Resources/DescribeCommandStrings.resx b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx index 30a4a7c5afb..1245c5ff667 100644 --- a/src/Aspire.Cli/Resources/DescribeCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx @@ -150,6 +150,9 @@ Health + + Include hidden resources in the output + URLs diff --git a/src/Aspire.Cli/Resources/ExportCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/ExportCommandStrings.Designer.cs index edc8789627f..c75333e4cb9 100644 --- a/src/Aspire.Cli/Resources/ExportCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ExportCommandStrings.Designer.cs @@ -104,6 +104,12 @@ internal static string GatheringConsoleLogs { return ResourceManager.GetString("GatheringConsoleLogs", resourceCulture); } } + + internal static string IncludeHiddenOptionDescription { + get { + return ResourceManager.GetString("IncludeHiddenOptionDescription", resourceCulture); + } + } /// /// Looks up a localized string similar to Gathering resource data.... diff --git a/src/Aspire.Cli/Resources/ExportCommandStrings.resx b/src/Aspire.Cli/Resources/ExportCommandStrings.resx index 1bfdb10611b..8bc473b0bb4 100644 --- a/src/Aspire.Cli/Resources/ExportCommandStrings.resx +++ b/src/Aspire.Cli/Resources/ExportCommandStrings.resx @@ -79,4 +79,7 @@ Dashboard is not available. Telemetry data (structured logs, traces) will not be included in the export. + + Include hidden resources in the output + diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs index 71f61d4cd1c..5545be5d208 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs @@ -105,6 +105,12 @@ public static string TimestampsOptionDescription { } } + public static string IncludeHiddenOptionDescription { + get { + return ResourceManager.GetString("IncludeHiddenOptionDescription", resourceCulture); + } + } + public static string SelectAppHostAction { get { return ResourceManager.GetString("SelectAppHostAction", resourceCulture); diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.resx b/src/Aspire.Cli/Resources/LogsCommandStrings.resx index a0679a756b2..868e505ee3a 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.resx @@ -150,6 +150,9 @@ Show timestamps for each log line + + Include hidden resources in the output + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf index d606717c04c..473811b70de 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Výstup ve formátu JSON pro spotřebu počítače. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf index d0ec199f29b..b5bca772062 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Ausgabe im JSON-Format zur maschinellen Verarbeitung. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf index babaf1b13e9..de999fdd55c 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Salida en formato JSON para el consumo de la máquina. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf index 6f71980e160..91ac2620785 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Générer la sortie au format JSON pour traitement automatique. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf index 627f1054130..3a71576e61f 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Output in formato JSON per l'utilizzo da parte del computer. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf index 4b8b08498ef..642585f44db 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 機械処理用の JSON 形式で出力します。 diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf index 7d29cca2696..8e1d8607359 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 컴퓨터 사용량에 대한 JSON형식의 출력입니다. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf index 3560eeefbd2..b0da1b7fab2 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Wynik w formacie JSON do przetwarzania przez maszynę. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf index 106058f3a25..0fb81399431 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Saída no formato JSON para consumo do computador. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf index e62e6f1d9b7..87d309257a2 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Вывод в формате JSON для потребления компьютером. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf index e9b7b4312fc..b900d3b9c73 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Makine tarafından kullanılmak üzere JSON biçiminde çıktı ver. diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf index c616459aa14..0c93d956d61 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 以 JSON 格式输出,供计算机使用。 diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf index cd09b7acec2..be0deb048db 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf @@ -1,4 +1,4 @@ - + @@ -37,6 +37,11 @@ URLs + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 輸出為 JSON 格式供機器使用。 diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.cs.xlf index 0b91bf80612..20100960002 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.cs.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.de.xlf index 8cece099933..e2c99ceacfb 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.de.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.es.xlf index 8a4f7767f8d..f94008ec935 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.es.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.fr.xlf index e9e79002dc4..17a5ce3d34d 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.fr.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.it.xlf index eff9f3b80b1..1eb8816ee48 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.it.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ja.xlf index c298d54c926..995b3a572da 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ja.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ko.xlf index be205ceb44c..bcd751026b5 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ko.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pl.xlf index e4e3cba4b23..e9f9dd72ba5 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pl.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pt-BR.xlf index f83b1c3a875..25b095e5b1b 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.pt-BR.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ru.xlf index 75aa6657137..ac8593e0252 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.ru.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.tr.xlf index aae59954879..e68babc6592 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.tr.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hans.xlf index 625dbb92a14..aded62c5242 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hans.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hant.xlf index 9ea5ddcf333..87f8bec99ef 100644 --- a/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ExportCommandStrings.zh-Hant.xlf @@ -42,6 +42,11 @@ Gathering traces... + + Include hidden resources in the output + Include hidden resources in the output + + No resources found. No resources found. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index 53364acd8e2..fe59dee289d 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -12,6 +12,11 @@ Streamujte protokoly v reálném čase při jejich zápisu. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Výstupní protokoly ve formátu JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index 928705e5577..b26e592315d 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -12,6 +12,11 @@ Streamen Sie Protokolle in Echtzeit, während sie geschrieben werden. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Protokolle im JSON-Format (NDJSON) ausgeben. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index cbb8d385020..9a120cdbac7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -12,6 +12,11 @@ Transmita los registros en tiempo real a medida que se escriben. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Registros de salida en formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index 2d48ce14aa1..c87fa0ee82d 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -12,6 +12,11 @@ Diffuser les journaux en temps réel au fur et à mesure de leur écriture. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Générer les journaux au format JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index 275390951dd..38b8a05e55b 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -12,6 +12,11 @@ Consente di trasmettere i log in tempo reale man mano che vengono scritti. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Log di output in formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index fd45f8c7cba..b7bd1e1879e 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -12,6 +12,11 @@ ログが書き込まれると同時にリアルタイムでストリームします。 + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) ログを JSON 形式 (NDJSON) で出力します。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index fae2373b452..90b8ff0e5c9 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -12,6 +12,11 @@ 로그가 기록되는 대로 실시간으로 스트리밍합니다. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) JSON 형식(NDJSON)으로 로그를 출력합니다. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index 73b8c623514..1686f0aabae 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -12,6 +12,11 @@ Przesyłaj dzienniki w czasie rzeczywistym podczas ich zapisywania. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Dzienniki wyjściowe w formacie JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index ee2a5fad800..1ff2a342cde 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -12,6 +12,11 @@ Transmita logs em tempo real conforme eles são gravados. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Logs de saída no formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index 7fd22a96d9c..6965bf5acf3 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -12,6 +12,11 @@ Потоковая передача журналов в реальном времени по мере их записи. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Вывод журналов в формате JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index 6d53beea746..0b9d7fc990a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -12,6 +12,11 @@ Günlükleri yazıldıkça gerçek zamanlı olarak akışla aktar. + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) Günlükleri JSON biçiminde (NDJSON) çıkar. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index b23d787fe3b..c1affe419f4 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -12,6 +12,11 @@ 在写入日志时实时流式传输这些日志。 + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 以 JSON 格式输出日志(NDJSON)。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index 4d5bbaf40a8..920af8a64fe 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -12,6 +12,11 @@ 在寫入記錄時即時串流這些記錄。 + + Include hidden resources in the output + Include hidden resources in the output + + Output format (Table or Json) 輸出記錄採用 JSON 格式 (NDJSON)。 diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 082abb7252c..e0fa2747212 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -425,12 +425,6 @@ public async Task> GetResourceSnapshotsAsync(Cancellation // Get current state for each resource directly using TryGetCurrentState foreach (var resource in appModel.Resources) { - // Skip the dashboard resource - if (string.Equals(resource.Name, KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) - { - continue; - } - foreach (var instanceName in resource.GetResolvedResourceNames()) { await AddResult(instanceName).ConfigureAwait(false); @@ -465,12 +459,6 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false)) { - // Skip the dashboard resource - if (string.Equals(resourceEvent.Resource.Name, KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) - { - continue; - } - var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); if (snapshot is not null) { @@ -605,6 +593,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu ResourceType = snapshot.ResourceType, State = snapshot.State?.Text, StateStyle = snapshot.State?.Style, + IsHidden = snapshot.IsHidden, HealthStatus = snapshot.HealthStatus?.ToString(), ExitCode = snapshot.ExitCode, CreatedAt = snapshot.CreationTimeStamp, @@ -654,12 +643,6 @@ public async IAsyncEnumerable GetResourceLogsAsync( { foreach (var resource in appModel.Resources) { - // Skip the dashboard - if (string.Equals(resource.Name, KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) - { - continue; - } - resourcesToLog.AddRange(resource.GetResolvedResourceNames()); } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 1f6d4f1e7d2..43cf58efcd5 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -857,6 +857,11 @@ public string? Type /// public Dictionary Properties { get; init; } = []; + /// + /// Gets a value indicating whether this resource is hidden. + /// + public bool IsHidden { get; init; } + /// /// Gets the MCP server information if the resource exposes an MCP endpoint. /// diff --git a/src/Aspire/Cli/Commands/LogsCommand.cs b/src/Aspire/Cli/Commands/LogsCommand.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index 58c0a7eb120..8217851017b 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -390,6 +390,139 @@ public async Task DescribeCommand_Follow_JsonFormat_StripsLoginPathFromDashboard Assert.Equal("http://localhost:18888/?resource=redis", resource.DashboardUrl); } + [Fact] + public async Task DescribeCommand_HiddenResources_AreExcludedByDefault() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateDescribeTestServices(workspace, outputWriter, [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + new ResourceSnapshot { Name = "hidden-svc", DisplayName = "hidden-svc", ResourceType = "Project", State = "Running", IsHidden = true }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join("", outputWriter.Logs); + var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.Resources); + Assert.Equal("redis", deserialized.Resources[0].Name); + } + + [Fact] + public async Task DescribeCommand_IncludeHidden_ShowsHiddenResources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateDescribeTestServices(workspace, outputWriter, [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + new ResourceSnapshot { Name = "hidden-svc", DisplayName = "hidden-svc", ResourceType = "Project", State = "Running", IsHidden = true }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe --format json --include-hidden"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join("", outputWriter.Logs); + var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); + + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Resources.Length); + Assert.Contains(deserialized.Resources, r => r.Name == "redis"); + Assert.Contains(deserialized.Resources, r => r.Name == "aspire-dashboard"); + Assert.Contains(deserialized.Resources, r => r.Name == "hidden-svc"); + } + + [Fact] + public async Task DescribeCommand_SpecificResource_IncludesHiddenWithoutFlag() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateDescribeTestServices(workspace, outputWriter, [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe aspire-dashboard --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join("", outputWriter.Logs); + var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.Resources); + Assert.Equal("aspire-dashboard", deserialized.Resources[0].Name); + } + + [Fact] + public async Task DescribeCommand_Follow_HiddenResources_AreExcludedByDefault() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateDescribeTestServices(workspace, outputWriter, [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe --follow --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonLines = outputWriter.Logs + .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal)) + .ToList(); + + Assert.Single(jsonLines); + + var resource = JsonSerializer.Deserialize(jsonLines[0], ResourcesCommandJsonContext.Ndjson.ResourceJson); + Assert.NotNull(resource); + Assert.Equal("redis", resource.Name); + } + + [Fact] + public async Task DescribeCommand_Follow_IncludeHidden_ShowsHiddenResources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateDescribeTestServices(workspace, outputWriter, [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + ]); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe --follow --format json --include-hidden"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonLines = outputWriter.Logs + .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal)) + .ToList(); + + Assert.Equal(2, jsonLines.Count); + Assert.Contains(jsonLines, l => l.Contains("redis")); + Assert.Contains(jsonLines, l => l.Contains("aspire-dashboard")); + } + private ServiceProvider CreateDescribeTestServices( TemporaryWorkspace workspace, TestOutputTextWriter outputWriter, diff --git a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs index 31a28616245..4e257638a9f 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExportCommandTests.cs @@ -597,6 +597,136 @@ public async Task ExportCommand_ResourceFilter_ReplicasByDisplayName_ExportsAllR entry => Assert.Equal("traces/apiservice-def.json", entry)); } + [Fact] + public async Task ExportCommand_HiddenResources_AreExcludedByDefault() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); + + var provider = CreateExportTestServices(workspace, + resources: [], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson(), + ["/api/telemetry/traces"] = BuildTracesJson(), + }, + resourceSnapshots: + [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + new ResourceSnapshot { Name = "hidden-svc", DisplayName = "hidden-svc", ResourceType = "Project", State = "Running", IsHidden = true }, + ], + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis ready" }, + new ResourceLogLine { ResourceName = "aspire-dashboard", LineNumber = 1, Content = "Dashboard started" }, + new ResourceLogLine { ResourceName = "hidden-svc", LineNumber = 1, Content = "Hidden service log" }, + ], + dashboardAvailable: false); + + var command = provider.GetRequiredService(); + var result = command.Parse($"export --output {outputPath}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(outputPath)); + + using var archive = ZipFile.OpenRead(outputPath); + var entryNames = archive.Entries.Select(e => e.FullName).OrderBy(n => n).ToList(); + + // Only redis should be present; hidden resources should be excluded + Assert.Collection(entryNames, + entry => Assert.Equal("consolelogs/redis.txt", entry), + entry => Assert.Equal("resources/redis.json", entry)); + } + + [Fact] + public async Task ExportCommand_IncludeHidden_ShowsHiddenResources() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); + + var provider = CreateExportTestServices(workspace, + resources: [], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson(), + ["/api/telemetry/traces"] = BuildTracesJson(), + }, + resourceSnapshots: + [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + ], + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis ready" }, + new ResourceLogLine { ResourceName = "aspire-dashboard", LineNumber = 1, Content = "Dashboard started" }, + ], + dashboardAvailable: false); + + var command = provider.GetRequiredService(); + var result = command.Parse($"export --include-hidden --output {outputPath}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(outputPath)); + + using var archive = ZipFile.OpenRead(outputPath); + var entryNames = archive.Entries.Select(e => e.FullName).OrderBy(n => n).ToList(); + + // Both resources should be present + Assert.Collection(entryNames, + entry => Assert.Equal("consolelogs/aspire-dashboard.txt", entry), + entry => Assert.Equal("consolelogs/redis.txt", entry), + entry => Assert.Equal("resources/aspire-dashboard.json", entry), + entry => Assert.Equal("resources/redis.json", entry)); + } + + [Fact] + public async Task ExportCommand_SpecificHiddenResource_WorksWithoutFlag() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "export.zip"); + + var provider = CreateExportTestServices(workspace, + resources: [], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson(), + ["/api/telemetry/traces"] = BuildTracesJson(), + }, + resourceSnapshots: + [ + new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" }, + new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" }, + ], + logLines: + [ + new ResourceLogLine { ResourceName = "redis", LineNumber = 1, Content = "Redis ready" }, + new ResourceLogLine { ResourceName = "aspire-dashboard", LineNumber = 1, Content = "Dashboard started" }, + ], + dashboardAvailable: false); + + var command = provider.GetRequiredService(); + var result = command.Parse($"export aspire-dashboard --output {outputPath}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(outputPath)); + + using var archive = ZipFile.OpenRead(outputPath); + var entryNames = archive.Entries.Select(e => e.FullName).OrderBy(n => n).ToList(); + + // Only the specified hidden resource should be present + Assert.Collection(entryNames, + entry => Assert.Equal("consolelogs/aspire-dashboard.txt", entry), + entry => Assert.Equal("resources/aspire-dashboard.json", entry)); + } + [Fact] public async Task ExportCommand_ResourceFilter_NonExistentResource_ReturnsError() { diff --git a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs index 3fb0fcfb59a..1fd3694e2ba 100644 --- a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.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.Runtime.CompilerServices; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; @@ -568,6 +569,328 @@ public async Task LogsCommand_TextOutput_WithoutTimestamps_NoTimestampPrefix() Assert.Equal("[apiservice-def456] Hello from replica 2", logLines[2]); } + [Fact] + public async Task LogsCommand_HiddenResources_AreExcludedByDefault() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServicesWithHidden(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + // Only visible resource logs should be present + Assert.All(logsOutput.Logs, l => Assert.Equal("redis", l.ResourceName)); + Assert.DoesNotContain(logsOutput.Logs, l => l.ResourceName == "aspire-dashboard"); + } + + [Fact] + public async Task LogsCommand_IncludeHidden_ShowsHiddenResourceLogs() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServicesWithHidden(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json --include-hidden"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + // Both visible and hidden resource logs should be present + Assert.Contains(logsOutput.Logs, l => l.ResourceName == "redis"); + Assert.Contains(logsOutput.Logs, l => l.ResourceName == "aspire-dashboard"); + } + + [Fact] + public async Task LogsCommand_SpecificHiddenResource_WorksWithoutFlag() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServicesWithHidden(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs aspire-dashboard --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + Assert.All(logsOutput.Logs, l => Assert.Equal("aspire-dashboard", l.ResourceName)); + } + + [Fact] + public async Task LogsCommand_NewResourceAfterInitialSnapshot_LogsAreIncluded() + { + // Verifies that logs from a resource not present in the initial snapshot + // (e.g. a resource that came online after streaming started) are still shown. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + }, + new ResourceSnapshot + { + Name = "aspire-dashboard", + DisplayName = "aspire-dashboard", + ResourceType = "Executable", + State = "Hidden" + } + ], + LogLines = + [ + new ResourceLogLine + { + ResourceName = "redis", + LineNumber = 1, + Content = "2025-01-15T10:30:00Z Ready to accept connections", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "aspire-dashboard", + LineNumber = 1, + Content = "2025-01-15T10:30:01Z Dashboard started", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "webapi", + LineNumber = 1, + Content = "2025-01-15T10:30:02Z Webapi started", + IsError = false + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + options.DisableAnsi = false; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + + // redis logs should be present (visible resource in initial snapshot) + Assert.Contains(logsOutput.Logs, l => l.ResourceName == "redis"); + // webapi logs should be present even though it was not in the initial snapshot + Assert.Contains(logsOutput.Logs, l => l.ResourceName == "webapi"); + // aspire-dashboard logs should still be excluded (hidden) + Assert.DoesNotContain(logsOutput.Logs, l => l.ResourceName == "aspire-dashboard"); + } + + [Fact] + public async Task LogsCommand_HiddenResourceAfterInitialSnapshot_IsExcludedInFollowMode() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var snapshotsCallCount = 0; + + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + } + ], + LogLines = + [ + new ResourceLogLine + { + ResourceName = "redis", + LineNumber = 1, + Content = "2025-01-15T10:30:00Z Ready to accept connections", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "late-hidden", + LineNumber = 1, + Content = "2025-01-15T10:30:01Z Hidden resource log", + IsError = false + } + ], + GetResourceSnapshotsHandler = _ => + { + snapshotsCallCount++; + return Task.FromResult(snapshotsCallCount == 1 + ? new List + { + new() + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + } + } + : new List + { + new() + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + }, + new() + { + Name = "late-hidden", + DisplayName = "late-hidden", + ResourceType = "Executable", + State = "Hidden" + } + }); + }, + WatchResourceSnapshotsHandler = (_, cancellationToken) => WatchWithLateHidden(cancellationToken) + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("logs --follow"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Contains(outputWriter.Logs, l => l.Contains("[redis] Ready to accept connections", StringComparison.Ordinal)); + Assert.DoesNotContain(outputWriter.Logs, l => l.Contains("late-hidden", StringComparison.Ordinal)); + } + + private ServiceProvider CreateLogsTestServicesWithHidden( + TemporaryWorkspace workspace, + TestOutputTextWriter outputWriter, + bool disableAnsi = false) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + }, + new ResourceSnapshot + { + Name = "aspire-dashboard", + DisplayName = "aspire-dashboard", + ResourceType = "Executable", + State = "Hidden" + } + ], + LogLines = + [ + new ResourceLogLine + { + ResourceName = "redis", + LineNumber = 1, + Content = "2025-01-15T10:30:00Z Ready to accept connections", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "aspire-dashboard", + LineNumber = 1, + Content = "2025-01-15T10:30:01Z Dashboard started", + IsError = false + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + options.DisableAnsi = disableAnsi; + }); + + return services.BuildServiceProvider(); + } + private ServiceProvider CreateLogsTestServices( TemporaryWorkspace workspace, TestOutputTextWriter outputWriter, @@ -651,4 +974,22 @@ private ServiceProvider CreateLogsTestServices( return services.BuildServiceProvider(); } + + private static async IAsyncEnumerable WatchWithLateHidden([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new ResourceSnapshot + { + Name = "late-hidden", + DisplayName = "late-hidden", + ResourceType = "Executable", + State = "Hidden" + }; + + // Keep the enumerable alive until cancelled so the watcher stays running. + var tcs = new TaskCompletionSource(); + await using (cancellationToken.Register(() => tcs.TrySetResult())) + { + await tcs.Task.ConfigureAwait(false); + } + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs index 16d1eee74eb..58982867b2a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -51,25 +51,48 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha /// public Func>>? GetResourceSnapshotsHandler { get; set; } + /// + /// Gets or sets the function to call when WatchResourceSnapshotsAsync is invoked. + /// If null, yields the ResourceSnapshots list. + /// + public Func>? WatchResourceSnapshotsHandler { get; set; } + public Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default) { return Task.FromResult(DashboardUrlsState); } - public Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) + public Task> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default) { if (GetResourceSnapshotsHandler is not null) { return GetResourceSnapshotsHandler(cancellationToken); } - return Task.FromResult(ResourceSnapshots); + var snapshots = includeHidden + ? ResourceSnapshots + : ResourceSnapshots.Where(s => !ResourceSnapshotMapper.IsHiddenResource(s)).ToList(); + return Task.FromResult(snapshots); } - public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (WatchResourceSnapshotsHandler is not null) + { + await foreach (var snapshot in WatchResourceSnapshotsHandler(includeHidden, cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return snapshot; + } + yield break; + } + foreach (var snapshot in ResourceSnapshots) { + if (!includeHidden && ResourceSnapshotMapper.IsHiddenResource(snapshot)) + { + continue; + } + yield return snapshot; } await Task.CompletedTask; diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index 749ad8bfc20..32a9e114b23 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -62,8 +62,8 @@ await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myr var result = await target.GetResourceSnapshotsAsync(); - // Dashboard resource should be skipped - Assert.DoesNotContain(result, r => r.Name == KnownResourceNames.AspireDashboard); + // Dashboard resource should now be included + Assert.Contains(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"); @@ -312,8 +312,6 @@ public async Task GetResourceLogsAsync_ReturnsLogsFromAllResources_WhenNoResourc var log2 = Assert.Single(logs, l => l.ResourceName == "resource2"); Assert.Equal($"{TestTimestamp} Log from resource2", log2.Content); - Assert.DoesNotContain(logs, l => l.ResourceName == KnownResourceNames.AspireDashboard); - await app.StopAsync(); }