diff --git a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs index cea08b4b40..ab135ccce4 100644 --- a/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs +++ b/src/ModularPipelines/DependencyInjection/DependencyInjectionSetup.cs @@ -135,6 +135,11 @@ private static void RegisterLoggingAndConsoleServices(IServiceCollection service .AddSingleton() .AddSingleton() .AddSingleton() + + // Progress display components (SRP extraction from ProgressPrinter) + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddSingleton() diff --git a/src/ModularPipelines/Helpers/IProgressCalculator.cs b/src/ModularPipelines/Helpers/IProgressCalculator.cs new file mode 100644 index 0000000000..d99d8e0ebf --- /dev/null +++ b/src/ModularPipelines/Helpers/IProgressCalculator.cs @@ -0,0 +1,27 @@ +namespace ModularPipelines.Helpers; + +/// +/// Provides progress calculation logic for module execution tracking. +/// +internal interface IProgressCalculator +{ + /// + /// Calculates the tick information for progress updates based on estimated duration. + /// + /// The estimated duration for the module. + /// Information about how to tick progress. + ProgressTickInfo CalculateTickInfo(TimeSpan estimatedDuration); + + /// + /// Calculates the progress increment for total progress when a module completes. + /// + /// Total number of modules in the pipeline. + /// The progress increment percentage. + double CalculateProgressIncrement(int totalModuleCount); +} + +/// +/// Contains calculated tick information for progress updates. +/// +/// Number of progress ticks to add per second. +internal readonly record struct ProgressTickInfo(double TicksPerSecond); diff --git a/src/ModularPipelines/Helpers/IProgressDisplay.cs b/src/ModularPipelines/Helpers/IProgressDisplay.cs new file mode 100644 index 0000000000..1124485e1b --- /dev/null +++ b/src/ModularPipelines/Helpers/IProgressDisplay.cs @@ -0,0 +1,54 @@ +using ModularPipelines.Engine; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace ModularPipelines.Helpers; + +/// +/// Abstraction for displaying module execution progress. +/// Separates the display mechanism from the notification handling logic. +/// +internal interface IProgressDisplay +{ + /// + /// Starts the progress display and keeps it running until completion or cancellation. + /// + /// The modules to track progress for. + /// Token to signal cancellation. + Task RunAsync(OrganizedModules organizedModules, CancellationToken cancellationToken); + + /// + /// Registers a module as started and begins tracking its progress. + /// + /// The state of the started module. + /// Estimated time for the module to complete. + void OnModuleStarted(ModuleState moduleState, TimeSpan estimatedDuration); + + /// + /// Updates display when a module completes execution. + /// + /// The state of the completed module. + /// Whether the module completed successfully. + void OnModuleCompleted(ModuleState moduleState, bool isSuccessful); + + /// + /// Updates display when a module is skipped. + /// + /// The state of the skipped module. + void OnModuleSkipped(ModuleState moduleState); + + /// + /// Registers a sub-module as created and begins tracking its progress. + /// + /// The parent module that created the sub-module. + /// The created sub-module. + /// Estimated time for the sub-module to complete. + void OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration); + + /// + /// Updates display when a sub-module completes. + /// + /// The completed sub-module. + /// Whether the sub-module completed successfully. + void OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful); +} diff --git a/src/ModularPipelines/Helpers/IProgressPrinter.cs b/src/ModularPipelines/Helpers/IProgressPrinter.cs index 448dc792b0..c831a3614d 100644 --- a/src/ModularPipelines/Helpers/IProgressPrinter.cs +++ b/src/ModularPipelines/Helpers/IProgressPrinter.cs @@ -2,9 +2,21 @@ namespace ModularPipelines.Helpers; +/// +/// Coordinates progress display and results printing for pipeline execution. +/// internal interface IProgressPrinter { + /// + /// Displays real-time progress of module execution. + /// + /// The organized modules to track. + /// Token to signal cancellation. Task PrintProgress(OrganizedModules organizedModules, CancellationToken cancellationToken); + /// + /// Prints the final results summary after pipeline completion. + /// + /// The pipeline execution summary. void PrintResults(PipelineSummary pipelineSummary); -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Helpers/IResultsPrinter.cs b/src/ModularPipelines/Helpers/IResultsPrinter.cs new file mode 100644 index 0000000000..5e276361f3 --- /dev/null +++ b/src/ModularPipelines/Helpers/IResultsPrinter.cs @@ -0,0 +1,16 @@ +using ModularPipelines.Models; + +namespace ModularPipelines.Helpers; + +/// +/// Abstraction for printing pipeline execution results. +/// Separates results presentation from the progress tracking logic. +/// +internal interface IResultsPrinter +{ + /// + /// Prints a summary of the pipeline execution results. + /// + /// The summary containing execution results. + void PrintResults(PipelineSummary pipelineSummary); +} diff --git a/src/ModularPipelines/Helpers/ProgressCalculator.cs b/src/ModularPipelines/Helpers/ProgressCalculator.cs new file mode 100644 index 0000000000..b1a5eb66c2 --- /dev/null +++ b/src/ModularPipelines/Helpers/ProgressCalculator.cs @@ -0,0 +1,35 @@ +namespace ModularPipelines.Helpers; + +/// +/// Provides progress calculation logic for module execution tracking. +/// +internal class ProgressCalculator : IProgressCalculator +{ + private const double HeadroomMultiplier = 1.1; // Give 10% headroom + private const double MinEstimatedSeconds = 1.0; + private const double TotalProgressPercentage = 100.0; + + /// + public ProgressTickInfo CalculateTickInfo(TimeSpan estimatedDuration) + { + var estimatedWithHeadroom = estimatedDuration * HeadroomMultiplier; + var totalEstimatedSeconds = estimatedWithHeadroom.TotalSeconds >= MinEstimatedSeconds + ? estimatedWithHeadroom.TotalSeconds + : MinEstimatedSeconds; + + var ticksPerSecond = TotalProgressPercentage / totalEstimatedSeconds; + + return new ProgressTickInfo(ticksPerSecond); + } + + /// + public double CalculateProgressIncrement(int totalModuleCount) + { + if (totalModuleCount <= 0) + { + return 0; + } + + return TotalProgressPercentage / totalModuleCount; + } +} diff --git a/src/ModularPipelines/Helpers/ProgressPrinter.cs b/src/ModularPipelines/Helpers/ProgressPrinter.cs index 93aed1e339..94429d6d37 100644 --- a/src/ModularPipelines/Helpers/ProgressPrinter.cs +++ b/src/ModularPipelines/Helpers/ProgressPrinter.cs @@ -1,35 +1,25 @@ -using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Mediator; -using Microsoft.Extensions.Options; -using ModularPipelines.Engine; using ModularPipelines.Events; -using ModularPipelines.Extensions; using ModularPipelines.Models; -using ModularPipelines.Modules; -using ModularPipelines.Options; -using Spectre.Console; -using Status = ModularPipelines.Enums.Status; namespace ModularPipelines.Helpers; /// -/// Displays real-time progress of module execution using Spectre.Console. +/// Coordinates progress display and handles module execution notifications. +/// Delegates to for rendering and for results. /// /// /// -/// Thread Safety: This class is thread-safe. Notification handlers can be called -/// concurrently from multiple threads without external synchronization. +/// Architecture: This class follows the Single Responsibility Principle by acting +/// as a coordinator that connects the notification system to display components. +/// The actual rendering logic is handled by injected dependencies. /// /// -/// Synchronization Strategy: Uses a lock (_progressLock) to protect -/// Spectre.Console ProgressContext operations which are not thread-safe. -/// ConcurrentDictionary is used for progress task lookups to allow lock-free reads -/// from background tasks that tick progress independently. The lock is only held -/// during ProgressContext mutations (AddTask, StopTask, Increment). +/// Thread Safety: Thread safety is managed by the underlying +/// implementation. Notification handlers can be called concurrently from multiple threads. /// /// -/// [ExcludeFromCodeCoverage] internal class ProgressPrinter : IProgressPrinter, INotificationHandler, @@ -38,400 +28,57 @@ internal class ProgressPrinter : IProgressPrinter, INotificationHandler, INotificationHandler { - private readonly IOptions _options; - private readonly ConcurrentDictionary _progressTasks = new(); - private readonly ConcurrentDictionary _moduleStateProgressTasks = new(); - private readonly ConcurrentDictionary _subModuleProgressTasks = new(); - private ProgressContext? _progressContext; - private ProgressTask? _totalProgressTask; - private int _totalModuleCount; - private int _completedModuleCount; - private readonly object _progressLock = new(); + private readonly IProgressDisplay _progressDisplay; + private readonly IResultsPrinter _resultsPrinter; - public ProgressPrinter(IOptions options) + public ProgressPrinter( + IProgressDisplay progressDisplay, + IResultsPrinter resultsPrinter) { - _options = options; + _progressDisplay = progressDisplay; + _resultsPrinter = resultsPrinter; } - public async Task PrintProgress(OrganizedModules organizedModules, CancellationToken cancellationToken) + public Task PrintProgress(OrganizedModules organizedModules, CancellationToken cancellationToken) { - if (!_options.Value.ShowProgressInConsole) - { - return; - } - - _totalModuleCount = organizedModules.RunnableModules.Count; - - await AnsiConsole.Progress() - .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), - new ElapsedTimeColumn(), new RemainingTimeColumn(), new SpinnerColumn()) - .StartAsync(async progressContext => - { - _progressContext = progressContext; - _totalProgressTask = progressContext.AddTask($"[green]Total[/]"); - - // Register ignored modules immediately - RegisterIgnoredModules(organizedModules.IgnoredModules, progressContext); - - progressContext.Refresh(); - - // Keep the progress display alive until all modules complete - while (!progressContext.IsFinished && !cancellationToken.IsCancellationRequested) - { - progressContext.Refresh(); - await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - } + return _progressDisplay.RunAsync(organizedModules, cancellationToken); + } - if (cancellationToken.IsCancellationRequested) - { - progressContext.Refresh(); - } - }); + public void PrintResults(PipelineSummary pipelineSummary) + { + _resultsPrinter.PrintResults(pipelineSummary); } public ValueTask Handle(ModuleStartedNotification notification, CancellationToken cancellationToken) { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return ValueTask.CompletedTask; - } - - lock (_progressLock) - { - var moduleState = notification.ModuleState; - var moduleName = moduleState.ModuleType.Name; - var progressTask = _progressContext.AddTask(moduleName, new ProgressTaskSettings - { - AutoStart = true, - }); - - _progressTasks[moduleState.Module] = progressTask; - _moduleStateProgressTasks[moduleState] = progressTask; - - // Start ticking progress based on estimated duration - _ = Task.Run(async () => - { - try - { - var estimatedDuration = notification.EstimatedDuration * 1.1; // Give 10% headroom - var totalEstimatedSeconds = estimatedDuration.TotalSeconds >= 1.0 ? estimatedDuration.TotalSeconds : 1.0; - var ticksPerSecond = 100.0 / totalEstimatedSeconds; - - while (progressTask is { IsFinished: false, Value: < 95 } && ticksPerSecond + progressTask.Value < 95) - { - await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - progressTask.Increment(ticksPerSecond); - } - } - catch (ObjectDisposedException) - { - // Expected when progress context is disposed during module completion - } - catch (InvalidOperationException) - { - // Expected when progress task is stopped or context is in invalid state - } - catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) - { - // Suppress other exceptions to prevent unobserved task exceptions - // Progress updates are non-critical UI feedback - } - }, CancellationToken.None); - } - + _progressDisplay.OnModuleStarted(notification.ModuleState, notification.EstimatedDuration); return ValueTask.CompletedTask; } public ValueTask Handle(ModuleCompletedNotification notification, CancellationToken cancellationToken) { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return ValueTask.CompletedTask; - } - - lock (_progressLock) - { - var moduleState = notification.ModuleState; - if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) - { - if (!progressTask.IsFinished) - { - if (notification.IsSuccessful) - { - progressTask.Increment(100 - progressTask.Value); - } - - var moduleName = moduleState.ModuleType.Name; - progressTask.Description = notification.IsSuccessful - ? GetSuccessColor(moduleState) + moduleName + "[/]" - : $"[red][[Failed]] {moduleName}[/]"; - - progressTask.StopTask(); - } - - _completedModuleCount++; - _totalProgressTask?.Increment(100.0 / _totalModuleCount); - - if (_completedModuleCount >= _totalModuleCount) - { - _totalProgressTask?.StopTask(); - } - } - } - + _progressDisplay.OnModuleCompleted(notification.ModuleState, notification.IsSuccessful); return ValueTask.CompletedTask; } public ValueTask Handle(ModuleSkippedNotification notification, CancellationToken cancellationToken) { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return ValueTask.CompletedTask; - } - - lock (_progressLock) - { - var moduleState = notification.ModuleState; - var moduleName = moduleState.ModuleType.Name; - - if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) - { - progressTask.Description = $"[yellow][[Skipped]] {moduleName}[/]"; - if (!progressTask.IsFinished) - { - progressTask.StopTask(); - } - } - else - { - // Module was skipped before it started - var task = _progressContext.AddTask($"[yellow][[Skipped]] {moduleName}[/]"); - task.StopTask(); - _progressTasks[moduleState.Module] = task; - } - - _completedModuleCount++; - _totalProgressTask?.Increment(100.0 / _totalModuleCount); - - if (_completedModuleCount >= _totalModuleCount) - { - _totalProgressTask?.StopTask(); - } - } - + _progressDisplay.OnModuleSkipped(notification.ModuleState); return ValueTask.CompletedTask; } public ValueTask Handle(SubModuleCreatedNotification notification, CancellationToken cancellationToken) { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return ValueTask.CompletedTask; - } - - lock (_progressLock) - { - if (!_progressTasks.TryGetValue(notification.ParentModule, out var parentTask)) - { - return ValueTask.CompletedTask; - } - - var progressTask = _progressContext.AddTaskAfter($"- {notification.SubModule.Name}", - new ProgressTaskSettings { AutoStart = true }, parentTask); - - _subModuleProgressTasks[notification.SubModule] = progressTask; - - // Start ticking progress based on estimated duration - _ = Task.Run(async () => - { - try - { - var estimatedDuration = notification.EstimatedDuration * 1.1; // Give 10% headroom - var totalEstimatedSeconds = estimatedDuration.TotalSeconds >= 1 ? estimatedDuration.TotalSeconds : 1; - var ticksPerSecond = 100 / totalEstimatedSeconds; - - while (progressTask is { IsFinished: false, Value: < 95 }) - { - await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); - progressTask.Increment(ticksPerSecond); - } - } - catch (ObjectDisposedException) - { - // Expected when progress context is disposed during submodule completion - } - catch (InvalidOperationException) - { - // Expected when progress task is stopped or context is in invalid state - } - catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) - { - // Suppress other exceptions to prevent unobserved task exceptions - // Progress updates are non-critical UI feedback - } - }, CancellationToken.None); - } - + _progressDisplay.OnSubModuleCreated( + notification.ParentModule, + notification.SubModule, + notification.EstimatedDuration); return ValueTask.CompletedTask; } public ValueTask Handle(SubModuleCompletedNotification notification, CancellationToken cancellationToken) { - if (_progressContext == null || !_options.Value.ShowProgressInConsole) - { - return ValueTask.CompletedTask; - } - - lock (_progressLock) - { - if (_subModuleProgressTasks.TryGetValue(notification.SubModule, out var progressTask)) - { - if (notification.IsSuccessful) - { - progressTask.Increment(100 - progressTask.Value); - } - - progressTask.Description = notification.IsSuccessful - ? $"[green]- {notification.SubModule.Name}[/]" - : $"[red][[Failed]] - {notification.SubModule.Name}[/]"; - - progressTask.StopTask(); - } - } - + _progressDisplay.OnSubModuleCompleted(notification.SubModule, notification.IsSuccessful); return ValueTask.CompletedTask; } - - public void PrintResults(PipelineSummary pipelineSummary) - { - if (!_options.Value.PrintResults) - { - return; - } - - var table = new Table - { - Expand = true, - }; - - table.AddColumn("Module"); - table.AddColumn("Duration"); - table.AddColumn("Status"); - table.AddColumn("Start"); - table.AddColumn("End"); - - // Create a lookup for module timelines by module name - var timelineLookup = pipelineSummary.ModuleTimelines? - .ToDictionary(t => t.ModuleName, t => t) - ?? new Dictionary(); - - foreach (var module in pipelineSummary.Modules) - { - var moduleName = module.GetType().Name; - var hasTimeline = timelineLookup.TryGetValue(moduleName, out var timeline); - - var duration = hasTimeline && timeline!.ExecutionDuration.HasValue - ? timeline.ExecutionDuration.Value.ToDisplayString() - : "-"; - - var status = hasTimeline - ? timeline!.Status.ToDisplayString() - : "-"; - - var isSameDay = hasTimeline - && timeline!.StartTime.HasValue - && timeline.EndTime.HasValue - && timeline.StartTime.Value.Date == timeline.EndTime.Value.Date; - - var start = hasTimeline && timeline!.StartTime.HasValue - ? GetTime(timeline.StartTime.Value, isSameDay) - : "-"; - - var end = hasTimeline && timeline!.EndTime.HasValue - ? GetTime(timeline.EndTime.Value, isSameDay) - : "-"; - - table.AddRow( - $"[cyan]{moduleName}[/]", - duration, - status, - start, - end); - - table.AddEmptyRow(); - } - - var isSameDayTotal = pipelineSummary.Start.Date == pipelineSummary.End.Date; - - table.AddRow( - "Total", - pipelineSummary.TotalDuration.ToDisplayString(), - pipelineSummary.Status.ToDisplayString(), - GetTime(pipelineSummary.Start, isSameDayTotal), - GetTime(pipelineSummary.End, isSameDayTotal)); - - Console.WriteLine(); - AnsiConsole.Write(table.Expand()); - - // Print execution metrics if available - PrintMetrics(pipelineSummary); - - Console.WriteLine(); - } - - private static void PrintMetrics(PipelineSummary pipelineSummary) - { - var metrics = pipelineSummary.Metrics; - if (metrics == null) - { - return; - } - - Console.WriteLine(); - AnsiConsole.MarkupLine("[bold underline]Execution Metrics[/]"); - - var metricsTable = new Table - { - Border = TableBorder.Rounded, - ShowHeaders = false, - }; - - metricsTable.AddColumn("Metric"); - metricsTable.AddColumn("Value"); - - metricsTable.AddRow("[cyan]Parallelism Factor[/]", $"[bold]{metrics.ParallelismFactor:F2}x[/]"); - metricsTable.AddRow("[cyan]Peak Concurrency[/]", $"[bold]{metrics.PeakConcurrency}[/] modules"); - metricsTable.AddRow("[cyan]Avg Concurrency[/]", $"[bold]{metrics.AverageConcurrency:F2}[/] modules"); - metricsTable.AddRow("[cyan]Efficiency[/]", $"[bold]{metrics.Efficiency * 100:F0}%[/]"); - metricsTable.AddRow("[cyan]Sequential Time[/]", $"[dim]{metrics.TotalModuleExecutionTime.ToDisplayString()}[/]"); - metricsTable.AddRow("[cyan]Wall-Clock Time[/]", $"[dim]{metrics.WallClockDuration.ToDisplayString()}[/]"); - - AnsiConsole.Write(metricsTable); - } - - private static string GetSuccessColor(ModuleState moduleState) - { - // Module state indicates success if State is Completed without issues - return moduleState.State == ModuleExecutionState.Completed ? "[green]" : "[orange3]"; - } - - private static void RegisterIgnoredModules(IReadOnlyList modulesToIgnore, ProgressContext progressContext) - { - foreach (var ignoredModule in modulesToIgnore) - { - progressContext.AddTask($"[yellow][[Ignored]] {ignoredModule.Module.GetType().Name}[/]").StopTask(); - } - } - - private static string GetTime(DateTimeOffset dateTimeOffset, bool isSameDay) - { - if (dateTimeOffset == DateTimeOffset.MinValue) - { - return string.Empty; - } - - return isSameDay - ? dateTimeOffset.ToTimeOnly().ToString("h:mm:ss tt") - : dateTimeOffset.ToString("yyyy/MM/dd h:mm:ss tt"); - } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs b/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs new file mode 100644 index 0000000000..8741357cfa --- /dev/null +++ b/src/ModularPipelines/Helpers/SpectreProgressDisplay.cs @@ -0,0 +1,278 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using ModularPipelines.Engine; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using ModularPipelines.Options; +using Spectre.Console; + +namespace ModularPipelines.Helpers; + +/// +/// Spectre.Console implementation of progress display. +/// Handles all console rendering and Spectre.Console-specific operations. +/// +/// +/// +/// Thread Safety: This class is thread-safe. Methods can be called +/// concurrently from multiple threads without external synchronization. +/// +/// +/// Synchronization Strategy: Uses a lock (_progressLock) to protect +/// Spectre.Console ProgressContext operations which are not thread-safe. +/// ConcurrentDictionary is used for progress task lookups to allow lock-free reads +/// from background tasks that tick progress independently. The lock is only held +/// during ProgressContext mutations (AddTask, StopTask, Increment). +/// +/// +/// +[ExcludeFromCodeCoverage] +internal class SpectreProgressDisplay : IProgressDisplay +{ + private readonly IOptions _options; + private readonly IProgressCalculator _progressCalculator; + private readonly ConcurrentDictionary _progressTasks = new(); + private readonly ConcurrentDictionary _moduleStateProgressTasks = new(); + private readonly ConcurrentDictionary _subModuleProgressTasks = new(); + private ProgressContext? _progressContext; + private ProgressTask? _totalProgressTask; + private int _totalModuleCount; + private int _completedModuleCount; + private readonly object _progressLock = new(); + + public SpectreProgressDisplay( + IOptions options, + IProgressCalculator progressCalculator) + { + _options = options; + _progressCalculator = progressCalculator; + } + + public async Task RunAsync(OrganizedModules organizedModules, CancellationToken cancellationToken) + { + if (!_options.Value.ShowProgressInConsole) + { + return; + } + + _totalModuleCount = organizedModules.RunnableModules.Count; + + await AnsiConsole.Progress() + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), + new ElapsedTimeColumn(), new RemainingTimeColumn(), new SpinnerColumn()) + .StartAsync(async progressContext => + { + _progressContext = progressContext; + _totalProgressTask = progressContext.AddTask("[green]Total[/]"); + + // Register ignored modules immediately + RegisterIgnoredModules(organizedModules.IgnoredModules, progressContext); + + progressContext.Refresh(); + + // Keep the progress display alive until all modules complete + while (!progressContext.IsFinished && !cancellationToken.IsCancellationRequested) + { + progressContext.Refresh(); + await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); + } + + if (cancellationToken.IsCancellationRequested) + { + progressContext.Refresh(); + } + }); + } + + public void OnModuleStarted(ModuleState moduleState, TimeSpan estimatedDuration) + { + if (_progressContext == null || !_options.Value.ShowProgressInConsole) + { + return; + } + + lock (_progressLock) + { + var moduleName = moduleState.ModuleType.Name; + var progressTask = _progressContext.AddTask(moduleName, new ProgressTaskSettings + { + AutoStart = true, + }); + + _progressTasks[moduleState.Module] = progressTask; + _moduleStateProgressTasks[moduleState] = progressTask; + + // Start ticking progress based on estimated duration + StartProgressTicker(progressTask, estimatedDuration); + } + } + + public void OnModuleCompleted(ModuleState moduleState, bool isSuccessful) + { + if (_progressContext == null || !_options.Value.ShowProgressInConsole) + { + return; + } + + lock (_progressLock) + { + if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) + { + if (!progressTask.IsFinished) + { + if (isSuccessful) + { + progressTask.Increment(100 - progressTask.Value); + } + + var moduleName = moduleState.ModuleType.Name; + progressTask.Description = isSuccessful + ? GetSuccessColor(moduleState) + moduleName + "[/]" + : $"[red][[Failed]] {moduleName}[/]"; + + progressTask.StopTask(); + } + + _completedModuleCount++; + _totalProgressTask?.Increment(_progressCalculator.CalculateProgressIncrement(_totalModuleCount)); + + if (_completedModuleCount >= _totalModuleCount) + { + _totalProgressTask?.StopTask(); + } + } + } + } + + public void OnModuleSkipped(ModuleState moduleState) + { + if (_progressContext == null || !_options.Value.ShowProgressInConsole) + { + return; + } + + lock (_progressLock) + { + var moduleName = moduleState.ModuleType.Name; + + if (_progressTasks.TryGetValue(moduleState.Module, out var progressTask)) + { + progressTask.Description = $"[yellow][[Skipped]] {moduleName}[/]"; + if (!progressTask.IsFinished) + { + progressTask.StopTask(); + } + } + else + { + // Module was skipped before it started + var task = _progressContext.AddTask($"[yellow][[Skipped]] {moduleName}[/]"); + task.StopTask(); + _progressTasks[moduleState.Module] = task; + } + + _completedModuleCount++; + _totalProgressTask?.Increment(_progressCalculator.CalculateProgressIncrement(_totalModuleCount)); + + if (_completedModuleCount >= _totalModuleCount) + { + _totalProgressTask?.StopTask(); + } + } + } + + public void OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration) + { + if (_progressContext == null || !_options.Value.ShowProgressInConsole) + { + return; + } + + lock (_progressLock) + { + if (!_progressTasks.TryGetValue(parentModule, out var parentTask)) + { + return; + } + + var progressTask = _progressContext.AddTaskAfter($"- {subModule.Name}", + new ProgressTaskSettings { AutoStart = true }, parentTask); + + _subModuleProgressTasks[subModule] = progressTask; + + // Start ticking progress based on estimated duration + StartProgressTicker(progressTask, estimatedDuration); + } + } + + public void OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful) + { + if (_progressContext == null || !_options.Value.ShowProgressInConsole) + { + return; + } + + lock (_progressLock) + { + if (_subModuleProgressTasks.TryGetValue(subModule, out var progressTask)) + { + if (isSuccessful) + { + progressTask.Increment(100 - progressTask.Value); + } + + progressTask.Description = isSuccessful + ? $"[green]- {subModule.Name}[/]" + : $"[red][[Failed]] - {subModule.Name}[/]"; + + progressTask.StopTask(); + } + } + } + + private void StartProgressTicker(ProgressTask progressTask, TimeSpan estimatedDuration) + { + _ = Task.Run(async () => + { + try + { + var tickInfo = _progressCalculator.CalculateTickInfo(estimatedDuration); + + while (progressTask is { IsFinished: false, Value: < 95 } && + tickInfo.TicksPerSecond + progressTask.Value < 95) + { + await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); + progressTask.Increment(tickInfo.TicksPerSecond); + } + } + catch (ObjectDisposedException) + { + // Expected when progress context is disposed during module completion + } + catch (InvalidOperationException) + { + // Expected when progress task is stopped or context is in invalid state + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) + { + // Suppress other exceptions to prevent unobserved task exceptions + // Progress updates are non-critical UI feedback + } + }, CancellationToken.None); + } + + private static string GetSuccessColor(ModuleState moduleState) + { + // Module state indicates success if State is Completed without issues + return moduleState.State == ModuleExecutionState.Completed ? "[green]" : "[orange3]"; + } + + private static void RegisterIgnoredModules(IReadOnlyList modulesToIgnore, ProgressContext progressContext) + { + foreach (var ignoredModule in modulesToIgnore) + { + progressContext.AddTask($"[yellow][[Ignored]] {ignoredModule.Module.GetType().Name}[/]").StopTask(); + } + } +} diff --git a/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs new file mode 100644 index 0000000000..a90310e56f --- /dev/null +++ b/src/ModularPipelines/Helpers/SpectreResultsPrinter.cs @@ -0,0 +1,161 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; +using ModularPipelines.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Options; +using Spectre.Console; + +namespace ModularPipelines.Helpers; + +/// +/// Spectre.Console implementation of results printing. +/// Handles all console rendering for pipeline execution results. +/// +[ExcludeFromCodeCoverage] +internal class SpectreResultsPrinter : IResultsPrinter +{ + private readonly IOptions _options; + + public SpectreResultsPrinter(IOptions options) + { + _options = options; + } + + public void PrintResults(PipelineSummary pipelineSummary) + { + if (!_options.Value.PrintResults) + { + return; + } + + var table = CreateModulesTable(pipelineSummary); + + Console.WriteLine(); + AnsiConsole.Write(table.Expand()); + + // Print execution metrics if available + PrintMetrics(pipelineSummary); + + Console.WriteLine(); + } + + private static Table CreateModulesTable(PipelineSummary pipelineSummary) + { + var table = new Table + { + Expand = true, + }; + + table.AddColumn("Module"); + table.AddColumn("Duration"); + table.AddColumn("Status"); + table.AddColumn("Start"); + table.AddColumn("End"); + + // Create a lookup for module timelines by module name + var timelineLookup = pipelineSummary.ModuleTimelines? + .ToDictionary(t => t.ModuleName, t => t) + ?? new Dictionary(); + + foreach (var module in pipelineSummary.Modules) + { + AddModuleRow(table, module, timelineLookup); + table.AddEmptyRow(); + } + + AddTotalRow(table, pipelineSummary); + + return table; + } + + private static void AddModuleRow( + Table table, + object module, + Dictionary timelineLookup) + { + var moduleName = module.GetType().Name; + var hasTimeline = timelineLookup.TryGetValue(moduleName, out var timeline); + + var duration = hasTimeline && timeline!.ExecutionDuration.HasValue + ? timeline.ExecutionDuration.Value.ToDisplayString() + : "-"; + + var status = hasTimeline + ? timeline!.Status.ToDisplayString() + : "-"; + + var isSameDay = hasTimeline + && timeline!.StartTime.HasValue + && timeline.EndTime.HasValue + && timeline.StartTime.Value.Date == timeline.EndTime.Value.Date; + + var start = hasTimeline && timeline!.StartTime.HasValue + ? FormatTime(timeline.StartTime.Value, isSameDay) + : "-"; + + var end = hasTimeline && timeline!.EndTime.HasValue + ? FormatTime(timeline.EndTime.Value, isSameDay) + : "-"; + + table.AddRow( + $"[cyan]{moduleName}[/]", + duration, + status, + start, + end); + } + + private static void AddTotalRow(Table table, PipelineSummary pipelineSummary) + { + var isSameDayTotal = pipelineSummary.Start.Date == pipelineSummary.End.Date; + + table.AddRow( + "Total", + pipelineSummary.TotalDuration.ToDisplayString(), + pipelineSummary.Status.ToDisplayString(), + FormatTime(pipelineSummary.Start, isSameDayTotal), + FormatTime(pipelineSummary.End, isSameDayTotal)); + } + + private static void PrintMetrics(PipelineSummary pipelineSummary) + { + var metrics = pipelineSummary.Metrics; + if (metrics == null) + { + return; + } + + Console.WriteLine(); + AnsiConsole.MarkupLine("[bold underline]Execution Metrics[/]"); + + var metricsTable = new Table + { + Border = TableBorder.Rounded, + ShowHeaders = false, + }; + + metricsTable.AddColumn("Metric"); + metricsTable.AddColumn("Value"); + + metricsTable.AddRow("[cyan]Parallelism Factor[/]", $"[bold]{metrics.ParallelismFactor:F2}x[/]"); + metricsTable.AddRow("[cyan]Peak Concurrency[/]", $"[bold]{metrics.PeakConcurrency}[/] modules"); + metricsTable.AddRow("[cyan]Avg Concurrency[/]", $"[bold]{metrics.AverageConcurrency:F2}[/] modules"); + metricsTable.AddRow("[cyan]Efficiency[/]", $"[bold]{metrics.Efficiency * 100:F0}%[/]"); + metricsTable.AddRow("[cyan]Sequential Time[/]", $"[dim]{metrics.TotalModuleExecutionTime.ToDisplayString()}[/]"); + metricsTable.AddRow("[cyan]Wall-Clock Time[/]", $"[dim]{metrics.WallClockDuration.ToDisplayString()}[/]"); + + AnsiConsole.Write(metricsTable); + } + + private static string FormatTime(DateTimeOffset dateTimeOffset, bool isSameDay) + { + if (dateTimeOffset == DateTimeOffset.MinValue) + { + return string.Empty; + } + + return isSameDay + ? dateTimeOffset.ToTimeOnly().ToString("h:mm:ss tt") + : dateTimeOffset.ToString("yyyy/MM/dd h:mm:ss tt"); + } +}