diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4674e5743..c43ea3eed3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,9 +98,9 @@ - - - + + + diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..c4fad7e9f8c 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -9,7 +9,7 @@ false aspire Aspire.Cli - $(NoWarn);CS1591 + $(NoWarn);CS1591;CS8002 true Size $(DefineConstants);CLI @@ -38,6 +38,7 @@ + @@ -163,6 +164,11 @@ True McpCommandStrings.resx + + True + True + MonitorCommandStrings.resx + @@ -258,5 +264,12 @@ ResXFileCodeGenerator McpCommandStrings.Designer.cs + + Resx + EmbeddedResource + Designer + ResXFileCodeGenerator + MonitorCommandStrings.Designer.cs + diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 02c0cab6a27..baa05de8b18 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -393,6 +393,27 @@ public async Task CallResourceMcpToolAsync( cancellationToken).ConfigureAwait(false); } + /// + public async Task?> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default) + { + var rpc = EnsureConnected(); + + _logger?.LogDebug("Requesting AppHost log entries"); + + try + { + return await rpc.InvokeWithCancellationAsync>( + "GetAppHostLogEntriesAsync", + [], + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogDebug(ex, "AppHost log streaming is not available. The AppHost may be running an older version."); + return null; + } + } + #region V2 API Methods /// diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs index 6c3d8448b9c..dc472b7b49d 100644 --- a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -133,4 +133,10 @@ Task WaitForResourceAsync( string status, int timeoutSeconds, CancellationToken cancellationToken = default); + /// Gets AppHost log entries streamed from the hosting process. + /// Returns null if the AppHost does not support log streaming. + /// + /// Cancellation token. + /// An async enumerable of log entries, or null if not supported. + Task?> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Commands/AtopCommand.cs b/src/Aspire.Cli/Commands/AtopCommand.cs new file mode 100644 index 00000000000..a2aae57bb8d --- /dev/null +++ b/src/Aspire.Cli/Commands/AtopCommand.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.UI; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class AtopCommand : BaseCommand +{ + private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly ILogger _logger; + + public AtopCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("atop", MonitorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + _backchannelMonitor = backchannelMonitor; + _logger = logger; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var tui = new AspireAtopTui(_backchannelMonitor, _logger); + await tui.RunAsync(cancellationToken).ConfigureAwait(false); + + return ExitCodeConstants.Success; + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index db956046a9a..70fce3a2027 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -130,6 +130,8 @@ public RootCommand( DocsCommand docsCommand, SdkCommand sdkCommand, SetupCommand setupCommand, + MonitorCommand monitorCommand, + AtopCommand atopCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -220,5 +222,10 @@ public RootCommand( Subcommands.Add(sdkCommand); } + if (featureFlags.IsFeatureEnabled(KnownFeatures.MonitorCommandEnabled, false)) + { + Subcommands.Add(atopCommand); + } + } } diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 97e0fb1d558..217653bcfe9 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -31,6 +31,7 @@ internal static class KnownFeatures public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string DotNetSdkInstallationEnabled => "dotnetSdkInstallationEnabled"; public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled"; + public static string MonitorCommandEnabled => "monitorCommandEnabled"; private static readonly Dictionary s_featureMetadata = new() { @@ -112,7 +113,12 @@ internal static class KnownFeatures [RunningInstanceDetectionEnabled] = new( RunningInstanceDetectionEnabled, "Enable or disable detection of already running Aspire instances to prevent conflicts", - DefaultValue: true) + DefaultValue: true), + + [MonitorCommandEnabled] = new( + MonitorCommandEnabled, + "Enable or disable the 'aspire atop' command for launching a TUI to monitor running AppHosts and resources", + DefaultValue: false) }; /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e7e1e91cde5..bbc8b8616fb 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -374,6 +374,8 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs new file mode 100644 index 00000000000..a01681394d8 --- /dev/null +++ b/src/Aspire.Cli/Resources/MonitorCommandStrings.Designer.cs @@ -0,0 +1,114 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class MonitorCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal MonitorCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.MonitorCommandStrings", typeof(MonitorCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + public static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + public static string NoRunningAppHostsFound { + get { + return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); + } + } + + public static string ResourcesTab { + get { + return ResourceManager.GetString("ResourcesTab", resourceCulture); + } + } + + public static string ParametersTab { + get { + return ResourceManager.GetString("ParametersTab", resourceCulture); + } + } + + public static string AppHostsDrawerTitle { + get { + return ResourceManager.GetString("AppHostsDrawerTitle", resourceCulture); + } + } + + public static string QuitShortcut { + get { + return ResourceManager.GetString("QuitShortcut", resourceCulture); + } + } + + public static string TabShortcut { + get { + return ResourceManager.GetString("TabShortcut", resourceCulture); + } + } + + public static string NoResourcesAvailable { + get { + return ResourceManager.GetString("NoResourcesAvailable", resourceCulture); + } + } + + public static string NoParametersAvailable { + get { + return ResourceManager.GetString("NoParametersAvailable", resourceCulture); + } + } + + public static string ConnectingToAppHost { + get { + return ResourceManager.GetString("ConnectingToAppHost", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/MonitorCommandStrings.resx b/src/Aspire.Cli/Resources/MonitorCommandStrings.resx new file mode 100644 index 00000000000..9a8ee7946a8 --- /dev/null +++ b/src/Aspire.Cli/Resources/MonitorCommandStrings.resx @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Launch a TUI to monitor running Aspire apphosts and resources. + + + Scanning for running AppHosts... + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + Resources + + + Parameters + + + App Hosts + + + Quit + + + Switch Tab + + + No resources available. + + + No parameters available. + + + Connecting to AppHost... + + diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf new file mode 100644 index 00000000000..7e4114c7b61 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.cs.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf new file mode 100644 index 00000000000..bdd85bd31e7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.de.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf new file mode 100644 index 00000000000..0bdfe7aef1e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.es.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf new file mode 100644 index 00000000000..85f608ecce5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.fr.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf new file mode 100644 index 00000000000..a26dbc39e3b --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.it.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf new file mode 100644 index 00000000000..889e61dd3e5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ja.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf new file mode 100644 index 00000000000..415470122f4 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ko.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf new file mode 100644 index 00000000000..0af82c39b66 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pl.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..c4de4bcc0d5 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.pt-BR.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf new file mode 100644 index 00000000000..65eaf7ddd4d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.ru.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf new file mode 100644 index 00000000000..6b8cd0eb676 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.tr.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..3fbcf2e0a34 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hans.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..d251b225500 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/MonitorCommandStrings.zh-Hant.xlf @@ -0,0 +1,62 @@ + + + + + + App Hosts + App Hosts + + + + Connecting to AppHost... + Connecting to AppHost... + + + + Launch a TUI to monitor running Aspire apphosts and resources. + Launch a TUI to monitor running Aspire apphosts and resources. + + + + No parameters available. + No parameters available. + + + + No resources available. + No resources available. + + + + No running AppHosts found. Start an AppHost with 'aspire run' first. + No running AppHosts found. Start an AppHost with 'aspire run' first. + + + + Parameters + Parameters + + + + Quit + Quit + + + + Resources + Resources + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Switch Tab + Switch Tab + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/UI/AspireAtopSplash.cs b/src/Aspire.Cli/UI/AspireAtopSplash.cs new file mode 100644 index 00000000000..863e5a31bae --- /dev/null +++ b/src/Aspire.Cli/UI/AspireAtopSplash.cs @@ -0,0 +1,600 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b; +using Hex1b.Theming; +using Hex1b.Widgets; + +namespace Aspire.Cli.UI; + +/// +/// Animated splash screen that renders the Aspire logo. +/// Braille particles whirlwind into position, crossfade to half-blocks, +/// then dissolve and fall with gravity. +/// +internal sealed class AspireAtopSplash +{ + // Phase timing (ms from start) + private const int WhirlwindDurationMs = 2200; + private const int HoldEndMs = 3000; + private const int DissolveDurationMs = 900; + private const int ExitDurationMs = 3000; + public const int TotalDurationMs = HoldEndMs + ExitDurationMs; + + // Logo dimensions in logical pixels (each pixel is half a character cell vertically) + private const int LogoWidth = 40; + private const int LogoHeight = 40; + private const int LogoCellHeight = LogoHeight / 2; + + // Physics (in braille sub-pixel units per second) + private const float Gravity = 80f; + private const float BounceDecay = 0.3f; + private const float SettleThreshold = 3f; + private const int FadeOutMs = 500; + + private readonly record struct BrailleDot( + int FinalBx, int FinalBy, byte R, byte G, byte B, + float StartRadius, float StartAngle, float TotalRotations); + + private sealed class Particle + { + public float X; + public float Y; + public float Vx; + public float Vy; + public byte R; + public byte G; + public byte B; + public float SpawnTimeMs; + public bool Settled; + } + + private readonly BrailleDot[] _dots; + private readonly long _startTicks; + private List? _particles; + private long _lastPhysicsTick; + private float _maxBrailleY; + + // Braille dot bit positions: index = col*4 + row + private static readonly int[] s_brailleBits = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80]; + + public AspireAtopSplash() + { + _startTicks = Environment.TickCount64; + + var pixelCount = s_pixelData.Length / 5; + var dotList = new List(pixelCount * 4); + var rng = new Random(99); + + for (var i = 0; i < pixelCount; i++) + { + var offset = i * 5; + var px = s_pixelData[offset]; + var py = s_pixelData[offset + 1]; + var r = s_pixelData[offset + 2]; + var g = s_pixelData[offset + 3]; + var b = s_pixelData[offset + 4]; + + var cellY = py / 2; + var isTop = py % 2 == 0; + var baseBx = px * 2; + var baseBy = cellY * 4 + (isTop ? 0 : 2); + + for (var dr = 0; dr < 2; dr++) + { + for (var dc = 0; dc < 2; dc++) + { + var startRadius = (float)(40 + rng.NextDouble() * 60); + var startAngle = (float)(rng.NextDouble() * Math.PI * 2); + var rotations = (float)(1.5 + rng.NextDouble() * 2.5); + + dotList.Add(new BrailleDot( + baseBx + dc, baseBy + dr, + r, g, b, + startRadius, startAngle, rotations)); + } + } + } + + _dots = dotList.ToArray(); + } + + private long ElapsedMs => Environment.TickCount64 - _startTicks; + + public bool IsComplete => ElapsedMs >= TotalDurationMs; + + public Hex1bWidget Build(RootContext ctx) + { + var elapsed = ElapsedMs; + + return ctx.ThemePanel(AspireTheme.Apply, + ctx.Surface(layerCtx => [ + layerCtx.Layer(surface => + { + var offsetX = Math.Max(0, (surface.Width - LogoWidth) / 2); + var offsetY = Math.Max(0, (surface.Height - LogoCellHeight) / 2); + var offsetBx = offsetX * 2; + var offsetBy = offsetY * 4; + + if (elapsed < WhirlwindDurationMs) + { + // Braille particles spiral into position + var progress = elapsed / (float)WhirlwindDurationMs; + RenderBrailleLogo(surface, progress, 1f, offsetBx, offsetBy); + } + else if (elapsed < HoldEndMs) + { + // Braille logo settled at final positions + RenderBrailleLogo(surface, 1f, 1f, offsetBx, offsetBy); + } + else if (elapsed < TotalDurationMs) + { + // Dissolve and melt (braille-only) + var exitElapsed = elapsed - HoldEndMs; + var offsetBxExit = offsetX * 2; + var offsetByExit = offsetY * 4; + RenderExit(surface, exitElapsed, offsetBxExit, offsetByExit); + } + }) + ]).Fill() + ).Fill(); + } + + // ── Braille whirlwind rendering ─────────────────────────────────────── + + private void RenderBrailleLogo(Hex1b.Surfaces.Surface surface, float spiralProgress, float brightness, int offsetBx, int offsetBy) + { + var t = EaseOutCubic(spiralProgress); + var cells = new Dictionary<(int cx, int cy), (int pattern, int totalR, int totalG, int totalB, int count)>(); + + foreach (var dot in _dots) + { + // Parametric spiral: radius shrinks, angle unwinds as t → 1 + var radius = dot.StartRadius * (1f - t); + var angle = dot.StartAngle + dot.TotalRotations * 2f * MathF.PI * (1f - t); + var bx = (int)Math.Round(dot.FinalBx + offsetBx + radius * MathF.Cos(angle)); + var by = (int)Math.Round(dot.FinalBy + offsetBy + radius * MathF.Sin(angle)); + + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } + + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; + + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) + { + cells[key] = (existing.pattern | bit, existing.totalR + dot.R, existing.totalG + dot.G, existing.totalB + dot.B, existing.count + 1); + } + else + { + cells[key] = (bit, dot.R, dot.G, dot.B, 1); + } + } + + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) + { + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } + + var r = Dim((byte)(totalR / count), brightness); + var g = Dim((byte)(totalG / count), brightness); + var b = Dim((byte)(totalB / count), brightness); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } + } + + // ── Dissolve / melt exit ────────────────────────────────────────────── + + private void RenderExit(Hex1b.Surfaces.Surface surface, long exitElapsedMs, int offsetBx, int offsetBy) + { + EnsureParticlesCreated(surface.Height, offsetBx, offsetBy); + UpdateParticles(exitElapsedMs); + + var dissolveProgress = Math.Clamp(exitElapsedMs / (float)DissolveDurationMs, 0f, 1f); + var dissolvedUpToRow = (int)(dissolveProgress * LogoCellHeight); + + // Fade factor for the end of the exit + var fadeStart = ExitDurationMs - FadeOutMs; + var fadeFactor = exitElapsedMs > fadeStart + ? 1f - Math.Clamp((exitElapsedMs - fadeStart) / (float)FadeOutMs, 0f, 1f) + : 1f; + + // Render undissolved rows as static braille + if (dissolvedUpToRow < LogoCellHeight) + { + RenderBrailleRows(surface, dissolvedUpToRow, LogoCellHeight, fadeFactor, offsetBx, offsetBy); + } + + // Render falling particles as braille + RenderFallingParticles(surface, exitElapsedMs, fadeFactor); + } + + private void RenderBrailleRows(Hex1b.Surfaces.Surface surface, int fromRow, int toRow, float brightness, int offsetBx, int offsetBy) + { + var cells = new Dictionary<(int cx, int cy), (int pattern, int totalR, int totalG, int totalB, int count)>(); + + foreach (var dot in _dots) + { + var cellRow = dot.FinalBy / 4; + if (cellRow < fromRow || cellRow >= toRow) + { + continue; + } + + var bx = dot.FinalBx + offsetBx; + var by = dot.FinalBy + offsetBy; + + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } + + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; + + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) + { + cells[key] = (existing.pattern | bit, existing.totalR + dot.R, existing.totalG + dot.G, existing.totalB + dot.B, existing.count + 1); + } + else + { + cells[key] = (bit, dot.R, dot.G, dot.B, 1); + } + } + + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) + { + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } + + var r = Dim((byte)(totalR / count), brightness); + var g = Dim((byte)(totalG / count), brightness); + var b = Dim((byte)(totalB / count), brightness); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } + } + + private void EnsureParticlesCreated(int surfaceHeight, int offsetBx, int offsetBy) + { + if (_particles is not null) + { + return; + } + + _maxBrailleY = surfaceHeight * 4 - 1; + _lastPhysicsTick = Environment.TickCount64; + _particles = new List(_dots.Length); + var rng = new Random(123); + + foreach (var dot in _dots) + { + var cellRow = (dot.FinalBy / 4); + var spawnTimeMs = (cellRow / (float)LogoCellHeight) * DissolveDurationMs; + + _particles.Add(new Particle + { + X = dot.FinalBx + offsetBx, + Y = dot.FinalBy + offsetBy, + Vx = (float)(rng.NextDouble() * 30 - 15), + Vy = (float)(rng.NextDouble() * 25 + 10), + R = dot.R, + G = dot.G, + B = dot.B, + SpawnTimeMs = spawnTimeMs + }); + } + } + + private void UpdateParticles(long exitElapsedMs) + { + if (_particles is null) + { + return; + } + + var currentTick = Environment.TickCount64; + var dt = (currentTick - _lastPhysicsTick) / 1000f; + _lastPhysicsTick = currentTick; + dt = Math.Min(dt, 0.1f); + + foreach (var p in _particles) + { + if (p.SpawnTimeMs > exitElapsedMs || p.Settled) + { + continue; + } + + p.Vy += Gravity * dt; + p.X += p.Vx * dt; + p.Y += p.Vy * dt; + + if (p.Y >= _maxBrailleY) + { + p.Y = _maxBrailleY; + p.Vy = -p.Vy * BounceDecay; + p.Vx *= 0.8f; + + if (Math.Abs(p.Vy) < SettleThreshold) + { + p.Settled = true; + } + } + } + } + + private void RenderFallingParticles(Hex1b.Surfaces.Surface surface, long exitElapsedMs, float fadeFactor) + { + if (_particles is null) + { + return; + } + + // Group active particles by cell position, accumulating color for averaging + var cells = new Dictionary<(int cx, int cy), (int pattern, int r, int g, int b, int count)>(); + + foreach (var p in _particles) + { + if (p.SpawnTimeMs > exitElapsedMs) + { + continue; + } + + var bx = (int)Math.Round(p.X); + var by = (int)Math.Round(p.Y); + + if (bx < 0 || bx >= surface.Width * 2 || by < 0 || by >= surface.Height * 4) + { + continue; + } + + var cx = bx / 2; + var cy = by / 4; + var dotCol = bx % 2; + var dotRow = by % 4; + var bit = s_brailleBits[dotCol * 4 + dotRow]; + + var key = (cx, cy); + if (cells.TryGetValue(key, out var existing)) + { + cells[key] = (existing.pattern | bit, existing.r + p.R, existing.g + p.G, existing.b + p.B, existing.count + 1); + } + else + { + cells[key] = (bit, p.R, p.G, p.B, 1); + } + } + + foreach (var ((cx, cy), (pattern, totalR, totalG, totalB, count)) in cells) + { + if (cx < 0 || cx >= surface.Width || cy < 0 || cy >= surface.Height) + { + continue; + } + + var r = Dim((byte)(totalR / count), fadeFactor); + var g = Dim((byte)(totalG / count), fadeFactor); + var b = Dim((byte)(totalB / count), fadeFactor); + var ch = (char)(0x2800 | pattern); + surface.WriteChar(cx, cy, ch, Hex1bColor.FromRgb(r, g, b)); + } + } + + // ── Shared helpers ──────────────────────────────────────────────────── + + private static float EaseOutCubic(float x) => 1f - (1f - x) * (1f - x) * (1f - x); + + private static byte Dim(byte value, float factor) => (byte)(value * factor); + + private static readonly byte[] s_pixelData = + [ + 0x12, 0x03, 0x0E, 0x0E, 0x0D, 0x13, 0x03, 0x23, 0x1E, 0x3A, 0x14, 0x03, 0x23, 0x1D, 0x3A, 0x15, 0x03, 0x0D, 0x0E, 0x0D, + 0x11, 0x04, 0x2D, 0x23, 0x4F, 0x12, 0x04, 0x58, 0x39, 0xC7, 0x13, 0x04, 0x5A, 0x35, 0xDF, 0x14, 0x04, 0x5A, 0x34, 0xDF, + 0x15, 0x04, 0x58, 0x38, 0xC7, 0x16, 0x04, 0x2D, 0x23, 0x4F, 0x10, 0x05, 0x26, 0x1F, 0x3F, 0x11, 0x05, 0x60, 0x38, 0xE5, + 0x12, 0x05, 0x4E, 0x26, 0xD8, 0x13, 0x05, 0x4F, 0x29, 0xD1, 0x14, 0x05, 0x4F, 0x29, 0xD1, 0x15, 0x05, 0x4E, 0x26, 0xD8, + 0x16, 0x05, 0x60, 0x38, 0xE5, 0x17, 0x05, 0x26, 0x1E, 0x3F, 0x0F, 0x06, 0x0A, 0x0B, 0x09, 0x10, 0x06, 0x55, 0x38, 0xB9, + 0x11, 0x06, 0x4F, 0x27, 0xDA, 0x12, 0x06, 0x56, 0x33, 0xD2, 0x13, 0x06, 0x6F, 0x4F, 0xDC, 0x14, 0x06, 0x6F, 0x4F, 0xDC, + 0x15, 0x06, 0x56, 0x33, 0xD2, 0x16, 0x06, 0x4F, 0x26, 0xDA, 0x17, 0x06, 0x55, 0x37, 0xB9, 0x18, 0x06, 0x09, 0x0A, 0x09, + 0x0F, 0x07, 0x35, 0x27, 0x63, 0x10, 0x07, 0x5A, 0x31, 0xE6, 0x11, 0x07, 0x4E, 0x28, 0xCF, 0x12, 0x07, 0x6D, 0x4C, 0xDB, + 0x13, 0x07, 0x77, 0x58, 0xDD, 0x14, 0x07, 0x77, 0x58, 0xDD, 0x15, 0x07, 0x6D, 0x4C, 0xDB, 0x16, 0x07, 0x4E, 0x28, 0xD0, + 0x17, 0x07, 0x5A, 0x31, 0xE5, 0x18, 0x07, 0x38, 0x2A, 0x66, 0x0E, 0x08, 0x15, 0x13, 0x1A, 0x0F, 0x08, 0x58, 0x36, 0xCC, + 0x10, 0x08, 0x4D, 0x25, 0xD5, 0x11, 0x08, 0x5E, 0x3B, 0xD7, 0x12, 0x08, 0x76, 0x58, 0xDE, 0x13, 0x08, 0x73, 0x54, 0xDD, + 0x14, 0x08, 0x73, 0x54, 0xDD, 0x15, 0x08, 0x76, 0x58, 0xDE, 0x16, 0x08, 0x5E, 0x3B, 0xD7, 0x17, 0x08, 0x4C, 0x25, 0xD5, + 0x18, 0x08, 0x5C, 0x39, 0xCF, 0x19, 0x08, 0x16, 0x15, 0x1B, 0x0E, 0x09, 0x49, 0x35, 0x8B, 0x0F, 0x09, 0x56, 0x2D, 0xE4, + 0x10, 0x09, 0x51, 0x2C, 0xD1, 0x11, 0x09, 0x71, 0x51, 0xDC, 0x12, 0x09, 0x75, 0x56, 0xDD, 0x13, 0x09, 0x74, 0x55, 0xDD, + 0x14, 0x09, 0x74, 0x55, 0xDD, 0x15, 0x09, 0x75, 0x56, 0xDD, 0x16, 0x09, 0x71, 0x51, 0xDC, 0x17, 0x09, 0x51, 0x2C, 0xD1, + 0x18, 0x09, 0x56, 0x2D, 0xE3, 0x19, 0x09, 0x45, 0x30, 0x87, 0x0D, 0x0A, 0x21, 0x1D, 0x31, 0x0E, 0x0A, 0x5D, 0x37, 0xDD, + 0x0F, 0x0A, 0x4B, 0x24, 0xD1, 0x10, 0x0A, 0x64, 0x42, 0xD9, 0x11, 0x0A, 0x77, 0x58, 0xDE, 0x12, 0x0A, 0x73, 0x54, 0xDD, + 0x13, 0x0A, 0x74, 0x55, 0xDD, 0x14, 0x0A, 0x74, 0x55, 0xDD, 0x15, 0x0A, 0x73, 0x54, 0xDD, 0x16, 0x0A, 0x77, 0x58, 0xDE, + 0x17, 0x0A, 0x64, 0x42, 0xD9, 0x18, 0x0A, 0x4C, 0x25, 0xD2, 0x19, 0x0A, 0x5B, 0x35, 0xDA, 0x1A, 0x0A, 0x1F, 0x1A, 0x2F, + 0x0D, 0x0B, 0x4F, 0x35, 0xA5, 0x0E, 0x0B, 0x52, 0x29, 0xDF, 0x0F, 0x0B, 0x55, 0x31, 0xD3, 0x10, 0x0B, 0x74, 0x55, 0xDD, + 0x11, 0x0B, 0x74, 0x55, 0xDD, 0x12, 0x0B, 0x74, 0x55, 0xDD, 0x13, 0x0B, 0x74, 0x55, 0xDD, 0x14, 0x0B, 0x74, 0x55, 0xDD, + 0x15, 0x0B, 0x74, 0x55, 0xDD, 0x16, 0x0B, 0x74, 0x55, 0xDD, 0x17, 0x0B, 0x74, 0x55, 0xDD, 0x18, 0x0B, 0x56, 0x31, 0xD3, + 0x19, 0x0B, 0x51, 0x29, 0xDE, 0x1A, 0x0B, 0x54, 0x39, 0xAA, 0x1B, 0x0B, 0x07, 0x07, 0x04, 0x0C, 0x0C, 0x2A, 0x20, 0x49, + 0x0D, 0x0C, 0x59, 0x31, 0xE1, 0x0E, 0x0C, 0x4D, 0x27, 0xD1, 0x0F, 0x0C, 0x69, 0x47, 0xDA, 0x10, 0x0C, 0x77, 0x58, 0xDE, + 0x11, 0x0C, 0x73, 0x54, 0xDD, 0x12, 0x0C, 0x74, 0x55, 0xDD, 0x13, 0x0C, 0x74, 0x55, 0xDD, 0x14, 0x0C, 0x74, 0x55, 0xDD, + 0x15, 0x0C, 0x74, 0x55, 0xDD, 0x16, 0x0C, 0x73, 0x54, 0xDD, 0x17, 0x0C, 0x76, 0x58, 0xDE, 0x18, 0x0C, 0x69, 0x48, 0xDA, + 0x19, 0x0C, 0x4C, 0x26, 0xD0, 0x1A, 0x0C, 0x5D, 0x34, 0xE4, 0x1B, 0x0C, 0x2E, 0x23, 0x4D, 0x0B, 0x0D, 0x09, 0x09, 0x09, + 0x0C, 0x0D, 0x56, 0x38, 0xBF, 0x0D, 0x0D, 0x4F, 0x27, 0xDA, 0x0E, 0x0D, 0x5A, 0x35, 0xD5, 0x0F, 0x0D, 0x78, 0x59, 0xDE, + 0x10, 0x0D, 0x72, 0x53, 0xDD, 0x11, 0x0D, 0x74, 0x55, 0xDD, 0x12, 0x0D, 0x74, 0x55, 0xDD, 0x13, 0x0D, 0x74, 0x55, 0xDD, + 0x14, 0x0D, 0x74, 0x55, 0xDD, 0x15, 0x0D, 0x74, 0x55, 0xDD, 0x16, 0x0D, 0x74, 0x55, 0xDD, 0x17, 0x0D, 0x74, 0x54, 0xDD, + 0x18, 0x0D, 0x76, 0x57, 0xDD, 0x19, 0x0D, 0x5A, 0x37, 0xD5, 0x1A, 0x0D, 0x4E, 0x26, 0xD9, 0x1B, 0x0D, 0x55, 0x36, 0xBE, + 0x1C, 0x0D, 0x09, 0x09, 0x09, 0x0B, 0x0E, 0x3A, 0x2B, 0x6D, 0x0C, 0x0E, 0x58, 0x2F, 0xE4, 0x0D, 0x0E, 0x4E, 0x29, 0xD0, + 0x0E, 0x0E, 0x84, 0x69, 0xE1, 0x0F, 0x0E, 0x98, 0x81, 0xE5, 0x10, 0x0E, 0x85, 0x6A, 0xE1, 0x11, 0x0E, 0x72, 0x53, 0xDD, + 0x12, 0x0E, 0x74, 0x56, 0xDD, 0x13, 0x0E, 0x74, 0x55, 0xDD, 0x14, 0x0E, 0x74, 0x55, 0xDD, 0x15, 0x0E, 0x74, 0x55, 0xDD, + 0x16, 0x0E, 0x74, 0x55, 0xDD, 0x17, 0x0E, 0x74, 0x55, 0xDD, 0x18, 0x0E, 0x76, 0x57, 0xDD, 0x19, 0x0E, 0x6E, 0x4D, 0xDB, + 0x1A, 0x0E, 0x4F, 0x2A, 0xD0, 0x1B, 0x0E, 0x58, 0x2F, 0xE4, 0x1C, 0x0E, 0x3A, 0x2B, 0x6D, 0x0A, 0x0F, 0x15, 0x13, 0x1D, + 0x0B, 0x0F, 0x5C, 0x39, 0xD3, 0x0C, 0x0F, 0x4A, 0x22, 0xD4, 0x0D, 0x0F, 0x6F, 0x4F, 0xDA, 0x0E, 0x0F, 0x9D, 0x87, 0xE6, + 0x0F, 0x0F, 0x97, 0x80, 0xE5, 0x10, 0x0F, 0x99, 0x83, 0xE5, 0x11, 0x0F, 0x7C, 0x5F, 0xDF, 0x12, 0x0F, 0x72, 0x53, 0xDD, + 0x13, 0x0F, 0x74, 0x56, 0xDD, 0x14, 0x0F, 0x74, 0x55, 0xDD, 0x15, 0x0F, 0x74, 0x55, 0xDD, 0x16, 0x0F, 0x74, 0x55, 0xDD, + 0x17, 0x0F, 0x74, 0x55, 0xDD, 0x18, 0x0F, 0x73, 0x54, 0xDD, 0x19, 0x0F, 0x77, 0x58, 0xDE, 0x1A, 0x0F, 0x60, 0x3D, 0xD7, + 0x1B, 0x0F, 0x4C, 0x26, 0xD4, 0x1C, 0x0F, 0x5C, 0x38, 0xD3, 0x1D, 0x0F, 0x15, 0x13, 0x1D, 0x0A, 0x10, 0x47, 0x32, 0x8E, + 0x0B, 0x10, 0x54, 0x2B, 0xE1, 0x0C, 0x10, 0x55, 0x30, 0xD2, 0x0D, 0x10, 0x92, 0x7A, 0xE4, 0x0E, 0x10, 0x98, 0x82, 0xE5, + 0x0F, 0x10, 0x96, 0x7F, 0xE5, 0x10, 0x10, 0x99, 0x82, 0xE5, 0x11, 0x10, 0x8F, 0x77, 0xE3, 0x12, 0x10, 0x73, 0x54, 0xDD, + 0x13, 0x10, 0x74, 0x55, 0xDD, 0x14, 0x10, 0x74, 0x55, 0xDD, 0x15, 0x10, 0x74, 0x55, 0xDD, 0x16, 0x10, 0x74, 0x55, 0xDD, + 0x17, 0x10, 0x74, 0x55, 0xDD, 0x18, 0x10, 0x74, 0x55, 0xDD, 0x19, 0x10, 0x75, 0x56, 0xDD, 0x1A, 0x10, 0x71, 0x52, 0xDC, + 0x1B, 0x10, 0x52, 0x2D, 0xD1, 0x1C, 0x10, 0x54, 0x2B, 0xE2, 0x1D, 0x10, 0x47, 0x31, 0x8E, 0x09, 0x11, 0x23, 0x1D, 0x37, + 0x0A, 0x11, 0x5C, 0x36, 0xDD, 0x0B, 0x11, 0x4A, 0x23, 0xD1, 0x0C, 0x11, 0x79, 0x5C, 0xDE, 0x0D, 0x11, 0x9C, 0x86, 0xE6, + 0x0E, 0x11, 0x96, 0x7F, 0xE5, 0x0F, 0x11, 0x97, 0x80, 0xE5, 0x10, 0x11, 0x96, 0x7F, 0xE5, 0x11, 0x11, 0x99, 0x83, 0xE6, + 0x12, 0x11, 0x81, 0x65, 0xE0, 0x13, 0x11, 0x72, 0x52, 0xDC, 0x14, 0x11, 0x75, 0x56, 0xDD, 0x15, 0x11, 0x74, 0x55, 0xDD, + 0x16, 0x11, 0x74, 0x55, 0xDD, 0x17, 0x11, 0x74, 0x55, 0xDD, 0x18, 0x11, 0x74, 0x55, 0xDD, 0x19, 0x11, 0x73, 0x54, 0xDD, + 0x1A, 0x11, 0x77, 0x58, 0xDE, 0x1B, 0x11, 0x65, 0x43, 0xD9, 0x1C, 0x11, 0x4C, 0x26, 0xD1, 0x1D, 0x11, 0x5B, 0x35, 0xDE, + 0x1E, 0x11, 0x20, 0x1A, 0x34, 0x08, 0x12, 0x05, 0x06, 0x02, 0x09, 0x12, 0x54, 0x39, 0xAF, 0x0A, 0x12, 0x50, 0x26, 0xDD, + 0x0B, 0x12, 0x5D, 0x3A, 0xD5, 0x0C, 0x12, 0x98, 0x81, 0xE5, 0x0D, 0x12, 0x97, 0x80, 0xE5, 0x0E, 0x12, 0x97, 0x80, 0xE5, + 0x0F, 0x12, 0x97, 0x80, 0xE5, 0x10, 0x12, 0x97, 0x80, 0xE5, 0x11, 0x12, 0x98, 0x81, 0xE5, 0x12, 0x12, 0x94, 0x7C, 0xE4, + 0x13, 0x12, 0x76, 0x57, 0xDD, 0x14, 0x12, 0x74, 0x54, 0xDD, 0x15, 0x12, 0x74, 0x55, 0xDD, 0x16, 0x12, 0x74, 0x55, 0xDD, + 0x17, 0x12, 0x74, 0x55, 0xDD, 0x18, 0x12, 0x74, 0x55, 0xDD, 0x19, 0x12, 0x74, 0x55, 0xDD, 0x1A, 0x12, 0x74, 0x55, 0xDD, + 0x1B, 0x12, 0x74, 0x55, 0xDD, 0x1C, 0x12, 0x57, 0x32, 0xD3, 0x1D, 0x12, 0x51, 0x29, 0xDD, 0x1E, 0x12, 0x50, 0x34, 0xAB, + 0x1F, 0x12, 0x05, 0x07, 0x03, 0x08, 0x13, 0x35, 0x2A, 0x5A, 0x09, 0x13, 0x5A, 0x32, 0xE3, 0x0A, 0x13, 0x4B, 0x25, 0xCF, + 0x0B, 0x13, 0x84, 0x68, 0xE0, 0x0C, 0x13, 0x9B, 0x85, 0xE6, 0x0D, 0x13, 0x96, 0x7F, 0xE5, 0x0E, 0x13, 0x97, 0x80, 0xE5, + 0x0F, 0x13, 0x97, 0x80, 0xE5, 0x10, 0x13, 0x97, 0x80, 0xE5, 0x11, 0x13, 0x96, 0x7F, 0xE5, 0x12, 0x13, 0x9A, 0x83, 0xE6, + 0x13, 0x13, 0x87, 0x6C, 0xE1, 0x14, 0x13, 0x72, 0x52, 0xDC, 0x15, 0x13, 0x75, 0x56, 0xDD, 0x16, 0x13, 0x74, 0x55, 0xDD, + 0x17, 0x13, 0x74, 0x55, 0xDD, 0x18, 0x13, 0x74, 0x55, 0xDD, 0x19, 0x13, 0x74, 0x55, 0xDD, 0x1A, 0x13, 0x74, 0x54, 0xDD, + 0x1B, 0x13, 0x76, 0x58, 0xDE, 0x1C, 0x13, 0x6A, 0x49, 0xDB, 0x1D, 0x13, 0x4D, 0x27, 0xD0, 0x1E, 0x13, 0x5B, 0x33, 0xE5, + 0x1F, 0x13, 0x31, 0x26, 0x56, 0x07, 0x14, 0x0C, 0x0C, 0x0E, 0x08, 0x14, 0x5B, 0x3A, 0xC7, 0x09, 0x14, 0x4C, 0x24, 0xD7, + 0x0A, 0x14, 0x68, 0x47, 0xD8, 0x0B, 0x14, 0x9B, 0x85, 0xE6, 0x0C, 0x14, 0x96, 0x7F, 0xE5, 0x0D, 0x14, 0x97, 0x80, 0xE5, + 0x0E, 0x14, 0x97, 0x80, 0xE5, 0x0F, 0x14, 0x97, 0x80, 0xE5, 0x10, 0x14, 0x97, 0x80, 0xE5, 0x11, 0x14, 0x97, 0x80, 0xE5, + 0x12, 0x14, 0x97, 0x80, 0xE5, 0x13, 0x14, 0x97, 0x80, 0xE5, 0x14, 0x14, 0x79, 0x5B, 0xDE, 0x15, 0x14, 0x73, 0x54, 0xDD, + 0x16, 0x14, 0x74, 0x56, 0xDD, 0x17, 0x14, 0x74, 0x55, 0xDD, 0x18, 0x14, 0x74, 0x55, 0xDD, 0x19, 0x14, 0x74, 0x55, 0xDD, + 0x1A, 0x14, 0x74, 0x55, 0xDD, 0x1B, 0x14, 0x74, 0x54, 0xDD, 0x1C, 0x14, 0x76, 0x58, 0xDE, 0x1D, 0x14, 0x5C, 0x39, 0xD5, + 0x1E, 0x14, 0x4E, 0x26, 0xD7, 0x1F, 0x14, 0x58, 0x38, 0xC5, 0x20, 0x14, 0x10, 0x0F, 0x11, 0x07, 0x15, 0x3C, 0x2B, 0x74, + 0x08, 0x15, 0x57, 0x2E, 0xE3, 0x09, 0x15, 0x4D, 0x27, 0xD0, 0x0A, 0x15, 0x8A, 0x70, 0xE2, 0x0B, 0x15, 0x97, 0x7F, 0xE5, + 0x0C, 0x15, 0x95, 0x7E, 0xE5, 0x0D, 0x15, 0x96, 0x7F, 0xE5, 0x0E, 0x15, 0x96, 0x7F, 0xE5, 0x0F, 0x15, 0x96, 0x7F, 0xE5, + 0x10, 0x15, 0x96, 0x7F, 0xE5, 0x11, 0x15, 0x96, 0x7F, 0xE5, 0x12, 0x15, 0x95, 0x7E, 0xE5, 0x13, 0x15, 0x98, 0x82, 0xE5, + 0x14, 0x15, 0x8B, 0x71, 0xE2, 0x15, 0x15, 0x71, 0x51, 0xDC, 0x16, 0x15, 0x73, 0x54, 0xDD, 0x17, 0x15, 0x73, 0x54, 0xDD, + 0x18, 0x15, 0x73, 0x54, 0xDD, 0x19, 0x15, 0x73, 0x54, 0xDD, 0x1A, 0x15, 0x73, 0x54, 0xDD, 0x1B, 0x15, 0x73, 0x53, 0xDD, + 0x1C, 0x15, 0x74, 0x55, 0xDD, 0x1D, 0x15, 0x6E, 0x4D, 0xDC, 0x1E, 0x15, 0x4E, 0x29, 0xD0, 0x1F, 0x15, 0x58, 0x30, 0xE5, + 0x20, 0x15, 0x3F, 0x2E, 0x78, 0x06, 0x16, 0x18, 0x14, 0x22, 0x07, 0x16, 0x5B, 0x37, 0xD4, 0x08, 0x16, 0x47, 0x1E, 0xD3, + 0x09, 0x16, 0x85, 0x6A, 0xE0, 0x0A, 0x16, 0xD3, 0xCA, 0xF4, 0x0B, 0x16, 0xC2, 0xB5, 0xF0, 0x0C, 0x16, 0xB0, 0xA0, 0xEC, + 0x0D, 0x16, 0xB2, 0xA1, 0xEC, 0x0E, 0x16, 0xB2, 0xA1, 0xEC, 0x0F, 0x16, 0xB2, 0xA1, 0xEC, 0x10, 0x16, 0xB2, 0xA1, 0xEC, + 0x11, 0x16, 0xB2, 0xA1, 0xEC, 0x12, 0x16, 0xB2, 0xA1, 0xEC, 0x13, 0x16, 0xB1, 0xA0, 0xEC, 0x14, 0x16, 0xB3, 0xA3, 0xED, + 0x15, 0x16, 0x99, 0x82, 0xE6, 0x16, 0x16, 0x8E, 0x74, 0xE3, 0x17, 0x16, 0x90, 0x77, 0xE3, 0x18, 0x16, 0x8F, 0x77, 0xE3, + 0x19, 0x16, 0x8F, 0x77, 0xE3, 0x1A, 0x16, 0x8F, 0x77, 0xE3, 0x1B, 0x16, 0x8F, 0x77, 0xE3, 0x1C, 0x16, 0x8E, 0x76, 0xE3, + 0x1D, 0x16, 0x94, 0x7C, 0xE4, 0x1E, 0x16, 0x70, 0x50, 0xDB, 0x1F, 0x16, 0x4A, 0x23, 0xD3, 0x20, 0x16, 0x59, 0x35, 0xD3, + 0x21, 0x16, 0x18, 0x14, 0x22, 0x06, 0x17, 0x4C, 0x35, 0x98, 0x07, 0x17, 0x52, 0x28, 0xE0, 0x08, 0x17, 0x5C, 0x39, 0xD4, + 0x09, 0x17, 0xD6, 0xCE, 0xF5, 0x0A, 0x17, 0xE2, 0xDC, 0xF7, 0x0B, 0x17, 0xE0, 0xDB, 0xF7, 0x0C, 0x17, 0xC8, 0xBC, 0xF2, + 0x0D, 0x17, 0xB8, 0xA9, 0xEE, 0x0E, 0x17, 0xBB, 0xAD, 0xEE, 0x0F, 0x17, 0xBB, 0xAC, 0xEE, 0x10, 0x17, 0xBB, 0xAC, 0xEE, + 0x11, 0x17, 0xBB, 0xAC, 0xEE, 0x12, 0x17, 0xBB, 0xAC, 0xEE, 0x13, 0x17, 0xBA, 0xAC, 0xEE, 0x14, 0x17, 0xBC, 0xAE, 0xEF, + 0x15, 0x17, 0xB4, 0xA5, 0xED, 0x16, 0x17, 0x98, 0x81, 0xE5, 0x17, 0x17, 0x99, 0x82, 0xE5, 0x18, 0x17, 0x99, 0x82, 0xE5, + 0x19, 0x17, 0x99, 0x82, 0xE5, 0x1A, 0x17, 0x99, 0x82, 0xE5, 0x1B, 0x17, 0x99, 0x82, 0xE5, 0x1C, 0x17, 0x98, 0x82, 0xE5, + 0x1D, 0x17, 0x9A, 0x83, 0xE6, 0x1E, 0x17, 0x94, 0x7D, 0xE4, 0x1F, 0x17, 0x57, 0x32, 0xD2, 0x20, 0x17, 0x53, 0x2A, 0xE1, + 0x21, 0x17, 0x4B, 0x34, 0x98, 0x05, 0x18, 0x25, 0x1D, 0x3C, 0x06, 0x18, 0x5D, 0x37, 0xE0, 0x07, 0x18, 0x45, 0x1D, 0xCF, + 0x08, 0x18, 0xA7, 0x93, 0xE9, 0x09, 0x18, 0xE6, 0xE1, 0xF8, 0x0A, 0x18, 0xD9, 0xD1, 0xF5, 0x0B, 0x18, 0xDC, 0xD5, 0xF6, + 0x0C, 0x18, 0xD9, 0xD2, 0xF5, 0x0D, 0x18, 0xBB, 0xAC, 0xEE, 0x0E, 0x18, 0xB8, 0xA9, 0xEE, 0x0F, 0x18, 0xB9, 0xAA, 0xEE, + 0x10, 0x18, 0xB9, 0xAA, 0xEE, 0x11, 0x18, 0xB9, 0xAA, 0xEE, 0x12, 0x18, 0xB9, 0xAA, 0xEE, 0x13, 0x18, 0xB9, 0xAA, 0xEE, + 0x14, 0x18, 0xB8, 0xA9, 0xEE, 0x15, 0x18, 0xBB, 0xAD, 0xEF, 0x16, 0x18, 0xA5, 0x91, 0xE9, 0x17, 0x18, 0x94, 0x7D, 0xE4, + 0x18, 0x18, 0x97, 0x80, 0xE5, 0x19, 0x18, 0x97, 0x80, 0xE5, 0x1A, 0x18, 0x97, 0x80, 0xE5, 0x1B, 0x18, 0x97, 0x80, 0xE5, + 0x1C, 0x18, 0x97, 0x80, 0xE5, 0x1D, 0x18, 0x95, 0x7E, 0xE5, 0x1E, 0x18, 0x9C, 0x86, 0xE6, 0x1F, 0x18, 0x7D, 0x60, 0xDE, + 0x20, 0x18, 0x4A, 0x23, 0xD0, 0x21, 0x18, 0x5C, 0x35, 0xE0, 0x22, 0x18, 0x27, 0x20, 0x3F, 0x04, 0x19, 0x0A, 0x0B, 0x08, + 0x05, 0x19, 0x55, 0x39, 0xB5, 0x06, 0x19, 0x4B, 0x21, 0xDA, 0x07, 0x19, 0x6E, 0x4E, 0xD9, 0x08, 0x19, 0xDF, 0xD9, 0xF7, + 0x09, 0x19, 0xDB, 0xD4, 0xF6, 0x0A, 0x19, 0xDC, 0xD5, 0xF6, 0x0B, 0x19, 0xDB, 0xD4, 0xF6, 0x0C, 0x19, 0xDE, 0xD8, 0xF7, + 0x0D, 0x19, 0xCE, 0xC4, 0xF3, 0x0E, 0x19, 0xB7, 0xA7, 0xED, 0x0F, 0x19, 0xB9, 0xAB, 0xEE, 0x10, 0x19, 0xB9, 0xAA, 0xEE, + 0x11, 0x19, 0xB9, 0xAA, 0xEE, 0x12, 0x19, 0xB9, 0xAA, 0xEE, 0x13, 0x19, 0xB9, 0xAA, 0xEE, 0x14, 0x19, 0xB9, 0xAA, 0xEE, + 0x15, 0x19, 0xBA, 0xAB, 0xEE, 0x16, 0x19, 0xB7, 0xA7, 0xED, 0x17, 0x19, 0x99, 0x83, 0xE6, 0x18, 0x19, 0x96, 0x7F, 0xE5, + 0x19, 0x19, 0x97, 0x80, 0xE5, 0x1A, 0x19, 0x97, 0x80, 0xE5, 0x1B, 0x19, 0x97, 0x80, 0xE5, 0x1C, 0x19, 0x97, 0x80, 0xE5, + 0x1D, 0x19, 0x97, 0x80, 0xE5, 0x1E, 0x19, 0x97, 0x80, 0xE5, 0x1F, 0x19, 0x99, 0x82, 0xE5, 0x20, 0x19, 0x5F, 0x3D, 0xD6, + 0x21, 0x19, 0x4E, 0x25, 0xDA, 0x22, 0x19, 0x59, 0x3C, 0xB9, 0x23, 0x19, 0x0B, 0x0B, 0x09, 0x04, 0x1A, 0x3B, 0x2E, 0x65, + 0x05, 0x1A, 0x5C, 0x34, 0xE6, 0x06, 0x1A, 0x4A, 0x22, 0xCE, 0x07, 0x1A, 0xBB, 0xAC, 0xEE, 0x08, 0x1A, 0xE3, 0xDE, 0xF8, + 0x09, 0x1A, 0xDA, 0xD3, 0xF6, 0x0A, 0x1A, 0xDC, 0xD5, 0xF6, 0x0B, 0x1A, 0xDC, 0xD5, 0xF6, 0x0C, 0x1A, 0xDC, 0xD5, 0xF6, + 0x0D, 0x1A, 0xDD, 0xD6, 0xF6, 0x0E, 0x1A, 0xC1, 0xB4, 0xF0, 0x0F, 0x1A, 0xB7, 0xA8, 0xEE, 0x10, 0x1A, 0xB9, 0xAB, 0xEE, + 0x11, 0x1A, 0xB9, 0xAA, 0xEE, 0x12, 0x1A, 0xB9, 0xAA, 0xEE, 0x13, 0x1A, 0xB9, 0xAA, 0xEE, 0x14, 0x1A, 0xB9, 0xAA, 0xEE, + 0x15, 0x1A, 0xB8, 0xA9, 0xEE, 0x16, 0x1A, 0xBB, 0xAD, 0xEF, 0x17, 0x1A, 0xAA, 0x98, 0xEA, 0x18, 0x1A, 0x95, 0x7D, 0xE4, + 0x19, 0x1A, 0x98, 0x81, 0xE5, 0x1A, 0x1A, 0x97, 0x80, 0xE5, 0x1B, 0x1A, 0x97, 0x80, 0xE5, 0x1C, 0x1A, 0x97, 0x80, 0xE5, + 0x1D, 0x1A, 0x97, 0x80, 0xE5, 0x1E, 0x1A, 0x96, 0x7F, 0xE5, 0x1F, 0x1A, 0x9B, 0x84, 0xE6, 0x20, 0x1A, 0x86, 0x6C, 0xE1, + 0x21, 0x1A, 0x4C, 0x26, 0xCF, 0x22, 0x1A, 0x5B, 0x33, 0xE6, 0x23, 0x1A, 0x38, 0x2B, 0x61, 0x03, 0x1B, 0x13, 0x12, 0x16, + 0x04, 0x1B, 0x5E, 0x3C, 0xCE, 0x05, 0x1B, 0x46, 0x1C, 0xD4, 0x06, 0x1B, 0x82, 0x65, 0xDF, 0x07, 0x1B, 0xE5, 0xE0, 0xF8, + 0x08, 0x1B, 0xDA, 0xD2, 0xF5, 0x09, 0x1B, 0xDC, 0xD5, 0xF6, 0x0A, 0x1B, 0xDC, 0xD5, 0xF6, 0x0B, 0x1B, 0xDC, 0xD5, 0xF6, + 0x0C, 0x1B, 0xDC, 0xD5, 0xF6, 0x0D, 0x1B, 0xDE, 0xD7, 0xF6, 0x0E, 0x1B, 0xD5, 0xCD, 0xF4, 0x0F, 0x1B, 0xB9, 0xA9, 0xEE, + 0x10, 0x1B, 0xB9, 0xAA, 0xEE, 0x11, 0x1B, 0xB9, 0xAA, 0xEE, 0x12, 0x1B, 0xB9, 0xAA, 0xEE, 0x13, 0x1B, 0xB9, 0xAA, 0xEE, + 0x14, 0x1B, 0xB9, 0xAA, 0xEE, 0x15, 0x1B, 0xB9, 0xAA, 0xEE, 0x16, 0x1B, 0xB9, 0xAA, 0xEE, 0x17, 0x1B, 0xB9, 0xAA, 0xEE, + 0x18, 0x1B, 0x9D, 0x87, 0xE7, 0x19, 0x1B, 0x96, 0x7E, 0xE5, 0x1A, 0x1B, 0x97, 0x80, 0xE5, 0x1B, 0x1B, 0x97, 0x80, 0xE5, + 0x1C, 0x1B, 0x97, 0x80, 0xE5, 0x1D, 0x1B, 0x97, 0x80, 0xE5, 0x1E, 0x1B, 0x97, 0x80, 0xE5, 0x1F, 0x1B, 0x96, 0x7F, 0xE5, + 0x20, 0x1B, 0x9B, 0x85, 0xE6, 0x21, 0x1B, 0x6A, 0x49, 0xD9, 0x22, 0x1B, 0x4B, 0x22, 0xD6, 0x23, 0x1B, 0x59, 0x39, 0xCA, + 0x24, 0x1B, 0x0F, 0x0F, 0x13, 0x03, 0x1C, 0x45, 0x32, 0x82, 0x04, 0x1C, 0x55, 0x2C, 0xE3, 0x05, 0x1C, 0x53, 0x2E, 0xD1, + 0x06, 0x1C, 0xCC, 0xC0, 0xF2, 0x07, 0x1C, 0xE0, 0xDA, 0xF7, 0x08, 0x1C, 0xDB, 0xD4, 0xF6, 0x09, 0x1C, 0xDC, 0xD5, 0xF6, + 0x0A, 0x1C, 0xDC, 0xD5, 0xF6, 0x0B, 0x1C, 0xDC, 0xD5, 0xF6, 0x0C, 0x1C, 0xDC, 0xD5, 0xF6, 0x0D, 0x1C, 0xDB, 0xD4, 0xF6, + 0x0E, 0x1C, 0xDF, 0xD8, 0xF7, 0x0F, 0x1C, 0xC9, 0xBD, 0xF2, 0x10, 0x1C, 0xB6, 0xA7, 0xED, 0x11, 0x1C, 0xBA, 0xAB, 0xEE, + 0x12, 0x1C, 0xB9, 0xAA, 0xEE, 0x13, 0x1C, 0xB9, 0xAA, 0xEE, 0x14, 0x1C, 0xB9, 0xAA, 0xEE, 0x15, 0x1C, 0xB9, 0xAA, 0xEE, + 0x16, 0x1C, 0xB9, 0xA9, 0xEE, 0x17, 0x1C, 0xBB, 0xAD, 0xEE, 0x18, 0x1C, 0xB0, 0x9E, 0xEB, 0x19, 0x1C, 0x96, 0x7E, 0xE4, + 0x1A, 0x1C, 0x97, 0x81, 0xE5, 0x1B, 0x1C, 0x97, 0x80, 0xE5, 0x1C, 0x1C, 0x97, 0x80, 0xE5, 0x1D, 0x1C, 0x97, 0x80, 0xE5, + 0x1E, 0x1C, 0x97, 0x80, 0xE5, 0x1F, 0x1C, 0x97, 0x7F, 0xE5, 0x20, 0x1C, 0x99, 0x82, 0xE5, 0x21, 0x1C, 0x8F, 0x76, 0xE3, + 0x22, 0x1C, 0x51, 0x2D, 0xD1, 0x23, 0x1C, 0x56, 0x2C, 0xE3, 0x24, 0x1C, 0x43, 0x2F, 0x80, 0x02, 0x1D, 0x20, 0x1C, 0x2F, + 0x03, 0x1D, 0x5B, 0x36, 0xD7, 0x04, 0x1D, 0x44, 0x1B, 0xD1, 0x05, 0x1D, 0x97, 0x7F, 0xE5, 0x06, 0x1D, 0xE6, 0xE2, 0xF8, + 0x07, 0x1D, 0xD9, 0xD2, 0xF5, 0x08, 0x1D, 0xDC, 0xD5, 0xF6, 0x09, 0x1D, 0xDC, 0xD5, 0xF6, 0x0A, 0x1D, 0xDC, 0xD5, 0xF6, + 0x0B, 0x1D, 0xDC, 0xD5, 0xF6, 0x0C, 0x1D, 0xDC, 0xD5, 0xF6, 0x0D, 0x1D, 0xDC, 0xD5, 0xF6, 0x0E, 0x1D, 0xDC, 0xD5, 0xF6, + 0x0F, 0x1D, 0xDB, 0xD3, 0xF6, 0x10, 0x1D, 0xBD, 0xAF, 0xEF, 0x11, 0x1D, 0xB8, 0xA9, 0xEE, 0x12, 0x1D, 0xB9, 0xAA, 0xEE, + 0x13, 0x1D, 0xB9, 0xAA, 0xEE, 0x14, 0x1D, 0xB9, 0xAA, 0xEE, 0x15, 0x1D, 0xB9, 0xAA, 0xEE, 0x16, 0x1D, 0xB9, 0xAA, 0xEE, + 0x17, 0x1D, 0xB9, 0xA9, 0xEE, 0x18, 0x1D, 0xBB, 0xAC, 0xEF, 0x19, 0x1D, 0xA2, 0x8D, 0xE8, 0x1A, 0x1D, 0x95, 0x7D, 0xE4, + 0x1B, 0x1D, 0x97, 0x81, 0xE5, 0x1C, 0x1D, 0x97, 0x80, 0xE5, 0x1D, 0x1D, 0x97, 0x80, 0xE5, 0x1E, 0x1D, 0x97, 0x80, 0xE5, + 0x1F, 0x1D, 0x97, 0x80, 0xE5, 0x20, 0x1D, 0x96, 0x7F, 0xE5, 0x21, 0x1D, 0x9C, 0x86, 0xE6, 0x22, 0x1D, 0x74, 0x56, 0xDC, + 0x23, 0x1D, 0x49, 0x21, 0xD2, 0x24, 0x1D, 0x5E, 0x39, 0xDB, 0x25, 0x1D, 0x1F, 0x1B, 0x2E, 0x02, 0x1E, 0x4A, 0x35, 0x8B, + 0x03, 0x1E, 0x53, 0x29, 0xE3, 0x04, 0x1E, 0x62, 0x40, 0xD5, 0x05, 0x1E, 0xD7, 0xCF, 0xF5, 0x06, 0x1E, 0xDD, 0xD7, 0xF6, + 0x07, 0x1E, 0xDB, 0xD4, 0xF6, 0x08, 0x1E, 0xDC, 0xD5, 0xF6, 0x09, 0x1E, 0xDC, 0xD5, 0xF6, 0x0A, 0x1E, 0xDC, 0xD5, 0xF6, + 0x0B, 0x1E, 0xDC, 0xD5, 0xF6, 0x0C, 0x1E, 0xDC, 0xD5, 0xF6, 0x0D, 0x1E, 0xDC, 0xD5, 0xF6, 0x0E, 0x1E, 0xDB, 0xD4, 0xF6, + 0x0F, 0x1E, 0xDE, 0xD8, 0xF6, 0x10, 0x1E, 0xD0, 0xC7, 0xF3, 0x11, 0x1E, 0xB7, 0xA8, 0xEE, 0x12, 0x1E, 0xB9, 0xAB, 0xEE, + 0x13, 0x1E, 0xB9, 0xAA, 0xEE, 0x14, 0x1E, 0xB9, 0xAA, 0xEE, 0x15, 0x1E, 0xB9, 0xAA, 0xEE, 0x16, 0x1E, 0xB9, 0xAA, 0xEE, + 0x17, 0x1E, 0xB9, 0xAA, 0xEE, 0x18, 0x1E, 0xBA, 0xAB, 0xEE, 0x19, 0x1E, 0xB4, 0xA3, 0xED, 0x1A, 0x1E, 0x98, 0x81, 0xE5, + 0x1B, 0x1E, 0x97, 0x80, 0xE5, 0x1C, 0x1E, 0x97, 0x80, 0xE5, 0x1D, 0x1E, 0x97, 0x80, 0xE5, 0x1E, 0x1E, 0x97, 0x80, 0xE5, + 0x1F, 0x1E, 0x97, 0x80, 0xE5, 0x20, 0x1E, 0x97, 0x80, 0xE5, 0x21, 0x1E, 0x98, 0x81, 0xE5, 0x22, 0x1E, 0x95, 0x7D, 0xE4, + 0x23, 0x1E, 0x59, 0x36, 0xD3, 0x24, 0x1E, 0x54, 0x2A, 0xE3, 0x25, 0x1E, 0x46, 0x31, 0x87, 0x02, 0x1F, 0x39, 0x21, 0x8D, + 0x03, 0x1F, 0x4B, 0x20, 0xDD, 0x04, 0x1F, 0x8F, 0x75, 0xE1, 0x05, 0x1F, 0xE9, 0xE6, 0xF9, 0x06, 0x1F, 0xD8, 0xCF, 0xF5, + 0x07, 0x1F, 0xDB, 0xD4, 0xF6, 0x08, 0x1F, 0xDB, 0xD4, 0xF6, 0x09, 0x1F, 0xDB, 0xD4, 0xF6, 0x0A, 0x1F, 0xDB, 0xD4, 0xF6, + 0x0B, 0x1F, 0xDB, 0xD4, 0xF6, 0x0C, 0x1F, 0xDB, 0xD4, 0xF6, 0x0D, 0x1F, 0xDB, 0xD4, 0xF6, 0x0E, 0x1F, 0xDB, 0xD4, 0xF6, + 0x0F, 0x1F, 0xDB, 0xD4, 0xF6, 0x10, 0x1F, 0xDB, 0xD4, 0xF6, 0x11, 0x1F, 0xBB, 0xAD, 0xEF, 0x12, 0x1F, 0xB7, 0xA8, 0xEE, + 0x13, 0x1F, 0xB8, 0xA9, 0xEE, 0x14, 0x1F, 0xB8, 0xA9, 0xEE, 0x15, 0x1F, 0xB8, 0xA9, 0xEE, 0x16, 0x1F, 0xB8, 0xA9, 0xEE, + 0x17, 0x1F, 0xB8, 0xA9, 0xEE, 0x18, 0x1F, 0xB8, 0xA9, 0xEE, 0x19, 0x1F, 0xBB, 0xAD, 0xEE, 0x1A, 0x1F, 0x9F, 0x8A, 0xE7, + 0x1B, 0x1F, 0x94, 0x7D, 0xE5, 0x1C, 0x1F, 0x96, 0x80, 0xE5, 0x1D, 0x1F, 0x96, 0x7F, 0xE5, 0x1E, 0x1F, 0x96, 0x7F, 0xE5, + 0x1F, 0x1F, 0x96, 0x7F, 0xE5, 0x20, 0x1F, 0x96, 0x7F, 0xE5, 0x21, 0x1F, 0x95, 0x7E, 0xE5, 0x22, 0x1F, 0x9E, 0x88, 0xE6, + 0x23, 0x1F, 0x70, 0x51, 0xD9, 0x24, 0x1F, 0x50, 0x27, 0xE0, 0x25, 0x1F, 0x39, 0x21, 0x8E, 0x02, 0x20, 0x47, 0x30, 0x8F, + 0x03, 0x20, 0x50, 0x26, 0xE1, 0x04, 0x20, 0x69, 0x49, 0xD6, 0x05, 0x20, 0xD6, 0xCE, 0xF5, 0x06, 0x20, 0xE3, 0xDE, 0xF8, + 0x07, 0x20, 0xE1, 0xDB, 0xF7, 0x08, 0x20, 0xE2, 0xDC, 0xF7, 0x09, 0x20, 0xE2, 0xDC, 0xF7, 0x0A, 0x20, 0xE2, 0xDC, 0xF7, + 0x0B, 0x20, 0xE2, 0xDC, 0xF7, 0x0C, 0x20, 0xE2, 0xDC, 0xF7, 0x0D, 0x20, 0xE2, 0xDC, 0xF7, 0x0E, 0x20, 0xE1, 0xDB, 0xF7, + 0x0F, 0x20, 0xE3, 0xDD, 0xF8, 0x10, 0x20, 0xD5, 0xCD, 0xF5, 0x11, 0x20, 0xBD, 0xAE, 0xEF, 0x12, 0x20, 0xBD, 0xAF, 0xEF, + 0x13, 0x20, 0xBD, 0xAF, 0xEF, 0x14, 0x20, 0xBD, 0xAF, 0xEF, 0x15, 0x20, 0xBD, 0xAF, 0xEF, 0x16, 0x20, 0xBD, 0xAF, 0xEF, + 0x17, 0x20, 0xBD, 0xAF, 0xEF, 0x18, 0x20, 0xBE, 0xB0, 0xEF, 0x19, 0x20, 0xB7, 0xA7, 0xED, 0x1A, 0x20, 0x9C, 0x86, 0xE6, + 0x1B, 0x20, 0x99, 0x83, 0xE6, 0x1C, 0x20, 0x9A, 0x83, 0xE6, 0x1D, 0x20, 0x9A, 0x83, 0xE6, 0x1E, 0x20, 0x9A, 0x83, 0xE6, + 0x1F, 0x20, 0x9A, 0x83, 0xE6, 0x20, 0x20, 0x9A, 0x83, 0xE6, 0x21, 0x20, 0x9A, 0x84, 0xE6, 0x22, 0x20, 0x94, 0x7C, 0xE5, + 0x23, 0x20, 0x5D, 0x3A, 0xD3, 0x24, 0x20, 0x52, 0x29, 0xE1, 0x25, 0x20, 0x46, 0x30, 0x8E, 0x02, 0x21, 0x2C, 0x25, 0x42, + 0x03, 0x21, 0x60, 0x39, 0xE4, 0x04, 0x21, 0x49, 0x21, 0xD3, 0x05, 0x21, 0x5E, 0x3B, 0xD3, 0x06, 0x21, 0x73, 0x56, 0xDA, + 0x07, 0x21, 0x71, 0x53, 0xD9, 0x08, 0x21, 0x71, 0x53, 0xD9, 0x09, 0x21, 0x71, 0x53, 0xD9, 0x0A, 0x21, 0x71, 0x53, 0xD9, + 0x0B, 0x21, 0x71, 0x53, 0xD9, 0x0C, 0x21, 0x71, 0x53, 0xD9, 0x0D, 0x21, 0x71, 0x53, 0xD9, 0x0E, 0x21, 0x71, 0x53, 0xD9, + 0x0F, 0x21, 0x71, 0x52, 0xD9, 0x10, 0x21, 0x6A, 0x48, 0xD7, 0x11, 0x21, 0x69, 0x46, 0xD7, 0x12, 0x21, 0x69, 0x47, 0xD7, + 0x13, 0x21, 0x69, 0x47, 0xD7, 0x14, 0x21, 0x69, 0x47, 0xD7, 0x15, 0x21, 0x69, 0x47, 0xD7, 0x16, 0x21, 0x69, 0x47, 0xD7, + 0x17, 0x21, 0x69, 0x47, 0xD7, 0x18, 0x21, 0x69, 0x47, 0xD7, 0x19, 0x21, 0x63, 0x41, 0xD5, 0x1A, 0x21, 0x60, 0x3E, 0xD4, + 0x1B, 0x21, 0x61, 0x3F, 0xD5, 0x1C, 0x21, 0x61, 0x3F, 0xD5, 0x1D, 0x21, 0x61, 0x3F, 0xD5, 0x1E, 0x21, 0x61, 0x3F, 0xD5, + 0x1F, 0x21, 0x61, 0x3F, 0xD5, 0x20, 0x21, 0x61, 0x3E, 0xD5, 0x21, 0x21, 0x62, 0x40, 0xD5, 0x22, 0x21, 0x57, 0x33, 0xD1, + 0x23, 0x21, 0x4B, 0x24, 0xD3, 0x24, 0x21, 0x5F, 0x38, 0xE4, 0x25, 0x21, 0x2C, 0x24, 0x42, 0x03, 0x22, 0x3C, 0x2F, 0x68, + 0x04, 0x22, 0x5C, 0x37, 0xDB, 0x05, 0x22, 0x55, 0x2B, 0xE3, 0x06, 0x22, 0x4F, 0x25, 0xE1, 0x07, 0x22, 0x50, 0x25, 0xE2, + 0x08, 0x22, 0x50, 0x25, 0xE2, 0x09, 0x22, 0x50, 0x25, 0xE2, 0x0A, 0x22, 0x50, 0x25, 0xE2, 0x0B, 0x22, 0x50, 0x25, 0xE2, + 0x0C, 0x22, 0x50, 0x25, 0xE2, 0x0D, 0x22, 0x50, 0x25, 0xE2, 0x0E, 0x22, 0x50, 0x25, 0xE2, 0x0F, 0x22, 0x50, 0x25, 0xE2, + 0x10, 0x22, 0x52, 0x27, 0xE2, 0x11, 0x22, 0x52, 0x27, 0xE2, 0x12, 0x22, 0x52, 0x27, 0xE2, 0x13, 0x22, 0x52, 0x27, 0xE2, + 0x14, 0x22, 0x52, 0x27, 0xE2, 0x15, 0x22, 0x52, 0x27, 0xE2, 0x16, 0x22, 0x52, 0x27, 0xE2, 0x17, 0x22, 0x52, 0x27, 0xE2, + 0x18, 0x22, 0x52, 0x27, 0xE2, 0x19, 0x22, 0x53, 0x29, 0xE3, 0x1A, 0x22, 0x54, 0x2A, 0xE3, 0x1B, 0x22, 0x53, 0x29, 0xE3, + 0x1C, 0x22, 0x53, 0x29, 0xE3, 0x1D, 0x22, 0x53, 0x29, 0xE3, 0x1E, 0x22, 0x53, 0x29, 0xE3, 0x1F, 0x22, 0x53, 0x29, 0xE3, + 0x20, 0x22, 0x54, 0x29, 0xE3, 0x21, 0x22, 0x53, 0x29, 0xE2, 0x22, 0x22, 0x56, 0x2D, 0xE3, 0x23, 0x22, 0x5C, 0x36, 0xDB, + 0x24, 0x22, 0x3C, 0x2F, 0x68, 0x04, 0x23, 0x19, 0x15, 0x26, 0x05, 0x23, 0x33, 0x23, 0x65, 0x06, 0x23, 0x2B, 0x1B, 0x65, + 0x07, 0x23, 0x2C, 0x1A, 0x64, 0x08, 0x23, 0x2C, 0x1A, 0x65, 0x09, 0x23, 0x2C, 0x1A, 0x65, 0x0A, 0x23, 0x2C, 0x1A, 0x65, + 0x0B, 0x23, 0x2C, 0x1A, 0x65, 0x0C, 0x23, 0x2C, 0x1A, 0x65, 0x0D, 0x23, 0x2C, 0x1A, 0x65, 0x0E, 0x23, 0x2C, 0x1A, 0x65, + 0x0F, 0x23, 0x2C, 0x1A, 0x65, 0x10, 0x23, 0x2C, 0x1A, 0x64, 0x11, 0x23, 0x2C, 0x1A, 0x64, 0x12, 0x23, 0x2C, 0x1A, 0x64, + 0x13, 0x23, 0x2C, 0x1A, 0x64, 0x14, 0x23, 0x2C, 0x1A, 0x64, 0x15, 0x23, 0x2C, 0x1A, 0x64, 0x16, 0x23, 0x2C, 0x1A, 0x64, + 0x17, 0x23, 0x2C, 0x1A, 0x64, 0x18, 0x23, 0x2C, 0x1A, 0x64, 0x19, 0x23, 0x2B, 0x19, 0x64, 0x1A, 0x23, 0x2B, 0x19, 0x64, + 0x1B, 0x23, 0x2B, 0x19, 0x64, 0x1C, 0x23, 0x2B, 0x19, 0x64, 0x1D, 0x23, 0x2B, 0x19, 0x64, 0x1E, 0x23, 0x2B, 0x19, 0x64, + 0x1F, 0x23, 0x2B, 0x19, 0x64, 0x20, 0x23, 0x2B, 0x19, 0x64, 0x21, 0x23, 0x2B, 0x1A, 0x65, 0x22, 0x23, 0x32, 0x23, 0x65, + 0x23, 0x23, 0x1A, 0x16, 0x26 + ]; +} diff --git a/src/Aspire.Cli/UI/AspireAtopTui.cs b/src/Aspire.Cli/UI/AspireAtopTui.cs new file mode 100644 index 00000000000..0fd06f871ab --- /dev/null +++ b/src/Aspire.Cli/UI/AspireAtopTui.cs @@ -0,0 +1,988 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Resources; +using Aspire.Cli.Utils; +using Hex1b; +using Hex1b.Layout; +using Hex1b.Logging; +using Hex1b.Widgets; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.UI; + +/// +/// Represents a summary of resource counts for an AppHost. +/// +internal sealed class ResourceSummary +{ + public int TotalCount { get; set; } + public int RunningCount { get; set; } +} + +/// +/// Represents an AppHost entry in the drawer list. +/// +internal sealed class AppHostEntry +{ + public required string DisplayName { get; init; } + public required string FullPath { get; init; } + public required IAppHostAuxiliaryBackchannel Connection { get; set; } + public bool IsOffline { get; set; } + public string? Branch { get; set; } + public ResourceSummary? Summary { get; set; } + public string? DashboardUrl { get; set; } + public string? AspireVersion { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public string? Pid { get; set; } + public string? RepositoryRoot { get; set; } +} + +/// +/// Main TUI for the aspire monitor command. +/// +internal sealed class AspireAtopTui +{ + private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly ILogger _logger; + + // Mutable state — captured by closures in the widget builder + private List _appHosts = []; + private int _selectedAppHostIndex; + private readonly Dictionary _resources = []; + private bool _isConnecting; + private string? _errorMessage; + private bool _showSplash = true; + private readonly AspireAtopSplash _splash = new(); + private object? _focusedResourceKey; + private object? _focusedParameterKey; + private CancellationTokenSource? _watchCts; + private NotificationStack? _notificationStack; + private Hex1bApp? _app; + private IHex1bLogStore? _appHostLogStore; + private ILoggerFactory? _appHostLoggerFactory; + private bool _appHostLogsAvailable; + + // Hack reveal transition after splash + private bool _revealing; + private long _revealStart; + private readonly HackRevealEffect _hackReveal = new(); + private const double RevealDurationSeconds = 4.0; + + public AspireAtopTui(IAuxiliaryBackchannelMonitor backchannelMonitor, ILogger logger) + { + _backchannelMonitor = backchannelMonitor; + _logger = logger; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + + _appHosts = _backchannelMonitor.Connections + .Where(c => c.AppHostInfo is not null) + .OrderByDescending(c => c.IsInScope) + .Select(c => new AppHostEntry + { + DisplayName = ShortenPath(c.AppHostInfo!.AppHostPath ?? "Unknown"), + FullPath = c.AppHostInfo!.AppHostPath ?? "Unknown", + Connection = c + }) + .ToList(); + + // Resolve git branches and resource summaries in the background (non-blocking) + _ = ResolveBranchesAsync(_appHosts, cancellationToken); + _ = FetchResourceSummariesAsync(_appHosts, cancellationToken); + + await using var terminal = Hex1bTerminal.CreateBuilder() + .WithDiagnostics("aspire-atop", forceEnable: true) + .WithHex1bApp((app, options) => + { + _app = app; + options.EnableDefaultCtrlCExit = true; + return BuildWidget; + }) + .WithMouse() + .Build(); + + // Animate the splash, then transition to main screen + _ = Task.Run(async () => + { + // Drive the animation by invalidating at ~30fps until all phases complete + while (!_splash.IsComplete) + { + _app?.Invalidate(); + await Task.Delay(33, cancellationToken).ConfigureAwait(false); + } + + _showSplash = false; + _revealing = true; + _revealStart = Stopwatch.GetTimestamp(); + _hackReveal.Reset(); + _app?.Invalidate(); + + if (_appHosts.Count > 0) + { + await ConnectToAppHostAsync(0, cancellationToken).ConfigureAwait(false); + } + + // Start background polling for new/offline AppHosts + _ = PollAppHostsAsync(cancellationToken); + }, cancellationToken); + + await terminal.RunAsync(cancellationToken).ConfigureAwait(false); + } + + private Hex1bWidget BuildWidget(RootContext ctx) + { + if (_showSplash) + { + return _splash.Build(ctx); + } + + if (_revealing) + { + var progress = Math.Clamp( + Stopwatch.GetElapsedTime(_revealStart).TotalSeconds / RevealDurationSeconds, 0, 1); + + if (progress >= 1.0) + { + _revealing = false; + return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: true)).Fill(); + } + + // During reveal, build without NotificationPanel (it requires ZStack context) + var revealContent = ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: false)).Fill(); + + return ctx.Surface(s => + { + _hackReveal.Update(s.Width, s.Height); + return + [ + s.WidgetLayer(revealContent), + s.Layer(_hackReveal.GetCompute(progress)) + ]; + }) + .RedrawAfter(16) + .Fill(); + } + + return ctx.ThemePanel(AspireTheme.Apply, BuildMainScreen(ctx, interactive: true)).Fill(); + } + + private Hex1bWidget BuildMainScreen(RootContext ctx, bool interactive) + { + // When no AppHosts are connected, show a waiting panel + if (_appHosts.Count == 0) + { + return BuildWaitingForAppHostsPanel(ctx, interactive); + } + + // Build right-side content depending on selected AppHost state + Hex1bWidget rightContent; + var selectedAppHost = _selectedAppHostIndex < _appHosts.Count ? _appHosts[_selectedAppHostIndex] : null; + + if (selectedAppHost?.IsOffline == true) + { + rightContent = BuildOfflinePanel(ctx, selectedAppHost); + } + else + { + // Tab panel with Resources and Parameters tabs + var tabPanel = ctx.TabPanel(tabs => [ + tabs.Tab(MonitorCommandStrings.ResourcesTab, c => BuildResourcesTab(c)), + tabs.Tab(MonitorCommandStrings.ParametersTab, c => BuildParametersTab(c)) + ]).Fill(); + + // Logger panel for AppHost logs + Hex1bWidget logPanel = _appHostLogsAvailable && _appHostLogStore is not null + ? ctx.LoggerPanel(_appHostLogStore).Fill() + : ctx.Text(" AppHost log streaming is not available in this version of the Aspire SDK.").Fill(); + + rightContent = ctx.VStack(v => [ + tabPanel, + v.DragBarPanel(logPanel).HandleEdge(DragBarEdge.Top).InitialSize(10).MinSize(3) + ]).Fill(); + } + + // NotificationPanel requires ZStack context — only use in interactive mode + var mainContent = interactive + ? ctx.NotificationPanel(rightContent).Fill() + : rightContent; + + // AppHost panels for the left pane + var appHostPanels = ctx.VStack(nav => [ + ..BuildAppHostPanels(ctx, nav) + ]).Fill(); + + // Build the content header with AppHost info + var selectedName = GetSelectedAppHostTitle(); + var dashboardUrl = selectedAppHost?.DashboardUrl; + var aspireVersion = selectedAppHost?.AspireVersion; + var startedAt = selectedAppHost?.StartedAt; + var pid = selectedAppHost?.Pid; + var repoRoot = selectedAppHost?.RepositoryRoot; + + var resourceCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)); + var runningCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase) + && string.Equals(r.State, "Running", StringComparison.OrdinalIgnoreCase)); + + var detailParts = new List(); + if (pid is not null) + { + detailParts.Add($"⚙ PID {pid}"); + } + if (aspireVersion is not null && aspireVersion != "unknown") + { + detailParts.Add($"◆ {aspireVersion}"); + } + if (startedAt is not null) + { + var uptime = DateTimeOffset.UtcNow - startedAt.Value; + detailParts.Add($"⏱ {FormatUptime(uptime)}"); + } + if (resourceCount > 0) + { + detailParts.Add($"▣ {runningCount}/{resourceCount}"); + } + var detailLine = detailParts.Count > 0 ? string.Join(" · ", detailParts) : null; + + var headerRows = new List, Hex1bWidget>>(); + + // Row 1: AppHost name + detail stats + headerRows.Add(h => h.HStack(row => [ + row.Text($"▲ {selectedName}"), + row.Text("").Fill(), + row.Text(detailLine ?? "").FixedWidth(detailLine?.Length ?? 0) + ]).FixedHeight(1)); + + // Row 2: Dashboard URL + Stop button + headerRows.Add(h => h.HStack(row => [ + row.Text("⊞ "), + dashboardUrl is not null + ? row.Hyperlink(dashboardUrl, dashboardUrl) + : (Hex1bWidget)row.Text("Dashboard: connecting..."), + row.Text("").Fill(), + row.Button(" ⏹ Stop ").OnClick(e => + { + _ = StopSelectedAppHostAsync(); + }) + ]).FixedHeight(1)); + + // Row 3: VSCode link (if repo root discovered) + if (repoRoot is not null) + { + var vscodeUrl = $"vscode://file/{Uri.EscapeDataString(repoRoot)}"; + headerRows.Add(h => h.HStack(row => [ + row.Text("⌨ "), + row.Hyperlink(vscodeUrl, repoRoot) + ]).FixedHeight(1)); + } + + var headerContent = ctx.VStack(h => + headerRows.Select(buildRow => buildRow(h)).ToArray() + ); + + var contentHeader = ctx.ThemePanel(AspireTheme.ApplyContentHeaderBorder, + ctx.Border(ctx.ThemePanel(AspireTheme.ApplyContentHeaderInner, headerContent))); + + // Main content area: header + tab content + var rightSide = ctx.VStack(r => [ + contentHeader, + mainContent + ]).Fill(); + + // Main layout: splitter with AppHost panels on the left, content on the right + var body = ctx.Padding(1, 1, 0, 0, ctx.HSplitter( + appHostPanels, + rightSide, + leftWidth: 30 + ).Fill()).Fill(); + + return ctx.VStack(outer => [ + body, + outer.InfoBar(bar => [ + bar.Section("⎋ q: " + MonitorCommandStrings.QuitShortcut), + bar.Separator(" │ "), + bar.Section("⇥ Tab: " + MonitorCommandStrings.TabShortcut), + bar.Separator(" │ "), + bar.Section(GetStatusText()).FillWidth() + ]) + ]).Fill(); + } + + private static Hex1bWidget BuildWaitingForAppHostsPanel(RootContext ctx, bool interactive) + { + var centerContent = ctx.VStack(v => [ + v.Text("").Fill(), + v.Center( + ctx.VStack(inner => [ + inner.Text(" ⏳ Waiting for AppHosts...").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" No running Aspire AppHosts detected.").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text(" Start an AppHost with 'aspire run' and it will").FixedHeight(1), + inner.Text(" appear here automatically.").FixedHeight(1) + ]) + ), + v.Text("").Fill() + ]).Fill(); + + return ctx.VStack(outer => [ + interactive ? ctx.NotificationPanel(centerContent).Fill() : centerContent, + outer.InfoBar(bar => [ + bar.Section("q: " + MonitorCommandStrings.QuitShortcut), + bar.Separator(" │ "), + bar.Section("Polling for AppHosts...").FillWidth() + ]) + ]).Fill(); + } + + private IEnumerable BuildAppHostPanels(RootContext ctx, WidgetContext nav) + { + if (_appHosts.Count == 0) + { + return [nav.Text($" {MonitorCommandStrings.NoRunningAppHostsFound}")]; + } + + return _appHosts.Select((appHost, i) => + { + var branchName = appHost.Branch ?? "unknown"; + var index = i; + + var interactable = nav.Interactable(ic => + { + var focused = ic.IsFocused || ic.IsHovered; + var innerContent = ctx.ThemePanel( + focused ? AspireTheme.ApplyAppHostTileInnerFocused : AspireTheme.ApplyAppHostTileInner, + ic.VStack(v => [ + v.Text($"▲ {appHost.DisplayName}").FixedHeight(1), + v.Text($" ⎇ {branchName}").FixedHeight(1) + ])); + + return (Hex1bWidget)ctx.ThemePanel( + focused + ? AspireTheme.ApplyAppHostTileFocused + : AspireTheme.ApplyAppHostTile, + ctx.Border(innerContent)); + }).OnClick(args => + { + _ = ConnectToAppHostAsync(index, CancellationToken.None); + }); + + return (Hex1bWidget)interactable; + }); + } + + private IEnumerable BuildResourcesTab(WidgetContext ctx) + { + if (_isConnecting) + { + return [ctx.Text($" {MonitorCommandStrings.ConnectingToAppHost}")]; + } + + if (_errorMessage is not null) + { + return [ctx.Text($" Error: {_errorMessage}")]; + } + + var resources = _resources.Values + .Where(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name) + .ToList(); + + if (resources.Count == 0) + { + return [ctx.Text($" {MonitorCommandStrings.NoResourcesAvailable}")]; + } + + var table = ctx.Table(resources) + .RowKey(r => r.Name) + .Focus(_focusedResourceKey) + .OnFocusChanged(key => + { + _focusedResourceKey = key; + }) + .Header(h => [ + h.Cell("Name").Width(SizeHint.Fill), + h.Cell("Type").Fixed(12), + h.Cell("State").Fixed(12), + h.Cell("Health").Fixed(14), + h.Cell("URLs").Width(SizeHint.Fill), + h.Cell("Actions").Fixed(20) + ]) + .Row((r, resource, state) => [ + r.Cell(resource.DisplayName ?? resource.Name), + r.Cell(resource.ResourceType ?? ""), + r.Cell(resource.State ?? "Unknown"), + r.Cell(FormatHealthStatus(resource.HealthStatus)), + r.Cell(cell => BuildUrlsCell(cell, resource)), + r.Cell(cell => cell.HStack(h => [ + h.Button("▶").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-start"); + }), + h.Button("⏹").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-stop"); + }), + h.Button("↻").OnClick(e => + { + _ = ExecuteResourceCommandAsync(resource.Name, "resource-restart"); + }) + ])) + ]) + .Fill() + .Full(); + + return [table]; + } + + private IEnumerable BuildParametersTab(WidgetContext ctx) + { + if (_isConnecting) + { + return [ctx.Text($" {MonitorCommandStrings.ConnectingToAppHost}")]; + } + + var parameters = _resources.Values + .Where(r => string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Name) + .ToList(); + + if (parameters.Count == 0) + { + return [ctx.Text($" {MonitorCommandStrings.NoParametersAvailable}")]; + } + + return [ + ctx.Table(parameters) + .RowKey(r => r.Name) + .Focus(_focusedParameterKey) + .OnFocusChanged(key => { _focusedParameterKey = key; }) + .Header(h => [ + h.Cell("Name").Width(SizeHint.Fill), + h.Cell("State").Fixed(16) + ]) + .Row((r, param, state) => [ + r.Cell(param.DisplayName ?? param.Name), + r.Cell(param.State ?? "Unknown") + ]) + .Fill() + .Full() + ]; + } + + private Hex1bWidget BuildOfflinePanel(RootContext ctx, AppHostEntry appHost) + { + return ctx.VStack(v => [ + v.Text("").Fill(), + v.Center( + ctx.VStack(inner => [ + inner.Text("⚠ AppHost Offline").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.Text($"{appHost.DisplayName} is no longer running.").FixedHeight(1), + inner.Text("").FixedHeight(1), + inner.HStack(buttons => [ + buttons.Button(" Remove from list ").OnClick(e => + { + _notificationStack ??= e.Context.Notifications; + var idx = _appHosts.IndexOf(appHost); + _appHosts.Remove(appHost); + if (_appHosts.Count == 0) + { + _selectedAppHostIndex = 0; + } + else if (_selectedAppHostIndex >= _appHosts.Count) + { + _selectedAppHostIndex = _appHosts.Count - 1; + _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); + } + else if (idx == _selectedAppHostIndex) + { + _ = ConnectToAppHostAsync(_selectedAppHostIndex, CancellationToken.None); + } + }), + buttons.Text(" ").FixedWidth(2), + buttons.Button(" Try reconnecting ").OnClick(e => + { + _notificationStack ??= e.Context.Notifications; + appHost.IsOffline = false; + _ = ConnectToAppHostAsync(_appHosts.IndexOf(appHost), CancellationToken.None); + }) + ]).FixedHeight(1) + ]) + ), + v.Text("").Fill() + ]).Fill(); + } + + private async Task PollAppHostsAsync(CancellationToken cancellationToken) + { + var knownPaths = new HashSet(_appHosts.Select(a => a.FullPath)); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + + var currentConnections = _backchannelMonitor.Connections + .Where(c => c.AppHostInfo is not null) + .ToList(); + + var currentPaths = new HashSet( + currentConnections.Select(c => c.AppHostInfo!.AppHostPath ?? "Unknown")); + + // Detect new AppHosts + foreach (var connection in currentConnections) + { + var path = connection.AppHostInfo!.AppHostPath ?? "Unknown"; + if (!knownPaths.Contains(path)) + { + var wasEmpty = _appHosts.Count == 0; + var entry = new AppHostEntry + { + DisplayName = ShortenPath(path), + FullPath = path, + Connection = connection + }; + _appHosts.Add(entry); + knownPaths.Add(path); + + // Resolve branch and resource summary in the background + _ = ResolveBranchAsync(entry, cancellationToken); + _ = FetchResourceSummaryAsync(entry, cancellationToken); + + // Auto-connect if this is the first AppHost + if (wasEmpty) + { + _ = ConnectToAppHostAsync(0, cancellationToken); + } + else + { + _notificationStack?.Post( + new Notification("New AppHost Detected", entry.DisplayName) + .Timeout(TimeSpan.FromSeconds(10)) + .PrimaryAction("Connect", async ctx => + { + var idx = _appHosts.IndexOf(entry); + if (idx >= 0) + { + await ConnectToAppHostAsync(idx, cancellationToken).ConfigureAwait(false); + } + ctx.Dismiss(); + })); + } + + _app?.Invalidate(); + } + else + { + // Check if a previously offline AppHost came back + var existing = _appHosts.FirstOrDefault(a => a.FullPath == path && a.IsOffline); + if (existing is not null) + { + existing.IsOffline = false; + // Update to the fresh connection since the old one is stale + existing.Connection = connection; + + _notificationStack?.Post( + new Notification("AppHost Back Online", existing.DisplayName) + .Timeout(TimeSpan.FromSeconds(5))); + + // Auto-reconnect if this is the currently selected AppHost + var idx = _appHosts.IndexOf(existing); + if (idx == _selectedAppHostIndex) + { + _ = ConnectToAppHostAsync(idx, cancellationToken); + } + + _app?.Invalidate(); + } + } + } + + // Detect offline AppHosts + foreach (var appHost in _appHosts) + { + if (!appHost.IsOffline && !currentPaths.Contains(appHost.FullPath)) + { + appHost.IsOffline = true; + _app?.Invalidate(); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error polling for AppHosts"); + } + } + } + + private async Task ResolveBranchesAsync(List entries, CancellationToken cancellationToken) + { + foreach (var entry in entries) + { + await ResolveBranchAsync(entry, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ResolveBranchAsync(AppHostEntry entry, CancellationToken cancellationToken) + { + try + { + entry.Branch = await GitBranchHelper.GetCurrentBranchAsync(entry.FullPath, cancellationToken).ConfigureAwait(false); + _app?.Invalidate(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve git branch for {Path}", entry.FullPath); + } + } + + private async Task FetchResourceSummariesAsync(List entries, CancellationToken cancellationToken) + { + foreach (var entry in entries) + { + await FetchResourceSummaryAsync(entry, cancellationToken).ConfigureAwait(false); + } + } + + private async Task FetchResourceSummaryAsync(AppHostEntry entry, CancellationToken cancellationToken) + { + if (entry.IsOffline) + { + return; + } + + try + { + var snapshots = await entry.Connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var resources = snapshots.Where(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)).ToList(); + entry.Summary = new ResourceSummary + { + TotalCount = resources.Count, + RunningCount = resources.Count(r => string.Equals(r.State, "Running", StringComparison.OrdinalIgnoreCase)) + }; + _app?.Invalidate(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch resource summary for {Path}", entry.FullPath); + } + } + + private async Task ConnectToAppHostAsync(int index, CancellationToken cancellationToken) + { + if (_watchCts is not null) + { + await _watchCts.CancelAsync().ConfigureAwait(false); + _watchCts.Dispose(); + } + _watchCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _isConnecting = true; + _errorMessage = null; + _resources.Clear(); + _selectedAppHostIndex = index; + + // Tear down previous state + _app?.Invalidate(); + + try + { + if (index >= _appHosts.Count) + { + return; + } + + var connection = _appHosts[index].Connection; + + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + _isConnecting = false; + foreach (var snapshot in snapshots) + { + _resources[snapshot.Name] = snapshot; + } + + // Fetch dashboard URL + try + { + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false); + if (dashboardInfo?.DashboardUrls is { Length: > 0 } urls) + { + _appHosts[index].DashboardUrl = urls[0]; + } + } + catch + { + // Dashboard info is best-effort + } + + // Fetch AppHost info (version, PID, start time) + try + { + var appHostInfo = connection.AppHostInfo; + if (appHostInfo is not null) + { + _appHosts[index].StartedAt = appHostInfo.StartedAt; + _appHosts[index].Pid = appHostInfo.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (connection is AppHostAuxiliaryBackchannel { SupportsV2: true } v2Connection) + { + var v2Info = await v2Connection.GetAppHostInfoV2Async(cancellationToken).ConfigureAwait(false); + if (v2Info is not null) + { + _appHosts[index].AspireVersion = v2Info.AspireHostVersion; + _appHosts[index].StartedAt = v2Info.StartedAt; + _appHosts[index].Pid = v2Info.Pid; + _appHosts[index].RepositoryRoot = v2Info.RepositoryRoot; + _logger.LogDebug("V2 info: Pid={Pid}, RepoRoot={RepoRoot}, AppHostPath={Path}", + v2Info.Pid, v2Info.RepositoryRoot ?? "(null)", v2Info.AppHostPath); + } + } + } + catch + { + // AppHost info is best-effort + } + + // Set up AppHost log streaming + _appHostLoggerFactory?.Dispose(); + _appHostLoggerFactory = LoggerFactory.Create(builder => + { + builder.AddHex1b(out var logStore); + _appHostLogStore = logStore; + }); + _appHostLogsAvailable = false; + + try + { + var logStream = await connection.GetAppHostLogEntriesAsync(cancellationToken).ConfigureAwait(false); + if (logStream is not null) + { + _appHostLogsAvailable = true; + var appHostLogger = _appHostLoggerFactory.CreateLogger("AppHost"); + + // Log a startup message to confirm the panel is connected + appHostLogger.LogInformation("Connected to AppHost log stream"); + + _ = Task.Run(async () => + { + try + { + await foreach (var entry in logStream.WithCancellation(_watchCts.Token).ConfigureAwait(false)) + { + appHostLogger.Log(entry.LogLevel, "{Message}", entry.Message); + _app?.Invalidate(); + } + } + catch (OperationCanceledException) + { + // Expected when switching AppHosts + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error streaming AppHost logs"); + } + }, _watchCts.Token); + } + else + { + _logger.LogDebug("AppHost log streaming returned null - not supported by this AppHost"); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "AppHost log streaming is not available"); + } + + _app?.Invalidate(); + + _ = Task.Run(async () => + { + try + { + await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(_watchCts.Token).ConfigureAwait(false)) + { + _resources[snapshot.Name] = snapshot; + _app?.Invalidate(); + } + + // Stream ended normally — AppHost likely shut down + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + _resources.Clear(); + _app?.Invalidate(); + } + } + catch (OperationCanceledException) + { + // Expected when switching AppHosts or shutting down + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error watching resource snapshots"); + + // Connection lost — mark as offline + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + _resources.Clear(); + } + + _app?.Invalidate(); + } + }, _watchCts.Token); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error connecting to AppHost"); + _isConnecting = false; + + if (index < _appHosts.Count) + { + _appHosts[index].IsOffline = true; + } + + _app?.Invalidate(); + } + } + + private async Task ExecuteResourceCommandAsync(string resourceName, string commandName) + { + try + { + if (_selectedAppHostIndex < _appHosts.Count) + { + var connection = _appHosts[_selectedAppHostIndex].Connection; + await connection.ExecuteResourceCommandAsync(resourceName, commandName, CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error executing {Command} on {Resource}", commandName, resourceName); + } + } + + private async Task StopSelectedAppHostAsync() + { + try + { + if (_selectedAppHostIndex < _appHosts.Count) + { + var appHost = _appHosts[_selectedAppHostIndex]; + var stopped = await appHost.Connection.StopAppHostAsync(CancellationToken.None).ConfigureAwait(false); + if (stopped) + { + _notificationStack?.Post( + new Notification("AppHost Stopped", $"{appHost.DisplayName} has been stopped.") + .Timeout(TimeSpan.FromSeconds(5))); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error stopping AppHost"); + } + } + + private string GetSelectedAppHostTitle() + { + if (_appHosts.Count == 0) + { + return "No AppHost"; + } + + return _selectedAppHostIndex < _appHosts.Count + ? _appHosts[_selectedAppHostIndex].DisplayName + : "Unknown"; + } + + private string GetStatusText() + { + if (_isConnecting) + { + return MonitorCommandStrings.ConnectingToAppHost; + } + + var resourceCount = _resources.Values + .Count(r => !string.Equals(r.ResourceType, "Parameter", StringComparison.OrdinalIgnoreCase)); + + return $"{resourceCount} resource(s)"; + } + + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + { + return $"{(int)uptime.TotalDays}d {uptime.Hours}h"; + } + if (uptime.TotalHours >= 1) + { + return $"{(int)uptime.TotalHours}h {uptime.Minutes}m"; + } + return $"{(int)uptime.TotalMinutes}m"; + } + + private static string FormatHealthStatus(string? healthStatus) + { + if (string.IsNullOrEmpty(healthStatus)) + { + return ""; + } + + var icon = string.Equals(healthStatus, "Healthy", StringComparison.OrdinalIgnoreCase) + ? "💚" + : "💔"; + + return $"{icon} {healthStatus}"; + } + + private static Hex1bWidget BuildUrlsCell(TableCellContext cell, ResourceSnapshot resource) + { + if (resource.Urls.Length == 0) + { + return cell.Text(""); + } + + return cell.HStack(h => + resource.Urls.Select(u => (Hex1bWidget)h.Hyperlink(u.Url, u.Url)).ToArray()); + } + + private static string ShortenPath(string path) + { + var fileName = Path.GetFileName(path); + + if (string.IsNullOrEmpty(fileName)) + { + return path; + } + + if (fileName.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + return fileName; + } + + var directory = Path.GetDirectoryName(path); + var parentFolder = !string.IsNullOrEmpty(directory) + ? Path.GetFileName(directory) + : null; + + return !string.IsNullOrEmpty(parentFolder) + ? $"{parentFolder}/{fileName}" + : fileName; + } +} diff --git a/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs b/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs new file mode 100644 index 00000000000..8b4327a44f1 --- /dev/null +++ b/src/Aspire.Cli/UI/AspireResourceConsoleLogWorkload.cs @@ -0,0 +1,157 @@ +// 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; +using System.Threading.Channels; +using Aspire.Cli.Backchannel; +using Hex1b; + +namespace Aspire.Cli.UI; + +/// +/// A terminal workload adapter that streams console logs from an Aspire resource +/// via the backchannel and renders them in an embedded terminal widget. +/// +internal sealed class AspireResourceConsoleLogWorkload : IHex1bTerminalWorkloadAdapter +{ + private readonly Channel _outputChannel; + private readonly IAppHostAuxiliaryBackchannel _connection; + private CancellationTokenSource? _streamCts; + private bool _disposed; + private string? _currentResourceName; + + public AspireResourceConsoleLogWorkload(IAppHostAuxiliaryBackchannel connection) + { + _connection = connection; + _outputChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + } + + /// + public event Action? Disconnected; + + /// + public async ValueTask> ReadOutputAsync(CancellationToken ct = default) + { + if (_disposed) + { + return ReadOnlyMemory.Empty; + } + + try + { + if (await _outputChannel.Reader.WaitToReadAsync(ct).ConfigureAwait(false)) + { + if (_outputChannel.Reader.TryRead(out var data)) + { + return data; + } + } + } + catch (OperationCanceledException) + { + } + catch (ChannelClosedException) + { + } + + return ReadOnlyMemory.Empty; + } + + /// + public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken ct = default) + { + // Read-only log view — input is ignored + return ValueTask.CompletedTask; + } + + /// + public ValueTask ResizeAsync(int width, int height, CancellationToken ct = default) + { + return ValueTask.CompletedTask; + } + + /// + /// Starts streaming logs for the specified resource. Cancels any previous stream. + /// + public void StartStreaming(string resourceName, CancellationToken cancellationToken) + { + StopStreaming(); + + _currentResourceName = resourceName; + _streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var ct = _streamCts.Token; + + // Clear terminal and show header + WriteOutput($"\x1b[2J\x1b[H\x1b[1;35m── Logs: {resourceName} ──\x1b[0m\r\n\r\n"); + + _ = Task.Run(async () => + { + try + { + await foreach (var logLine in _connection.GetResourceLogsAsync(resourceName, follow: true, cancellationToken: ct).ConfigureAwait(false)) + { + var prefix = logLine.IsError + ? "\x1b[31m" // red for errors + : "\x1b[0m"; // default + WriteOutput($"{prefix}{logLine.Content}\x1b[0m\r\n"); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + WriteOutput($"\r\n\x1b[31mLog stream error: {ex.Message}\x1b[0m\r\n"); + } + }, ct); + } + + /// + /// Stops the current log stream. + /// + public void StopStreaming() + { + if (_streamCts is not null) + { + _streamCts.Cancel(); + _streamCts.Dispose(); + _streamCts = null; + } + + _currentResourceName = null; + } + + /// + /// Gets the name of the resource currently being streamed, if any. + /// + public string? CurrentResourceName => _currentResourceName; + + private void WriteOutput(string text) + { + if (_disposed) + { + return; + } + + var bytes = Encoding.UTF8.GetBytes(text); + _outputChannel.Writer.TryWrite(bytes); + } + + /// + public ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + StopStreaming(); + _outputChannel.Writer.TryComplete(); + Disconnected?.Invoke(); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Aspire.Cli/UI/AspireTheme.cs b/src/Aspire.Cli/UI/AspireTheme.cs new file mode 100644 index 00000000000..7f8e7d8a39c --- /dev/null +++ b/src/Aspire.Cli/UI/AspireTheme.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b.Theming; + +namespace Aspire.Cli.UI; + +/// +/// Defines the Aspire color theme derived from the Aspire brand palette. +/// +internal static class AspireTheme +{ + // Brand colors from the Aspire logo SVG + private static readonly Hex1bColor s_purple = Hex1bColor.FromRgb(81, 43, 212); // #512BD4 — primary + private static readonly Hex1bColor s_purpleMedium = Hex1bColor.FromRgb(116, 85, 221); // #7455DD + private static readonly Hex1bColor s_purpleLight = Hex1bColor.FromRgb(151, 128, 229); // #9780E5 + private static readonly Hex1bColor s_purpleFaint = Hex1bColor.FromRgb(185, 170, 238); // #B9AAEE + private static readonly Hex1bColor s_lavender = Hex1bColor.FromRgb(220, 213, 246); // #DCD5F6 + + // Dark surfaces + private static readonly Hex1bColor s_bgDark = Hex1bColor.FromRgb(13, 17, 23); // #0D1117 + private static readonly Hex1bColor s_bgSurface = Hex1bColor.FromRgb(22, 27, 34); // #161B22 + private static readonly Hex1bColor s_bgElevated = Hex1bColor.FromRgb(33, 38, 45); // #21262D + private static readonly Hex1bColor s_border = Hex1bColor.FromRgb(48, 54, 61); // #30363D + + // Text + private static readonly Hex1bColor s_textPrimary = Hex1bColor.FromRgb(230, 237, 243); // #E6EDF3 + private static readonly Hex1bColor s_textMuted = Hex1bColor.FromRgb(139, 148, 158); // #8B949E + + /// + /// Applies the Aspire color theme to the given base theme. + /// + public static Hex1bTheme Apply(Hex1bTheme theme) + { + return theme + // Global + .Set(GlobalTheme.BackgroundColor, s_bgDark) + .Set(GlobalTheme.ForegroundColor, s_textPrimary) + + // Table + // Border + .Set(BorderTheme.BorderColor, s_border) + .Set(BorderTheme.TitleColor, s_purpleLight) + + // TabPanel + .Set(TabPanelTheme.ContentBackgroundColor, s_bgDark) + .Set(TabPanelTheme.ContentForegroundColor, s_textPrimary) + + // TabBar + .Set(TabBarTheme.BackgroundColor, s_bgSurface) + .Set(TabBarTheme.ForegroundColor, s_textMuted) + .Set(TabBarTheme.SelectedBackgroundColor, s_bgDark) + .Set(TabBarTheme.SelectedForegroundColor, s_lavender) + + // InfoBar + .Set(InfoBarTheme.BackgroundColor, s_purpleMedium) + .Set(InfoBarTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255)) + + // Button + .Set(ButtonTheme.ForegroundColor, s_textPrimary) + .Set(ButtonTheme.BackgroundColor, s_bgSurface) + .Set(ButtonTheme.FocusedForegroundColor, s_lavender) + .Set(ButtonTheme.FocusedBackgroundColor, s_purple) + .Set(ButtonTheme.HoveredForegroundColor, s_purpleFaint) + .Set(ButtonTheme.HoveredBackgroundColor, s_bgElevated) + + // List (AppHost drawer) + .Set(ListTheme.BackgroundColor, s_bgSurface) + .Set(ListTheme.ForegroundColor, s_textPrimary) + .Set(ListTheme.SelectedBackgroundColor, s_purple) + .Set(ListTheme.SelectedForegroundColor, s_lavender) + .Set(ListTheme.HoveredBackgroundColor, s_bgElevated) + .Set(ListTheme.HoveredForegroundColor, s_purpleFaint) + + // Separator + .Set(SeparatorTheme.Color, s_border) + + // Splitter + .Set(SplitterTheme.DividerColor, s_purple) + .Set(SplitterTheme.FocusedDividerColor, s_purple) + .Set(SplitterTheme.ThumbColor, s_purple) + + // Notification card + .Set(NotificationCardTheme.BackgroundColor, s_bgElevated) + .Set(NotificationCardTheme.TitleColor, s_purpleLight) + .Set(NotificationCardTheme.BodyColor, s_textPrimary) + .Set(NotificationCardTheme.ProgressBarColor, s_purple); + } + + /// + /// Applies the half-block border theme used for both tiles and the content header. + /// + private static Hex1bTheme ApplyHalfBlockBorder(Hex1bTheme theme, Hex1bColor fillColor) + { + return theme + .Set(BorderTheme.BorderColor, fillColor) + .Set(BorderTheme.TopLine, "▄") + .Set(BorderTheme.BottomLine, "▀") + .Set(BorderTheme.LeftLine, "█") + .Set(BorderTheme.RightLine, "█") + .Set(BorderTheme.TopLeftCorner, "▄") + .Set(BorderTheme.TopRightCorner, "▄") + .Set(BorderTheme.BottomLeftCorner, "▀") + .Set(BorderTheme.BottomRightCorner, "▀"); + } + + /// + /// Applies the theme for the content area header border. + /// + public static Hex1bTheme ApplyContentHeaderBorder(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purple); + } + + /// + /// Applies the inner theme for the content area header. + /// + public static Hex1bTheme ApplyContentHeaderInner(Hex1bTheme theme) + { + return theme + .Set(GlobalTheme.BackgroundColor, s_purple) + .Set(GlobalTheme.ForegroundColor, Hex1bColor.FromRgb(255, 255, 255)) + .Set(HyperlinkTheme.ForegroundColor, s_lavender) + .Set(HyperlinkTheme.FocusedForegroundColor, s_lavender) + .Set(HyperlinkTheme.HoveredForegroundColor, Hex1bColor.FromRgb(255, 255, 255)); + } + + /// + /// Applies the AppHost tile theme for the left-pane tiles. + /// + public static Hex1bTheme ApplyAppHostTile(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purple); + } + + /// + /// Applies the focused/hovered AppHost tile theme with a lighter background. + /// + public static Hex1bTheme ApplyAppHostTileFocused(Hex1bTheme theme) + { + return ApplyHalfBlockBorder(theme, s_purpleMedium); + } + + /// + /// Applies the inner fill color for tile content. + /// + public static Hex1bTheme ApplyAppHostTileInner(Hex1bTheme theme) + { + return theme.Set(GlobalTheme.BackgroundColor, s_purple); + } + + /// + /// Applies the inner fill color for focused tile content. + /// + public static Hex1bTheme ApplyAppHostTileInnerFocused(Hex1bTheme theme) + { + return theme.Set(GlobalTheme.BackgroundColor, s_purpleMedium); + } +} diff --git a/src/Aspire.Cli/UI/HackRevealEffect.cs b/src/Aspire.Cli/UI/HackRevealEffect.cs new file mode 100644 index 00000000000..b0939083e17 --- /dev/null +++ b/src/Aspire.Cli/UI/HackRevealEffect.cs @@ -0,0 +1,254 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Hex1b.Surfaces; +using Hex1b.Theming; + +namespace Aspire.Cli.UI; + +/// +/// Transition effect that reveals the TUI from black after the splash screen. +/// Borders and structural characters fade in first from the bottom up, +/// then alphanumeric text appears as rapidly scrambled characters before +/// settling into the actual content. +/// +internal sealed class HackRevealEffect +{ + private struct CellInfo + { + public bool HasContent; + public bool IsAlphaNumeric; + public string Character; + public Hex1bColor? Foreground; + public Hex1bColor? Background; + public double BgRevealTime; + public double CharRevealTime; + public double SettleTime; + public int ScrambleSeed; + } + + private CellInfo[,]? _cells; + private int _width, _height; + private bool _initialScanDone; + private readonly Random _rng = new(); + + private const string ScrambleChars = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef@#$%&!?<>{}[]~"; + + public void Reset() + { + _cells = null; + _initialScanDone = false; + } + + public void Update(int width, int height) + { + if (_cells is not null && _width == width && _height == height) + { + return; + } + + _width = width; + _height = height; + _cells = new CellInfo[width, height]; + _initialScanDone = false; + } + + public CellCompute GetCompute(double progress) + { + return ctx => + { + var below = ctx.GetBelow(); + + if (_cells is null) + { + return new SurfaceCell(" ", null, null); + } + + ref var cell = ref _cells[ctx.X, ctx.Y]; + + // Phase 1: Initial full-screen capture — show all black + if (!_initialScanDone) + { + CaptureCell(ref cell, below, ctx.Y); + + // Mark scan complete after the last cell + if (ctx.X == _width - 1 && ctx.Y == _height - 1) + { + _initialScanDone = true; + } + + return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); + } + + // Phase 2: Reveal from cache, but promote empty cells if new content appears + if (!cell.HasContent) + { + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; + + if (hasVisibleChar) + { + // New content streamed in — assign reveal times from current progress + bool isAlpha = !IsStructuralChar(below.Character); + double jitter = _rng.NextDouble() * 0.04; + double bgReveal = progress + jitter; + double charReveal = isAlpha + ? bgReveal + 0.08 + _rng.NextDouble() * 0.06 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.15 + _rng.NextDouble() * 0.10 + : charReveal; + + cell = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; + } + } + + // Render from cached cell data + if (progress >= 1.0) + { + return below; + } + + if (progress < cell.BgRevealTime) + { + return new SurfaceCell(" ", null, Hex1bColor.FromRgb(0, 0, 0)); + } + + double bgFade = Math.Clamp((progress - cell.BgRevealTime) / 0.18, 0, 1); + var bg = FadeFromBlack(cell.Background, bgFade); + + if (!cell.HasContent) + { + return new SurfaceCell(" ", null, bg); + } + + if (progress < cell.CharRevealTime) + { + return new SurfaceCell(" ", null, bg); + } + + double fgFade = Math.Clamp((progress - cell.CharRevealTime) / 0.08, 0, 1); + var fg = FadeFromBlack(cell.Foreground, fgFade); + + if (!cell.IsAlphaNumeric) + { + return new SurfaceCell(cell.Character ?? " ", fg, bg) with + { + Attributes = below.Attributes, + DisplayWidth = below.DisplayWidth + }; + } + + if (progress >= cell.SettleTime) + { + return new SurfaceCell(cell.Character ?? " ", fg, bg) with + { + Attributes = below.Attributes, + DisplayWidth = below.DisplayWidth + }; + } + + int idx = (int)(progress * 200 + cell.ScrambleSeed) % ScrambleChars.Length; + return new SurfaceCell(ScrambleChars[idx].ToString(), fg, bg); + }; + } + + private void CaptureCell(ref CellInfo cell, SurfaceCell below, int y) + { + bool hasVisibleChar = !below.IsContinuation + && below.Character != "\uE000" + && !string.IsNullOrEmpty(below.Character) + && below.Character != " "; + + double rowFrac = 1.0 - (double)y / Math.Max(1, _height - 1); + double jitter = _rng.NextDouble() * 0.06; + + if (hasVisibleChar) + { + bool isAlpha = !IsStructuralChar(below.Character); + double bgReveal = rowFrac * 0.45 + jitter; + double charReveal = isAlpha + ? 0.55 + _rng.NextDouble() * 0.10 + : bgReveal; + double settle = isAlpha + ? charReveal + 0.20 + _rng.NextDouble() * 0.15 + : charReveal; + + cell = new CellInfo + { + HasContent = true, + IsAlphaNumeric = isAlpha, + Character = below.Character, + Foreground = below.Foreground, + Background = below.Background, + BgRevealTime = bgReveal, + CharRevealTime = charReveal, + SettleTime = settle, + ScrambleSeed = _rng.Next() + }; + } + else + { + cell = new CellInfo + { + HasContent = false, + Background = below.Background, + BgRevealTime = rowFrac * 0.45 + jitter, + }; + } + } + + private static bool IsStructuralChar(string ch) + { + if (ch.Length == 0) + { + return false; + } + + char c = ch[0]; + if (c is >= '\u2500' and <= '\u259F') + { + return true; + } + + if (c is >= '\u2800' and <= '\u28FF') + { + return true; + } + + if (c is '|' or '+' or '-' or '=' or '_') + { + return true; + } + + return false; + } + + private static Hex1bColor? FadeFromBlack(Hex1bColor? target, double amount) + { + if (target is null || target.Value.IsDefault) + { + return amount >= 1.0 ? target : Hex1bColor.FromRgb(0, 0, 0); + } + + var c = target.Value; + return Hex1bColor.FromRgb( + (byte)(c.R * amount), + (byte)(c.G * amount), + (byte)(c.B * amount)); + } +} diff --git a/src/Aspire.Cli/Utils/GitBranchHelper.cs b/src/Aspire.Cli/Utils/GitBranchHelper.cs new file mode 100644 index 00000000000..c93787a6cac --- /dev/null +++ b/src/Aspire.Cli/Utils/GitBranchHelper.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Cli.Utils; + +/// +/// Resolves the current git branch for a given file path. +/// +internal static class GitBranchHelper +{ + /// + /// Gets the current git branch for the directory containing the specified file path. + /// + /// A file path whose parent directory is used as the git working directory. + /// A cancellation token. + /// The branch name, or null if git is unavailable or the path is not in a git repository. + public static async Task GetCurrentBranchAsync(string filePath, CancellationToken cancellationToken = default) + { + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return null; + } + + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "rev-parse --abbrev-ref HEAD", + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + return null; + } + + var branch = output.Trim(); + return string.IsNullOrEmpty(branch) ? null : branch; + } + catch + { + return null; + } + } +} diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 06a99cb3027..bff28001bf6 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -35,6 +35,16 @@ public async IAsyncEnumerable GetAppHostLogEntriesAsync([En using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); var linkedToken = linkedCts.Token; + // Replay buffered entries first so late-connecting clients see history + var loggerProvider = serviceProvider.GetService(); + if (loggerProvider is not null) + { + foreach (var entry in loggerProvider.GetReplaySnapshot()) + { + yield return entry; + } + } + Channel? channel = null; try diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 9596720b7e6..0047d2a66d6 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -58,6 +58,7 @@ public async Task GetAppHostInfoAsync(GetAppHostInfoRequ _ = request; var legacyInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false); + var repoRoot = DiscoverRepositoryRoot(legacyInfo.AppHostPath); return new GetAppHostInfoResponse { @@ -65,7 +66,8 @@ public async Task GetAppHostInfoAsync(GetAppHostInfoRequ AspireHostVersion = typeof(AuxiliaryBackchannelRpcTarget).Assembly.GetName().Version?.ToString() ?? "unknown", AppHostPath = legacyInfo.AppHostPath, CliProcessId = legacyInfo.CliProcessId, - StartedAt = legacyInfo.StartedAt + StartedAt = legacyInfo.StartedAt, + RepositoryRoot = repoRoot }; } @@ -988,6 +990,16 @@ private HttpClientTransport CreateHttpClientTransport(Uri endpointUri) #endregion + /// + /// Streams AppHost log entries from the hosting process. + /// Delegates to . + /// + public IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default) + { + var rpcTarget = serviceProvider.GetRequiredService(); + return rpcTarget.GetAppHostLogEntriesAsync(cancellationToken); + } + /// /// Converts a JsonElement to its underlying CLR type for proper serialization. /// @@ -1022,4 +1034,44 @@ private static object ConvertJsonNumber(JsonElement element) // Fall back to double for floating point return element.GetDouble(); } + + /// + /// Walks up from the AppHost project path looking for a .git directory to find the repository root. + /// + private string? DiscoverRepositoryRoot(string appHostPath) + { + try + { + // Resolve to absolute path + var fullPath = Path.GetFullPath(appHostPath); + + var directory = Directory.Exists(fullPath) + ? new DirectoryInfo(fullPath) + : new FileInfo(fullPath).Directory; + + // If the path didn't yield a directory, fall back to current directory + directory ??= new DirectoryInfo(Environment.CurrentDirectory); + + while (directory is not null) + { + var gitPath = Path.Combine(directory.FullName, ".git"); + + // Standard repo: .git is a directory + // Worktree: .git is a file containing "gitdir: ..." + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + logger.LogDebug("Found git root at {Dir} (worktree={IsWorktree})", directory.FullName, File.Exists(gitPath)); + return directory.FullName; + } + + directory = directory.Parent; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to discover repository root from {Path}", appHostPath); + } + + return null; + } } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index eb84f2a5778..ed9301f98e9 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -98,6 +98,11 @@ internal sealed class GetAppHostInfoResponse /// Gets when the AppHost process started. /// public DateTimeOffset? StartedAt { get; init; } + + /// + /// Gets the root directory of the repository containing the AppHost, if discovered. + /// + public string? RepositoryRoot { get; init; } } /// diff --git a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs index c05564f80db..50e89e0adcf 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs @@ -10,6 +10,9 @@ namespace Aspire.Hosting.Backchannel; internal class BackchannelLoggerProvider : ILoggerProvider { private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly List _replayBuffer = new(); + private readonly object _replayLock = new(); + private const int MaxReplayEntries = 1000; private readonly IServiceProvider _serviceProvider; private readonly object _channelRegisteredLock = new(); private readonly CancellationTokenSource _backgroundChannelRegistrationCts = new(); @@ -21,6 +24,29 @@ public BackchannelLoggerProvider(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } + /// + /// Gets a snapshot of buffered log entries for replay to late-connecting clients. + /// + internal List GetReplaySnapshot() + { + lock (_replayLock) + { + return new List(_replayBuffer); + } + } + + internal void AddToReplayBuffer(BackchannelLogEntry entry) + { + lock (_replayLock) + { + if (_replayBuffer.Count >= MaxReplayEntries) + { + _replayBuffer.RemoveAt(0); + } + _replayBuffer.Add(entry); + } + } + private void RegisterLogChannel() { // Why do we execute this on a background task? This method is spawned on a background @@ -49,7 +75,7 @@ public ILogger CreateLogger(string categoryName) } } - return new BackchannelLogger(categoryName, _channel); + return new BackchannelLogger(categoryName, _channel, this); } public void Dispose() @@ -59,7 +85,7 @@ public void Dispose() } } -internal class BackchannelLogger(string categoryName, Channel channel) : ILogger +internal class BackchannelLogger(string categoryName, Channel channel, BackchannelLoggerProvider provider) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { @@ -84,6 +110,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Message = formatter(state, exception), }; + provider.AddToReplayBuffer(entry); channel.Writer.TryWrite(entry); } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 6e1b56b6b90..b3d9bd63ef5 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -188,7 +188,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(TimeProvider.System); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); _innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); _innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error); _innerBuilder.Logging.AddFilter("Aspire.Hosting.Dashboard", LogLevel.Error); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs new file mode 100644 index 00000000000..a5629890b48 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/AtopCommandTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for the Aspire CLI atop command (TUI). +/// Each test class runs as a separate CI job for parallelization. +/// +public sealed class AtopCommandTests(ITestOutputHelper output) +{ + [Fact] + public async Task AtopShowsHealthyResources() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AtopShowsHealthyResources)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./AspireStarterApp): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Pattern searchers for detached run + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + // Pattern searcher for the atop TUI showing a healthy resource + var waitForHealthyInMonitor = new CellPatternSearcher() + .Find("Healthy"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Enable the monitor command feature flag + sequenceBuilder.Type("aspire config set features.monitorCommandEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("AspireStarterApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd AspireStarterApp/AspireStarterApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in the background using aspire run --detach + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Launch aspire atop TUI — this takes over the terminal + sequenceBuilder.Type("aspire atop") + .Enter() + .WaitUntil(s => waitForHealthyInMonitor.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + // Healthy text found — test passed. Send Ctrl+C to exit the TUI. + .Ctrl().Key(Hex1b.Input.Hex1bKey.C) + .WaitForSuccessPrompt(counter); + + // Stop the AppHost + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +}