Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sample/Consumer/Consumer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Src\Nivot.Aspire.ProjectCommander\Nivot.Aspire.ProjectCommander.csproj" />
<ProjectReference Include="..\ProjectCommander.ServiceDefaults\ProjectCommander.ServiceDefaults.csproj" />
</ItemGroup>

Expand Down
4 changes: 3 additions & 1 deletion Sample/Consumer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

builder.AddServiceDefaults();

builder.AddAzureEventHubConsumerClient("hub");
builder.AddAzureEventHubConsumerClient("client");

builder.Services.AddAspireProjectCommanderClient();

builder.Services.AddHostedService<ConsumerWorker>();

Expand Down
15 changes: 12 additions & 3 deletions Sample/ProjectCommander.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Projects.DataGenerator>("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<Projects.Consumer>("consumer")
.WithReference(datahub)
.WithReference(commander)
.WithReference(client)
.WaitFor(datahub);

builder.Build().Run();
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -31,9 +32,9 @@ public static IResourceBuilder<ProjectCommanderHubResource> AddAspireProjectComm
/// <exception cref="ArgumentException"></exception>
public static IResourceBuilder<ProjectCommanderHubResource> 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));
Expand All @@ -48,19 +49,52 @@ public static IResourceBuilder<ProjectCommanderHubResource> AddAspireProjectComm
{
throw new ArgumentException("HubPath must be a valid path", nameof(options.HubPath));
}

var resource = new ProjectCommanderHubResource(options);

builder.Services.TryAddLifecycleHook<ProjectCommanderHubLifecycleHook>();

var resource = new ProjectCommanderHubResource("project-commander", options);
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
{
var notify = e.Services.GetRequiredService<ResourceNotificationService>();
await notify.PublishUpdateAsync(resource, state => state with
{
State = KnownResourceStates.Starting,
CreationTimeStamp = DateTime.Now
});

var logger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(resource);
logger.LogInformation("Initializing Aspire Project Commander Resource");

await builder.Eventing.PublishAsync(
new BeforeResourceStartedEvent(resource, e.Services), ct);

var loggerFactory = e.Services.GetRequiredService<ResourceLoggerService>();
var model = e.Services.GetRequiredService<DistributedApplicationModel>();

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()
{
ResourceType = "ProjectCommander",
State = "Stopped",
Properties = [
new(CustomResourceKnownProperties.Source, "Project Commander"),
]
new(
CustomResourceKnownProperties.Source,
"Project Commander Host"),
],
#if !DEBUG
IsHidden = true
#endif
})
.ExcludeFromManifest();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="JetBrains.Annotations" Version="2025.2.0" />
</ItemGroup>
</Project>
19 changes: 14 additions & 5 deletions Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
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
/// <summary>
/// Represents the Aspire Project Commander SignalR Hub implementation.
/// </summary>
/// <param name="logger"></param>
/// <param name="loggerService"></param>
/// <param name="model"></param>
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);

await Groups.AddToGroupAsync(Context.ConnectionId, resourceName);
}

[UsedImplicitly]
public async IAsyncEnumerable<IReadOnlyList<LogLine>> 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))
{
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,43 @@
namespace CommunityToolkit.Aspire.Hosting.ProjectCommander;

/// <summary>
///
/// Represents the Aspire Project Commander SignalR Hub Aspire resource.
/// </summary>
/// <param name="name"></param>
/// <param name="options"></param>
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<ProjectCommanderHub>? Hub { get; set; }

private IHubContext<ProjectCommanderHub> BuildHub()
private IHubContext<ProjectCommanderHub> 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");

// signalr project command host setup
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);
Expand All @@ -65,7 +57,7 @@ private IHubContext<ProjectCommanderHub> 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<ProjectCommanderHub>(options.HubPath!);

var hub = _web.Services.GetRequiredService<IHubContext<ProjectCommanderHub>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
// ReSharper disable once CheckNamespace
namespace Aspire.Hosting;

/// <summary>
/// Represents a command associated with a project, including its name and display name.
/// </summary>
/// <param name="Name">The unique name of the command. This value is typically used as an identifier.</param>
/// <param name="DisplayName">The user-friendly name of the command, intended for display in UI or logs.</param>
/// <param name="Arguments">Optional arguments to pass to the command.</param>
public record ProjectCommand(string Name, string DisplayName, params string[] Arguments);

/// <summary>
/// Extension methods for configuring the Aspire Project Commander.
/// </summary>
Expand All @@ -20,14 +28,14 @@ public static class ResourceBuilderProjectCommanderExtensions
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static IResourceBuilder<T> WithProjectCommands<T>(
this IResourceBuilder<T> builder, params (string Name, string DisplayName)[] commands)
this IResourceBuilder<T> 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) =>
Expand All @@ -39,7 +47,7 @@ public static IResourceBuilder<T> WithProjectCommands<T>(
{
var model = context.ServiceProvider.GetRequiredService<DistributedApplicationModel>();
var hub = model.Resources.OfType<ProjectCommanderHubResource>().Single().Hub!;

var groupName = context.ResourceName;
await hub.Clients.Group(groupName).SendAsync("ReceiveCommand", command.Name, context.CancellationToken);

Expand All @@ -50,7 +58,9 @@ public static IResourceBuilder<T> WithProjectCommands<T>(
errorMessage = ex.Message;
}
return new ExecuteCommandResult() { Success = success, ErrorMessage = errorMessage };
}, new CommandOptions {
},
new CommandOptions
{
IconName = "DesktopSignal",
IconVariant = IconVariant.Regular
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static IServiceCollection AddAspireProjectCommanderClient(this IServiceCo
{
var sp = services.BuildServiceProvider();

if (sp.GetService<IAspireProjectCommanderClient>() == null)
if (sp.GetService<IAspireProjectCommanderClient>() is null)
{
var worker = ActivatorUtilities.CreateInstance<AspireProjectCommanderClientWorker>(sp);
services.AddSingleton<IHostedService>(worker);
Expand Down