From 4d51a2466bd5b605e9ab8b449e0fdeabc1a18704 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Tue, 10 Jun 2025 12:24:43 -0400 Subject: [PATCH 1/4] add remote log endpoint; sample cli logviewer 'spiralog' --- .../ProjectCommander.Tests.csproj | 4 +- ProjectCommander.sln | 10 ++ Sample/Consumer/Consumer.csproj | 2 +- Sample/Consumer/Program.cs | 3 +- Sample/DataGenerator/DataGenerator.csproj | 2 +- Sample/DataGenerator/Program.cs | 3 +- Sample/ProjectCommander.AppHost/Program.cs | 4 +- .../ProjectCommander.AppHost.csproj | 6 +- .../ProjectCommander.ServiceDefaults.csproj | 4 +- Sample/SpiraLog/Program.cs | 115 ++++++++++++++++++ .../SpiraLog/Properties/launchSettings.json | 8 ++ Sample/SpiraLog/SpiraLog.csproj | 20 +++ ...vot.Aspire.Hosting.ProjectCommander.csproj | 2 +- .../ProjectCommanderHub.cs | 58 ++++++++- .../ProjectCommanderHubLifecycleHook.cs | 5 +- .../ProjectCommanderHubResource.cs | 25 +++- .../Nivot.Aspire.ProjectCommander.csproj | 4 +- 17 files changed, 248 insertions(+), 27 deletions(-) create mode 100644 Sample/SpiraLog/Program.cs create mode 100644 Sample/SpiraLog/Properties/launchSettings.json create mode 100644 Sample/SpiraLog/SpiraLog.csproj diff --git a/ProjectCommander.Tests/ProjectCommander.Tests.csproj b/ProjectCommander.Tests/ProjectCommander.Tests.csproj index 336377f..05b1432 100644 --- a/ProjectCommander.Tests/ProjectCommander.Tests.csproj +++ b/ProjectCommander.Tests/ProjectCommander.Tests.csproj @@ -9,12 +9,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/ProjectCommander.sln b/ProjectCommander.sln index 818cafe..d095851 100644 --- a/ProjectCommander.sln +++ b/ProjectCommander.sln @@ -24,6 +24,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectCommander.Tests", "ProjectCommander.Tests\ProjectCommander.Tests.csproj", "{3FA2AEB2-18B5-4DF8-A556-09492F6408D4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpiraLog", "Sample\SpiraLog\SpiraLog.csproj", "{D45D1DF8-C846-1C71-D6DD-AC07B173733A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -58,6 +60,10 @@ Global {3FA2AEB2-18B5-4DF8-A556-09492F6408D4}.Debug|Any CPU.Build.0 = Debug|Any CPU {3FA2AEB2-18B5-4DF8-A556-09492F6408D4}.Release|Any CPU.ActiveCfg = Release|Any CPU {3FA2AEB2-18B5-4DF8-A556-09492F6408D4}.Release|Any CPU.Build.0 = Release|Any CPU + {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D45D1DF8-C846-1C71-D6DD-AC07B173733A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,5 +73,9 @@ Global {DECE34B3-8776-4684-A92F-8B9C8A1147A7} = {A12F2526-69A6-444C-82CA-49DAAB8D0C7E} {62A936C1-61B6-479A-81CB-1B864E093332} = {A12F2526-69A6-444C-82CA-49DAAB8D0C7E} {6F6EF83D-432C-48DC-8C5C-50356F6A97DA} = {A12F2526-69A6-444C-82CA-49DAAB8D0C7E} + {D45D1DF8-C846-1C71-D6DD-AC07B173733A} = {A12F2526-69A6-444C-82CA-49DAAB8D0C7E} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {812F688B-A919-42B2-9FC8-B26C65E615C0} EndGlobalSection EndGlobal diff --git a/Sample/Consumer/Consumer.csproj b/Sample/Consumer/Consumer.csproj index a1fb90b..959e12e 100644 --- a/Sample/Consumer/Consumer.csproj +++ b/Sample/Consumer/Consumer.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/Consumer/Program.cs b/Sample/Consumer/Program.cs index 19973fd..6e01dce 100644 --- a/Sample/Consumer/Program.cs +++ b/Sample/Consumer/Program.cs @@ -7,8 +7,7 @@ builder.AddServiceDefaults(); -builder.AddAzureEventHubConsumerClient("data", - settings => settings.EventHubName = "hub"); +builder.AddAzureEventHubConsumerClient("hub"); builder.Services.AddHostedService(); diff --git a/Sample/DataGenerator/DataGenerator.csproj b/Sample/DataGenerator/DataGenerator.csproj index 023857c..734f8e9 100644 --- a/Sample/DataGenerator/DataGenerator.csproj +++ b/Sample/DataGenerator/DataGenerator.csproj @@ -8,7 +8,7 @@ - + diff --git a/Sample/DataGenerator/Program.cs b/Sample/DataGenerator/Program.cs index c52c958..5844c08 100644 --- a/Sample/DataGenerator/Program.cs +++ b/Sample/DataGenerator/Program.cs @@ -9,8 +9,7 @@ builder.Services.AddAspireProjectCommanderClient(); -builder.AddAzureEventHubProducerClient("data", - settings => settings.EventHubName = "hub"); +builder.AddAzureEventHubProducerClient("hub"); builder.Services.AddHostedService(); diff --git a/Sample/ProjectCommander.AppHost/Program.cs b/Sample/ProjectCommander.AppHost/Program.cs index 90b373d..1b60b9d 100644 --- a/Sample/ProjectCommander.AppHost/Program.cs +++ b/Sample/ProjectCommander.AppHost/Program.cs @@ -5,8 +5,8 @@ var commander = builder.AddAspireProjectCommander(); var datahub = builder.AddAzureEventHubs("data") - .AddEventHub("hub") - .RunAsEmulator(); + .RunAsEmulator() + .AddHub("hub"); builder.AddProject("datagenerator") .WithReference(datahub) diff --git a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj index b2ec6d8..d45f51b 100644 --- a/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj +++ b/Sample/ProjectCommander.AppHost/ProjectCommander.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe @@ -13,8 +13,8 @@ - - + + diff --git a/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj b/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj index bbba937..a7642fe 100644 --- a/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj +++ b/Sample/ProjectCommander.ServiceDefaults/ProjectCommander.ServiceDefaults.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/Sample/SpiraLog/Program.cs b/Sample/SpiraLog/Program.cs new file mode 100644 index 0000000..7c782c0 --- /dev/null +++ b/Sample/SpiraLog/Program.cs @@ -0,0 +1,115 @@ +// See https://aka.ms/new-console-template for more information + +using Microsoft.AspNetCore.SignalR.Client; +using Spectre.Console; + +AnsiConsole.MarkupLine("[green]SpiraLog[/] 0.1"); +AnsiConsole.WriteLine(); + +if (args.Length == 0) +{ + AnsiConsole.WriteLine("Usage: spiralog [hubPort]"); + return; +} + +string resourceName = args[0]; + +int? port = args.Length == 2 ? int.Parse(args[1]) : null; + +//var builder = Host.CreateApplicationBuilder(args); + +var hubBuilder = new HubConnectionBuilder() + .WithUrl($"https://localhost:{port ?? 27960}/projectcommander/") + .WithAutomaticReconnect() + .WithKeepAliveInterval(TimeSpan.FromSeconds(120)); // two minute keep alive + +await using var hubConnection = hubBuilder.Build(); + +await hubConnection.StartAsync(); + +AnsiConsole.MarkupLine($"[green]Connected[/] to Aspire Project Commander Hub for resource '[yellow]{resourceName}[/]'"); +AnsiConsole.MarkupLine("Press [bold]SPACE[/] to pause/resume log output"); + +// Simple pause mechanism +bool isPaused = false; +var pauseEvent = new ManualResetEventSlim(true); // Initially not paused +var cts = new CancellationTokenSource(); + +// Start a task to monitor for space bar presses +_ = Task.Run(() => +{ + try + { + while (!cts.Token.IsCancellationRequested) + { + if (Console.KeyAvailable) + { + var key = Console.ReadKey(true); + if (key.Key == ConsoleKey.Spacebar) + { + isPaused = !isPaused; + + if (isPaused) + { + pauseEvent.Reset(); // Block processing + AnsiConsole.MarkupLine("[bold red]PAUSED[/] (Press SPACE to resume)"); + } + else + { + pauseEvent.Set(); // Allow processing to continue + AnsiConsole.MarkupLine("[bold green]RESUMED[/]"); + } + } + } + Thread.Sleep(50); + } + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error in key monitoring:[/] {ex.Message}"); + } +}, cts.Token); + +try +{ + await foreach (var data in hubConnection.StreamAsync>("WatchResourceLogs", resourceName, null)) + { + // Wait here if paused, but respect cancellation + pauseEvent.Wait(cts.Token); + + foreach (var (lineNumber, content, isErrorMessage) in data) + { + // Check if we got paused during processing, with cancellation support + pauseEvent.Wait(cts.Token); + + // parse out timestamp + var parts = content.Split(' ', 2); + var timestamp = parts[0]; + var compactTimestamp = DateTimeOffset.Parse(timestamp).ToLocalTime().ToString("HH:mm:ss.fff"); + + AnsiConsole.Markup($"[yellow][[{compactTimestamp}]][/] "); + + if (isErrorMessage) + { + AnsiConsole.Write("[red]*[/] "); + + // may contain embedded escape codes + AnsiConsole.WriteLine(parts.Length > 1 ? parts[1] : string.Empty); + } + else + { + // may contain embedded escape codes + AnsiConsole.WriteLine(parts.Length > 1 ? parts[1] : string.Empty); + } + } + } +} +finally +{ + // shutdown key monitoring task + cts.Cancel(); +} + +public readonly record struct LogLine(int LineNumber, string Content, bool IsErrorMessage); + + diff --git a/Sample/SpiraLog/Properties/launchSettings.json b/Sample/SpiraLog/Properties/launchSettings.json new file mode 100644 index 0000000..fa07426 --- /dev/null +++ b/Sample/SpiraLog/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "aspirlog": { + "commandName": "Project", + "commandLineArgs": "datagenerator" + } + } +} \ No newline at end of file diff --git a/Sample/SpiraLog/SpiraLog.csproj b/Sample/SpiraLog/SpiraLog.csproj new file mode 100644 index 0000000..4bdebd4 --- /dev/null +++ b/Sample/SpiraLog/SpiraLog.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + 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 bd0e3f0..25bfdb0 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/Nivot.Aspire.Hosting.ProjectCommander.csproj @@ -16,7 +16,7 @@ - + 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 5f79d55..98dc1bf 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -1,14 +1,68 @@ +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Razor.Hosting; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace CommunityToolkit.Aspire.Hosting.ProjectCommander; -internal sealed class ProjectCommanderHub(ILogger logger) : Hub +internal sealed class ProjectCommanderHub(ILogger logger, ResourceLoggerService resourceLogger, DistributedApplicationModel appModel) : Hub { - public async Task Identify(string resourceName) + public async Task Identify([ResourceName] string resourceName) { logger.LogInformation("{ResourceName} connected to Aspire Project Commander Hub", resourceName); await Groups.AddToGroupAsync(Context.ConnectionId, resourceName); } + + public async IAsyncEnumerable> WatchResourceLogs([ResourceName] string resourceName, int? take = null) + { + logger.LogInformation("Getting {LinesWanted} logs for resource {ResourceName}", take?.ToString() ?? "all", resourceName); + + int taken = 0; + + static (int LineNumber, string Content, bool IsErrorMessage) ToTuple(LogLine logLine) => + (logLine.LineNumber, logLine.Content, logLine.IsErrorMessage); + + // resolve IResource from resource name + var resource = appModel.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) + .WithCancellation(Context.ConnectionAborted) + .ConfigureAwait(false)) + { + if (take is null) + { + // No limit, return all logs + yield return logs.ToList(); + } + else if (taken < take.Value) + { + // Calculate how many more logs we can take + int remaining = take.Value - taken; + + if (logs.Count <= remaining) + { + // Return entire batch + yield return logs.ToList(); + taken += logs.Count; + } + else + { + // Return partial batch to reach exactly 'take' logs + yield return logs.Take(remaining).ToList(); + taken = take.Value; + break; + } + } + else + { + break; + } + } + } } \ No newline at end of file diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs index 61c25f8..721ff57 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubLifecycleHook.cs @@ -14,9 +14,10 @@ internal sealed class ProjectCommanderHubLifecycleHook( public async Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) { var hubResource = appModel.Resources.OfType().Single(); - + var logger = loggerService.GetLogger(hubResource); - hubResource.SetLogger(logger); + hubResource.SetLogger(loggerService); + hubResource.SetModel(appModel); await notificationService.PublishUpdateAsync(hubResource, state => state with { diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs index 21e6b81..8a6528a 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -18,6 +19,8 @@ public sealed class ProjectCommanderHubResource([ResourceName] string name, Proj { private WebApplication? _web; private ILogger? _logger; + private ResourceLoggerService? _resourceLogger; + private DistributedApplicationModel _appModel; internal async Task StartHubAsync() { @@ -28,26 +31,38 @@ internal async Task StartHubAsync() _logger?.LogInformation("Aspire Project Commander Hub started"); } - internal void SetLogger(ILogger logger) => _logger = logger; + internal void SetLogger(ResourceLoggerService logger) => _resourceLogger = logger; + + internal void SetModel(DistributedApplicationModel appModel) => _appModel = appModel; internal IHubContext? Hub { get; set; } private IHubContext BuildHub() { // we need the logger to be set before building the hub so we can inject it - Debug.Assert(_logger != null, "Logger must be set before building hub"); + 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"); - _logger?.LogInformation("Building SignalR Hub"); + _logger.LogInformation("Building SignalR Hub"); // signalr project command host setup var host = WebApplication.CreateBuilder(); + // used for streaming logs to clients + host.Services.AddSingleton(_resourceLogger); + + // require to resolve IResource from resource names + host.Services.AddSingleton(_appModel); + // proxy logging to AppHost logger - host.Services.AddSingleton(_logger!); + host.Services.AddSingleton(_logger); host.WebHost.UseUrls($"{(options.UseHttps ? "https" : "http")}://localhost:{options.HubPort}"); - host.Services.AddSignalR(); + host.Services.AddSignalR() + .AddJsonProtocol(json => json.PayloadSerializerOptions.IncludeFields = true); _web = host.Build(); _web.UseRouting(); diff --git a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj index aa6495c..3f8af38 100644 --- a/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj +++ b/Src/Nivot.Aspire.ProjectCommander/Nivot.Aspire.ProjectCommander.csproj @@ -12,8 +12,8 @@ - - + + all runtime; build; native; contentfiles; analyzers From e9a9acd20a6068b56ea1b97b9192c84b2c5b9e79 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Tue, 10 Jun 2025 12:25:32 -0400 Subject: [PATCH 2/4] bump packages to 1.1 --- Src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Src/Directory.Build.props b/Src/Directory.Build.props index 46afb2d..ef26dbb 100644 --- a/Src/Directory.Build.props +++ b/Src/Directory.Build.props @@ -15,7 +15,7 @@ true True true - 1.0.2 + 1.1.0 oisin From c7d90676ed266b8aea7ba69d2e3a440737da3810 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Tue, 10 Jun 2025 12:27:14 -0400 Subject: [PATCH 3/4] remove dead code --- .../ProjectCommanderHub.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs index 98dc1bf..8bfda9a 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs @@ -20,9 +20,6 @@ public async IAsyncEnumerable> WatchResourceLogs([Resourc int taken = 0; - static (int LineNumber, string Content, bool IsErrorMessage) ToTuple(LogLine logLine) => - (logLine.LineNumber, logLine.Content, logLine.IsErrorMessage); - // resolve IResource from resource name var resource = appModel.Resources.SingleOrDefault(r => r.Name == resourceName); if (resource is null) From 88a6045f6dc565915583a0425bde0da447dd6efa Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Tue, 10 Jun 2025 12:32:30 -0400 Subject: [PATCH 4/4] update to non-obsoleted methods; fix nits --- .../ProjectCommanderHubResource.cs | 3 +-- .../ResourceBuilderProjectCommanderExtensions.cs | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs index 8a6528a..ffe0d05 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHubResource.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -20,7 +19,7 @@ public sealed class ProjectCommanderHubResource([ResourceName] string name, Proj private WebApplication? _web; private ILogger? _logger; private ResourceLoggerService? _resourceLogger; - private DistributedApplicationModel _appModel; + private DistributedApplicationModel? _appModel; internal async Task StartHubAsync() { diff --git a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs index 0c9020b..c4486e2 100644 --- a/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs +++ b/Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs @@ -50,7 +50,10 @@ public static IResourceBuilder WithProjectCommands( errorMessage = ex.Message; } return new ExecuteCommandResult() { Success = success, ErrorMessage = errorMessage }; - }, iconName: "DesktopSignal", iconVariant: IconVariant.Regular); + }, new CommandOptions { + IconName = "DesktopSignal", + IconVariant = IconVariant.Regular + }); } return builder;