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 1b60b9d..243a80b 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -6,17 +6,26 @@ 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) .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) + .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 40927e1..1e2aecb 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/DistributedApplicationBuilderExtensions.cs @@ -1,6 +1,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; @@ -31,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)); @@ -48,10 +49,38 @@ public static IResourceBuilder AddAspireProjectComm { throw new ArgumentException("HubPath must be a valid path", nameof(options.HubPath)); } + + var resource = new ProjectCommanderHubResource(options); - 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); + logger.LogInformation("Initializing Aspire Project Commander Resource"); + + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(resource, e.Services), ct); + + var loggerFactory = e.Services.GetRequiredService(); + var model = e.Services.GetRequiredService(); + + await resource.StartHubAsync(loggerFactory, model).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() @@ -59,8 +88,13 @@ public static IResourceBuilder AddAspireProjectComm ResourceType = "ProjectCommander", State = "Stopped", Properties = [ - new(CustomResourceKnownProperties.Source, "Project Commander"), - ] + new( + CustomResourceKnownProperties.Source, + "Project Commander Host"), + ], +#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..a3e3458 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -1,12 +1,19 @@ using Aspire.Hosting.ApplicationModel; -using Microsoft.AspNetCore.Razor.Hosting; +using JetBrains.Annotations; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; 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) { logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); @@ -14,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/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/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>(); 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..36c42ea 100644 --- a/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs @@ -18,7 +18,7 @@ public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCo { var sp = services.BuildServiceProvider(); - if (sp.GetService() == null) + if (sp.GetService() is null) { var worker = ActivatorUtilities.CreateInstance(sp); services.AddSingleton(worker);