From 2bc1a8d530a526dcac0c6cf227b705fe737b87cf Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 27 Aug 2025 21:44:41 -0400 Subject: [PATCH 1/4] remove lifecycle hook and switch to eventing --- Sample/ProjectCommander.AppHost/Program.cs | 4 +- ...DistributedApplicationBuilderExtensions.cs | 42 +++++++++++++-- ...vot.Aspire.Hosting.ProjectCommander.csproj | 1 + .../ProjectCommanderHub.cs | 5 +- .../ProjectCommanderHubLifecycleHook.cs | 54 ------------------- ...sourceBuilderProjectCommanderExtensions.cs | 18 +++++-- ...lectionAspireProjectCommanderExtensions.cs | 3 +- 7 files changed, 61 insertions(+), 66 deletions(-) delete mode 100644 Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 1b60b9d..19866ca 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -13,7 +13,9 @@ .WithReference(commander) .WaitFor(commander) .WaitFor(datahub) - .WithProjectCommands(("slow", "Go Slow"), ("fast", "Go Fast")); + .WithProjectCommands( + new("slow", "Go Slow"), + new("fast", "Go Fast")); builder.AddProject("consumer") .WithReference(datahub) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs index 40927e1..33a507b 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -1,6 +1,9 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Threading; namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; @@ -48,11 +51,39 @@ public static IResourceBuilder AddAspireProjectComm { throw new ArgumentException("HubPath must be a valid path", nameof(options.HubPath)); } - - builder.Services.TryAddLifecycleHook(); - + var resource = new ProjectCommanderHubResource("project-commander", options); + builder.Eventing.Subscribe(resource, async (e, ct) => + { + var notify = e.Services.GetRequiredService(); + await notify.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Starting, + CreationTimeStamp = DateTime.Now + }); + + var logger = e.Services.GetRequiredService().GetLogger(resource); + var model = e.Services.GetRequiredService(); + resource.SetLogger(e.Services.GetRequiredService()); + resource.SetModel(model); + logger.LogInformation("Initializing Aspire Project Commander Resource"); + + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(resource, e.Services), ct); + + await resource.StartHubAsync().ConfigureAwait(false); + + var hubUrl = await resource.ConnectionStringExpression.GetValueAsync(ct); + + await notify.PublishUpdateAsync(resource, state => state with + { + State = KnownResourceStates.Running, + StartTimeStamp = DateTime.Now, + Properties = [.. state.Properties, new("hub.url", hubUrl)] + }); + }); + return builder.AddResource(resource) .WithInitialState(new() { @@ -60,7 +91,10 @@ public static IResourceBuilder AddAspireProjectComm State = "Stopped", Properties = [ new(CustomResourceKnownProperties.Source, "Project Commander"), - ] + ], +#if !DEBUG + IsHidden = true +#endif }) .ExcludeFromManifest(); } diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj index 137da02..df83b10 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj @@ -21,5 +21,6 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 8bfda9a..ed37cb1 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -1,5 +1,5 @@ using Aspire.Hosting.ApplicationModel; -using Microsoft.AspNetCore.Razor.Hosting; +using JetBrains.Annotations; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; @@ -7,6 +7,7 @@ namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService resourceLogger, DistributedApplicationModel appModel) : Hub { + [UsedImplicitly] public async Task Identify([ResourceName] string resourceName) { logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); @@ -52,7 +53,7 @@ public async IAsyncEnumerable> WatchResourceLogs([Resourc { // Return partial batch to reach exactly 'take' logs yield return logs.Take(remaining).ToList(); - taken = take.Value; + _ = take.Value; break; } } diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs deleted file mode 100644 index 721ff57..0000000 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Eventing; -using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.Logging; - -namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; - -internal sealed class ProjectCommanderHubLifecycleHook( - ResourceNotificationService notificationService, - ResourceLoggerService loggerService, - IDistributedApplicationEventing eventing, - IServiceProvider services) : IDistributedApplicationLifecycleHook -{ - public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) - { - var hubResource = appModel.Resources.OfType().Single(); - - var logger = loggerService.GetLogger(hubResource); - hubResource.SetLogger(loggerService); - hubResource.SetModel(appModel); - - await notificationService.PublishUpdateAsync(hubResource, state => state with - { - State = KnownResourceStates.Starting, - CreationTimeStamp = DateTime.Now - }); - - try - { - await eventing.PublishAsync( - new BeforeResourceStartedEvent(hubResource, services), cancellationToken); - - await hubResource.StartHubAsync(); - - var hubUrl = await hubResource.ConnectionStringExpression.GetValueAsync(cancellationToken); - - await notificationService.PublishUpdateAsync(hubResource, state => state with - { - State = KnownResourceStates.Running, - StartTimeStamp = DateTime.Now, - Properties = [.. state.Properties, new("HubUrl", hubUrl)] - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to start Project Commands Hub: {Message}", ex.Message); - - await notificationService.PublishUpdateAsync(hubResource, state => state with - { - State = KnownResourceStates.FailedToStart - }); - } - } -} \ No newline at end of file diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index c4486e2..cee2e05 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -6,6 +6,14 @@ // ReSharper disable once CheckNamespace namespace Aspire.Hosting; +/// +/// Represents a command associated with a project, including its name and display name. +/// +/// The unique name of the command. This value is typically used as an identifier. +/// The user-friendly name of the command, intended for display in UI or logs. +/// Optional arguments to pass to the command. +public record ProjectCommand(string Name, string DisplayName, params string[] Arguments); + /// /// Extension methods for configuring the Aspire Project Commander. /// @@ -20,14 +28,14 @@ public static class ResourceBuilderProjectCommanderExtensions /// /// public static IResourceBuilder WithProjectCommands( - this IResourceBuilder builder, params (string Name, string DisplayName)[] commands) + this IResourceBuilder builder, params ProjectCommand[] commands) where T : ProjectResource { if (commands.Length == 0) { throw new ArgumentException("You must supply at least one command."); } - + foreach (var command in commands) { builder.WithCommand(command.Name, command.DisplayName, async (context) => @@ -39,7 +47,7 @@ public static IResourceBuilder WithProjectCommands( { var model = context.ServiceProvider.GetRequiredService(); var hub = model.Resources.OfType().Single().Hub!; - + var groupName = context.ResourceName; await hub.Clients.Group(groupName).SendAsync("ReceiveCommand", command.Name, context.CancellationToken); @@ -50,7 +58,9 @@ public static IResourceBuilder WithProjectCommands( errorMessage = ex.Message; } return new ExecuteCommandResult() { Success = success, ErrorMessage = errorMessage }; - }, new CommandOptions { + }, + new CommandOptions + { IconName = "DesktopSignal", IconVariant = IconVariant.Regular }); diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 82d53fe..2d7e132 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -13,8 +13,9 @@ public static class ServiceCollectionAspireProjectCommanderExtensions /// Adds the Aspire Project Commander client to the service collection. /// /// + /// /// - public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services) + public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services, object? unknown = null) { var sp = services.BuildServiceProvider(); From 901f92b36b082198560868fb112b1adda4e7dcad Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 27 Aug 2025 23:07:17 -0400 Subject: [PATCH 2/4] refactor and clean up --- Sample/Consumer/Consumer.csproj | 1 + Sample/Consumer/Program.cs | 4 ++- Sample/ProjectCommander.AppHost/Program.cs | 11 ++++-- ...DistributedApplicationBuilderExtensions.cs | 20 +++++------ .../ProjectCommanderHub.cs | 16 ++++++--- .../ProjectCommanderHubResource.cs | 34 +++++++------------ 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/Sample/Consumer/Consumer.csproj b/Sample/Consumer/Consumer.csproj index 1eb8ccf..66f4392 100644 --- a/Sample/Consumer/Consumer.csproj +++ b/Sample/Consumer/Consumer.csproj @@ -12,6 +12,7 @@ + diff --git a/Sample/Consumer/Program.cs b/Sample/Consumer/Program.cs index 6e01dce..7f9b058 100644 --- a/Sample/Consumer/Program.cs +++ b/Sample/Consumer/Program.cs @@ -7,7 +7,9 @@ builder.AddServiceDefaults(); -builder.AddAzureEventHubConsumerClient("hub"); +builder.AddAzureEventHubConsumerClient("client"); + +builder.Services.AddAspireProjectCommanderClient(); builder.Services.AddHostedService(); diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 19866ca..243a80b 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -6,7 +6,13 @@ var datahub = builder.AddAzureEventHubs("data") .RunAsEmulator() - .AddHub("hub"); + .AddHub("hub") + .WithProperties(configure => + { + configure.PartitionCount = 2; + }); + +var client = datahub.AddConsumerGroup("client"); builder.AddProject("datagenerator") .WithReference(datahub) @@ -18,7 +24,8 @@ new("fast", "Go Fast")); builder.AddProject("consumer") - .WithReference(datahub) + .WithReference(commander) + .WithReference(client) .WaitFor(datahub); builder.Build().Run(); diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs index 33a507b..1e2aecb 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -1,9 +1,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System.Threading; namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; @@ -34,9 +32,9 @@ public static IResourceBuilder AddAspireProjectComm /// public static IResourceBuilder AddAspireProjectCommander(this IDistributedApplicationBuilder builder, ProjectCommanderHubOptions options) { - if (builder.Resources.Any(r => r.Name == "project-commander")) + if (builder.Resources.Any(r => r.Name == ProjectCommanderHubResource.ResourceName)) { - throw new InvalidOperationException("project-commander resource already exists in the application model"); + throw new InvalidOperationException("ProjectCommanderHubResource already exists in the application model"); } if (options == null) throw new ArgumentNullException(nameof(options)); @@ -52,7 +50,7 @@ public static IResourceBuilder AddAspireProjectComm throw new ArgumentException("HubPath must be a valid path", nameof(options.HubPath)); } - var resource = new ProjectCommanderHubResource("project-commander", options); + var resource = new ProjectCommanderHubResource(options); builder.Eventing.Subscribe(resource, async (e, ct) => { @@ -64,15 +62,15 @@ await notify.PublishUpdateAsync(resource, state => state with }); var logger = e.Services.GetRequiredService().GetLogger(resource); - var model = e.Services.GetRequiredService(); - resource.SetLogger(e.Services.GetRequiredService()); - resource.SetModel(model); logger.LogInformation("Initializing Aspire Project Commander Resource"); await builder.Eventing.PublishAsync( new BeforeResourceStartedEvent(resource, e.Services), ct); - await resource.StartHubAsync().ConfigureAwait(false); + var loggerFactory = e.Services.GetRequiredService(); + var model = e.Services.GetRequiredService(); + + await resource.StartHubAsync(loggerFactory, model).ConfigureAwait(false); var hubUrl = await resource.ConnectionStringExpression.GetValueAsync(ct); @@ -90,7 +88,9 @@ await notify.PublishUpdateAsync(resource, state => state with ResourceType = "ProjectCommander", State = "Stopped", Properties = [ - new(CustomResourceKnownProperties.Source, "Project Commander"), + new( + CustomResourceKnownProperties.Source, + "Project Commander Host"), ], #if !DEBUG IsHidden = true diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index ed37cb1..004f401 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -5,7 +5,13 @@ namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; -internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService resourceLogger, DistributedApplicationModel appModel) : Hub +/// +/// Represents the Aspire Project Commander SignalR Hub implementation. +/// +/// +/// +/// +internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService loggerService, DistributedApplicationModel model) : Hub { [UsedImplicitly] public async Task Identify([ResourceName] string resourceName) @@ -15,21 +21,23 @@ public async Task Identify([ResourceName] string resourceName) await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); } + [UsedImplicitly] public async IAsyncEnumerable> WatchResourceLogs([ResourceName] string resourceName, int? take = null) { - logger.LogInformation("Getting {LinesWanted} logs for resource {ResourceName}", take?.ToString() ?? "all", resourceName); + logger.LogTrace("Getting {LinesWanted} logs for resource {ResourceName}", take?.ToString() ?? "all", resourceName); int taken = 0; // resolve IResource from resource name - var resource = appModel.Resources.SingleOrDefault(r => r.Name == resourceName); + var resource = model.Resources.SingleOrDefault(r => r.Name == resourceName); + if (resource is null) { logger.LogWarning("Resource {ResourceName} not found", resourceName); yield break; // No matching resource found, exit } - await foreach (var logs in resourceLogger.WatchAsync(resource) + await foreach (var logs in loggerService.WatchAsync(resource) .WithCancellation(Context.ConnectionAborted) .ConfigureAwait(false)) { diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs index ffe0d05..fedfcde 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs @@ -9,40 +9,32 @@ namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; /// -/// +/// Represents the Aspire Project Commander SignalR Hub Aspire resource. /// -/// /// -public sealed class ProjectCommanderHubResource([ResourceName] string name, ProjectCommanderHubOptions options) - : Resource(name), IResourceWithConnectionString, IAsyncDisposable +public sealed class ProjectCommanderHubResource(ProjectCommanderHubOptions options) + : Resource(ResourceName), IResourceWithConnectionString, IAsyncDisposable { + internal const string ResourceName = "project-commander"; + private WebApplication? _web; private ILogger? _logger; - private ResourceLoggerService? _resourceLogger; - private DistributedApplicationModel? _appModel; - internal async Task StartHubAsync() + internal async Task StartHubAsync(ResourceLoggerService loggerService, DistributedApplicationModel model) { - Hub = BuildHub(); + Hub = BuildHub(loggerService, model); await (_web!.StartAsync()).ConfigureAwait(false); _logger?.LogInformation("Aspire Project Commander Hub started"); } - internal void SetLogger(ResourceLoggerService logger) => _resourceLogger = logger; - - internal void SetModel(DistributedApplicationModel appModel) => _appModel = appModel; - internal IHubContext? Hub { get; set; } - private IHubContext BuildHub() + private IHubContext BuildHub(ResourceLoggerService loggerService, DistributedApplicationModel model) { - // we need the logger to be set before building the hub so we can inject it - Debug.Assert(_resourceLogger != null, "ResourceLoggerService must be set before building hub"); - _logger = _resourceLogger.GetLogger(this); - - Debug.Assert(_appModel != null, "DistributedApplicationModel must be set before building hub"); + // Get logger for this resource + _logger = loggerService.GetLogger(this); _logger.LogInformation("Building SignalR Hub"); @@ -50,10 +42,10 @@ private IHubContext BuildHub() var host = WebApplication.CreateBuilder(); // used for streaming logs to clients - host.Services.AddSingleton(_resourceLogger); + host.Services.AddSingleton(loggerService); // require to resolve IResource from resource names - host.Services.AddSingleton(_appModel); + host.Services.AddSingleton(model); // proxy logging to AppHost logger host.Services.AddSingleton(_logger); @@ -65,7 +57,7 @@ private IHubContext BuildHub() _web = host.Build(); _web.UseRouting(); - _web.MapGet("/", () => "Aspire Project Commander Host 1.0, powered by SignalR."); + _web.MapGet("/", () => "Aspire Project Commander Host, powered by SignalR."); _web.MapHub(options.HubPath!); var hub = _web.Services.GetRequiredService>(); From d8c921e4eccafec9c6a1da162fd0ce0e3e823494 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 27 Aug 2025 23:10:57 -0400 Subject: [PATCH 3/4] undo experiment --- .../ServiceCollectionAspireProjectCommanderExtensions.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs index 2d7e132..36c42ea 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -13,13 +13,12 @@ public static class ServiceCollectionAspireProjectCommanderExtensions /// Adds the Aspire Project Commander client to the service collection. /// /// - /// /// - public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services, object? unknown = null) + public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCollection services) { var sp = services.BuildServiceProvider(); - if (sp.GetService() == null) + if (sp.GetService() is null) { var worker = ActivatorUtilities.CreateInstance(sp); services.AddSingleton(worker); From d81ef10b16dce603a5d759787d64a0d6565aaccc Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 27 Aug 2025 23:11:46 -0400 Subject: [PATCH 4/4] Update Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ProjectCommanderHub.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 004f401..a3e3458 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -61,7 +61,7 @@ public async IAsyncEnumerable> WatchResourceLogs([Resourc { // Return partial batch to reach exactly 'take' logs yield return logs.Take(remaining).ToList(); - _ = take.Value; + taken = take.Value; break; } }