diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 038f6d4d19d..3097fc2ef63 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -508,7 +508,9 @@ public async Task ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumera Title = title, Number = stepCounter++, StartTime = DateTime.UtcNow, - CompletionState = activity.Data.CompletionState + CompletionState = activity.Data.CompletionState, + ParentStepId = activity.Data.ParentStepId, + HierarchyLevel = activity.Data.HierarchyLevel ?? 0 }; steps[activity.Data.Id] = stepInfo; @@ -517,6 +519,8 @@ public async Task ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumera } else if (IsCompletionStateComplete(activity.Data.CompletionState)) { + stepInfo.ParentStepId ??= activity.Data.ParentStepId; + stepInfo.HierarchyLevel = activity.Data.HierarchyLevel ?? stepInfo.HierarchyLevel; stepInfo.CompletionState = activity.Data.CompletionState; stepInfo.CompletionText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data); stepInfo.EndTime = DateTime.UtcNow; @@ -670,8 +674,11 @@ public async Task ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumera } } - // Build duration breakdown (sorted by duration desc) + // Build duration breakdown, ordered by step sequence var now = DateTime.UtcNow; + var earliestStartTime = steps.Count > 0 + ? steps.Values.Min(s => s.StartTime) + : now; var durationRecords = steps.Values.Select(s => { var end = s.EndTime ?? now; @@ -687,9 +694,14 @@ var cs when IsCompletionStateWarning(cs) => ConsoleActivityLogger.ActivityState. s.Title, state, end - s.StartTime, - s.FailureReason); + s.FailureReason, + s.ParentStepId, + s.HierarchyLevel, + s.Number, + s.StartTime - earliestStartTime, + end - earliestStartTime); }) - .OrderByDescending(r => r.Duration) + .OrderBy(r => r.Sequence) .ToList(); logger.SetStepDurations(durationRecords); @@ -901,6 +913,8 @@ private class StepInfo public string Id { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public int Number { get; set; } + public string? ParentStepId { get; set; } + public int HierarchyLevel { get; set; } public DateTime StartTime { get; set; } public DateTime? EndTime { get; set; } public string CompletionState { get; set; } = CompletionStates.InProgress; diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index 2e51d0c0f4f..eb31dae4d09 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -122,5 +122,17 @@ internal static string PipelineEnvironmentOptionDescription { return ResourceManager.GetString("PipelineEnvironmentOptionDescription", resourceCulture); } } + + internal static string PipelineStepTimelineLabel { + get { + return ResourceManager.GetString("PipelineStepTimelineLabel", resourceCulture); + } + } + + internal static string PipelineStepsSummaryTitle { + get { + return ResourceManager.GetString("PipelineStepsSummaryTitle", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index 8c70d0f578e..8c56005c286 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -156,4 +156,10 @@ The environment to use for the operation. The default is 'Production'. + + Step timeline: + + + Steps Summary: + diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index eea76333d39..6b009e2e638 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index d076240fed8..02269a7357b 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index 03b7f0f23c7..37e4a94fe39 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index d5527cdca35..976848646af 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index b561477a05e..71d54270d27 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 4a9be75a2ad..4e19ab166bd 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 28bf17ac5a0..0c9caab6a81 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 72760f42bf7..03027c839b7 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 7c539858126..1d3eb96f886 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 98943c2bc3c..c558a02065f 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index fe18b6019c4..6dafdc878e8 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index ff940405fc7..e1e856cc1c5 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index bcc71ac17b5..92ffa64930e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -47,6 +47,16 @@ Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'. + + Step timeline: + Step timeline: + + + + Steps Summary: + Steps Summary: + + Scanning for running apphosts... Scanning for running apphosts... diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 0a64c1ff90f..5fbe28ced0f 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.RegularExpressions; using Aspire.Cli.Backchannel; +using Aspire.Cli.Resources; using Aspire.Shared; using Spectre.Console; @@ -50,6 +51,8 @@ internal sealed class ConsoleActivityLogger private const string WarningSymbol = "⚠"; private const string InProgressSymbol = "→"; private const string InfoSymbol = "i"; + private const int SummaryTimelineWidth = 28; + private const int SummaryTimelineTicks = 4; public ConsoleActivityLogger(IAnsiConsole console, ICliHostEnvironment hostEnvironment, bool isDebugOrTraceLoggingEnabled = false, bool? forceColor = null) { @@ -243,32 +246,7 @@ public void WriteSummary() if (_durationRecords is { Count: > 0 }) { - _console.WriteLine(); - _console.MarkupLine("Steps Summary:"); - foreach (var rec in _durationRecords) - { - // PadLeft(10) accommodates split units like "2h 30m", decimal units like "1.5s", and very long durations like "999d 23h" - var durStr = DurationFormatter.FormatDuration(rec.Duration, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed).PadLeft(10); - var symbol = rec.State switch - { - ActivityState.Success => _enableColor ? "[green]" + SuccessSymbol + "[/]" : SuccessSymbol, - ActivityState.Warning => _enableColor ? "[yellow]" + WarningSymbol + "[/]" : WarningSymbol, - ActivityState.Failure => _enableColor ? "[red]" + FailureSymbol + "[/]" : FailureSymbol, - _ => _enableColor ? "[cyan]" + InProgressSymbol + "[/]" : InProgressSymbol - }; - var name = rec.DisplayName.EscapeMarkup(); - var reason = rec.State == ActivityState.Failure && !string.IsNullOrEmpty(rec.FailureReason) - ? ( _enableColor ? $" [red]— {HighlightMessage(rec.FailureReason!.EscapeMarkup())}[/]" : $" — {rec.FailureReason!.EscapeMarkup()}" ) - : string.Empty; - var lineSb = new StringBuilder(); - lineSb.Append(" ") - .Append(durStr).Append(" ") - .Append(symbol).Append(' ') - .Append("[dim]").Append(name).Append("[/]") - .Append(reason); - _console.MarkupLine(lineSb.ToString()); - } - _console.WriteLine(); + WriteStepDurationsSummary(_durationRecords); } // If a caller provided a final status line via SetFinalResult, print it now @@ -354,14 +332,287 @@ public void SetFinalResult(bool succeeded, IReadOnlyList - /// Provides per-step duration data (already sorted) for inclusion in the summary. + /// Provides per-step duration data for inclusion in the summary. /// public void SetStepDurations(IEnumerable records) { _durationRecords = records.ToList(); } - public readonly record struct StepDurationRecord(string Key, string DisplayName, ActivityState State, TimeSpan Duration, string? FailureReason); + public readonly record struct StepDurationRecord( + string Key, + string DisplayName, + ActivityState State, + TimeSpan Duration, + string? FailureReason, + string? ParentKey = null, + int Level = 0, + int Sequence = 0, + TimeSpan StartOffset = default, + TimeSpan EndOffset = default); + + private void WriteStepDurationsSummary(IReadOnlyList records) + { + var orderedRecords = OrderStepDurationsHierarchically(records); + var summaryTitle = SharedCommandStrings.PipelineStepsSummaryTitle; + var timelineLabel = SharedCommandStrings.PipelineStepTimelineLabel; + var durationWidth = Math.Max(10, orderedRecords.Max(r => DurationFormatter.FormatDuration(r.Duration, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed).Length)); + var nameWidth = Math.Max(timelineLabel.Length, orderedRecords.Max(r => GetIndentedDisplayName(r).Length)); + var totalTimeline = orderedRecords.Max(r => r.EndOffset > TimeSpan.Zero ? r.EndOffset : r.Duration); + var renderTimeline = ShouldRenderTimeline(durationWidth, nameWidth, totalTimeline); + var timelinePrefix = $" {new string(' ', durationWidth)} {new string(' ', nameWidth)} "; + var timelineLabelPrefix = $" {new string(' ', durationWidth)} {timelineLabel.PadRight(nameWidth)} "; + + _console.WriteLine(); + _console.MarkupLine(summaryTitle); + + if (renderTimeline) + { + _console.MarkupLine($"{timelineLabelPrefix}[dim]{BuildTimelineLabels(totalTimeline, SummaryTimelineWidth).EscapeMarkup()}[/]"); + _console.MarkupLine($"{timelinePrefix}[dim]{BuildTimelineScale(SummaryTimelineWidth).EscapeMarkup()}[/]"); + } + + foreach (var rec in orderedRecords) + { + var durStr = DurationFormatter.FormatDuration(rec.Duration, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed).PadLeft(durationWidth); + var symbol = rec.State switch + { + ActivityState.Success => _enableColor ? "[green]" + SuccessSymbol + "[/]" : SuccessSymbol, + ActivityState.Warning => _enableColor ? "[yellow]" + WarningSymbol + "[/]" : WarningSymbol, + ActivityState.Failure => _enableColor ? "[red]" + FailureSymbol + "[/]" : FailureSymbol, + _ => _enableColor ? "[cyan]" + InProgressSymbol + "[/]" : InProgressSymbol + }; + var displayName = GetIndentedDisplayName(rec); + var name = (renderTimeline ? displayName.PadRight(nameWidth) : displayName).EscapeMarkup(); + var reason = rec.State == ActivityState.Failure && !string.IsNullOrEmpty(rec.FailureReason) + ? (_enableColor ? $" [red]— {HighlightMessage(rec.FailureReason!.EscapeMarkup())}[/]" : $" — {rec.FailureReason!.EscapeMarkup()}") + : string.Empty; + + var lineSb = new StringBuilder(); + lineSb.Append(" ") + .Append(durStr).Append(" ") + .Append(symbol).Append(' ') + .Append("[dim]").Append(name).Append("[/]"); + + if (renderTimeline) + { + var timelineBar = ColorizeSummaryBar(BuildTimelineBar(rec, totalTimeline, SummaryTimelineWidth), rec.State); + lineSb.Append(" ").Append(timelineBar); + } + + lineSb.Append(reason); + _console.MarkupLine(lineSb.ToString()); + } + + _console.WriteLine(); + } + + private static List OrderStepDurationsHierarchically(IReadOnlyList records) + { + var orderedRecords = records + .OrderBy(r => r.Sequence) + .ThenBy(r => r.DisplayName, StringComparers.CommandName) + .ToList(); + var recordsByKey = orderedRecords.ToDictionary(r => r.Key, StringComparers.CommandName); + var childrenByParent = new Dictionary>(StringComparers.CommandName); + + foreach (var record in orderedRecords) + { + if (record.ParentKey is { Length: > 0 } parentKey && + !string.Equals(parentKey, record.Key, StringComparisons.CommandName) && + recordsByKey.ContainsKey(parentKey)) + { + if (!childrenByParent.TryGetValue(parentKey, out var children)) + { + children = []; + childrenByParent[parentKey] = children; + } + + children.Add(record); + } + } + + foreach (var children in childrenByParent.Values) + { + children.Sort(static (left, right) => + { + var sequenceComparison = left.Sequence.CompareTo(right.Sequence); + return sequenceComparison != 0 + ? sequenceComparison + : StringComparers.CommandName.Compare(left.DisplayName, right.DisplayName); + }); + } + + var result = new List(orderedRecords.Count); + var visited = new HashSet(StringComparers.CommandName); + + foreach (var root in orderedRecords.Where(r => r.ParentKey is null || !recordsByKey.ContainsKey(r.ParentKey))) + { + VisitRecord(root, childrenByParent, visited, result); + } + + foreach (var record in orderedRecords) + { + if (visited.Add(record.Key)) + { + result.Add(record); + } + } + + return result; + } + + private static void VisitRecord( + StepDurationRecord record, + IReadOnlyDictionary> childrenByParent, + ISet visited, + ICollection result) + { + if (!visited.Add(record.Key)) + { + return; + } + + result.Add(record); + + if (!childrenByParent.TryGetValue(record.Key, out var children)) + { + return; + } + + foreach (var child in children) + { + VisitRecord(child, childrenByParent, visited, result); + } + } + + private static string GetIndentedDisplayName(StepDurationRecord record) + { + var level = Math.Max(record.Level, 0); + return level == 0 + ? record.DisplayName + : $"{new string(' ', level * 2)}{record.DisplayName}"; + } + + private static string BuildTimelineScale(int width) + { + if (width <= 0) + { + return "││"; + } + + var chars = Enumerable.Repeat('─', width).ToArray(); + for (var tick = 1; tick < SummaryTimelineTicks; tick++) + { + var position = (int)Math.Round((double)tick * (width - 1) / SummaryTimelineTicks); + if (position >= 0 && position < chars.Length) + { + chars[position] = '┬'; + } + } + + return $"│{new string(chars)}│"; + } + + private static string BuildTimelineLabels(TimeSpan totalTimeline, int width) + { + // Match the zero label to the unit family used by the end label so short timelines don't mix `0s` + // with millisecond- or microsecond-based durations. + var startText = BuildTimelineStartLabel(totalTimeline); + var endText = DurationFormatter.FormatDuration(totalTimeline, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed); + var labelWidth = Math.Max(width + 2, startText.Length + 1 + endText.Length); + var spacing = Math.Max(1, labelWidth - startText.Length - endText.Length); + + return $"{startText}{new string(' ', spacing)}{endText}"; + } + + private static string BuildTimelineStartLabel(TimeSpan totalTimeline) + { + var unit = totalTimeline > TimeSpan.Zero ? DurationFormatter.GetUnit(totalTimeline) : "ms"; + return $"0{unit}"; + } + + private bool ShouldRenderTimeline(int durationWidth, int nameWidth, TimeSpan totalTimeline) + { + var consoleWidth = _console.Profile.Width; + if (consoleWidth <= 0 || consoleWidth == int.MaxValue) + { + return true; + } + + // If the shared padded name column plus the chart would overflow the console, prefer keeping + // the hierarchical step names readable and omit the chart for the whole summary. + var timelineWidth = Math.Max( + BuildTimelineLabels(totalTimeline, SummaryTimelineWidth).Length, + BuildTimelineScale(SummaryTimelineWidth).Length); + + return 2 + durationWidth + 2 + 1 + 1 + nameWidth + 2 + timelineWidth <= consoleWidth; + } + + private static string BuildTimelineBar(StepDurationRecord record, TimeSpan totalTimeline, int width) + { + if (width <= 0) + { + return "││"; + } + + var chars = Enumerable.Repeat(' ', width).ToArray(); + var start = record.StartOffset; + var end = record.EndOffset > start ? record.EndOffset : start + record.Duration; + double startPosition; + double endPosition; + + if (totalTimeline <= TimeSpan.Zero) + { + startPosition = 0; + endPosition = 0; + } + else + { + startPosition = start.TotalMilliseconds / totalTimeline.TotalMilliseconds * (width - 1); + endPosition = end.TotalMilliseconds / totalTimeline.TotalMilliseconds * (width - 1); + } + + // When a span is smaller than a single character cell it would disappear if we only rendered bar caps. + // Show a point marker instead so very short durations remain visible in the summary. + if (endPosition - startPosition < 1) + { + var pointIndex = Math.Clamp((int)Math.Round((startPosition + endPosition) / 2, MidpointRounding.AwayFromZero), 0, width - 1); + chars[pointIndex] = '╴'; + } + else + { + var startIndex = Math.Clamp((int)Math.Floor(startPosition), 0, width - 1); + var endIndex = Math.Clamp((int)Math.Ceiling(endPosition), startIndex, width - 1); + chars[startIndex] = '╶'; + chars[endIndex] = '╴'; + + for (var i = startIndex + 1; i < endIndex; i++) + { + chars[i] = '─'; + } + } + + return $"│{new string(chars)}│"; + } + + private string ColorizeSummaryBar(string bar, ActivityState state) + { + var escapedBar = bar.EscapeMarkup(); + + if (!_enableColor) + { + return escapedBar; + } + + return state switch + { + ActivityState.Success => $"[green]{escapedBar}[/]", + ActivityState.Warning => $"[yellow]{escapedBar}[/]", + ActivityState.Failure => $"[red]{escapedBar}[/]", + _ => $"[cyan]{escapedBar}[/]" + }; + } private void WriteCompletion(string taskKey, string symbol, string message, ActivityState state, double? seconds) { diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 6cf43fcf694..fbe299a547a 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -465,6 +465,17 @@ internal sealed class PublishingActivityData /// public string? StepId { get; init; } + /// + /// Gets the identifier of the parent step used for hierarchical step summaries. + /// + public string? ParentStepId { get; init; } + + /// + /// Gets the hierarchical level of the step used for display purposes. + /// Nullable for backwards compatibility with older app hosts that do not send hierarchy metadata. + /// + public int? HierarchyLevel { get; init; } + /// /// Gets the optional completion message for tasks (appears as dimmed child text). /// diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 8e2c2f82941..2313f0fb28e 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -7,6 +7,7 @@ #pragma warning disable ASPIREPIPELINES002 #pragma warning disable ASPIREPIPELINES003 +using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; using System.Runtime.ExceptionServices; @@ -600,6 +601,8 @@ private static async Task ExecuteStepsAsTaskDag( { // Create a TaskCompletionSource for each step var stepCompletions = new Dictionary(steps.Count, StringComparer.Ordinal); + var stepHierarchyByName = GetStepHierarchyByStep(steps, stepsByName); + var reportingStepIds = new ConcurrentDictionary(StringComparer.Ordinal); foreach (var step in steps) { stepCompletions[step.Name] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -641,7 +644,22 @@ async Task ExecuteStepWithDependencies(PipelineStep step) try { var activityReporter = context.Services.GetRequiredService(); - var reportingStep = await activityReporter.CreateStepAsync(step.Name, context.CancellationToken).ConfigureAwait(false); + var stepHierarchy = stepHierarchyByName.GetValueOrDefault(step.Name); + var parentStepId = stepHierarchy.ParentStepName is { } parentStepName && + reportingStepIds.TryGetValue(parentStepName, out var value) + ? value + : null; + + var reportingStep = activityReporter switch + { + PipelineActivityReporter pipelineActivityReporter => await pipelineActivityReporter.CreateStepAsync(step.Name, parentStepId, stepHierarchy.Level, context.CancellationToken).ConfigureAwait(false), + _ => await activityReporter.CreateStepAsync(step.Name, context.CancellationToken).ConfigureAwait(false) + }; + + if (reportingStep is ReportingStep concreteReportingStep) + { + reportingStepIds[step.Name] = concreteReportingStep.Id; + } await using (reportingStep.ConfigureAwait(false)) { @@ -1090,6 +1108,30 @@ private static HashSet GetAllTransitiveDependencies( return result; } + /// + /// Gets the display hierarchy information for a set of steps. + /// + private static Dictionary GetStepHierarchyByStep( + List steps, + Dictionary stepsByName) + { + var executionLevels = GetExecutionLevelsByStep(steps, stepsByName); + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var step in steps) + { + var parentStepName = step.DependsOnSteps + .Where(stepsByName.ContainsKey) + .OrderByDescending(dep => executionLevels.GetValueOrDefault(dep)) + .ThenBy(dep => dep, StringComparer.Ordinal) + .FirstOrDefault(); + + result[step.Name] = new StepHierarchyInfo(parentStepName, executionLevels.GetValueOrDefault(step.Name)); + } + + return result; + } + /// /// Gets the execution level (distance from root steps) for a step. /// @@ -1147,6 +1189,8 @@ private static int GetExecutionLevelRecursive( return maxLevel; } + private readonly record struct StepHierarchyInfo(string? ParentStepName, int Level); + /// /// Gets the topological order of steps for execution. /// diff --git a/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs b/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs index 23fa8ab3ad4..bfa1b4e8e10 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs @@ -38,21 +38,34 @@ public PipelineActivityReporter(InteractionService interactionService, ILogger

CompletionStates.InProgress }; + private static PublishingActivityData CreateStepActivityData(ReportingStep step, string statusText, CompletionState completionState, bool enableMarkdown) + { + return new PublishingActivityData + { + Id = step.Id, + StatusText = statusText, + CompletionState = ToBackchannelCompletionState(completionState), + StepId = null, + ParentStepId = step.ParentStepId, + HierarchyLevel = step.HierarchyLevel, + EnableMarkdown = enableMarkdown + }; + } + public async Task CreateStepAsync(string title, CancellationToken cancellationToken = default) { - var step = new ReportingStep(this, Guid.NewGuid().ToString(), title); + return await CreateStepAsync(title, parentStepId: null, hierarchyLevel: 0, cancellationToken).ConfigureAwait(false); + } + + internal async Task CreateStepAsync(string title, string? parentStepId, int hierarchyLevel, CancellationToken cancellationToken = default) + { + var step = new ReportingStep(this, Guid.NewGuid().ToString(), title, parentStepId, hierarchyLevel); _steps.TryAdd(step.Id, step); var state = new PublishingActivity { Type = PublishingActivityTypes.Step, - Data = new PublishingActivityData - { - Id = step.Id, - StatusText = step.Title, - CompletionState = ToBackchannelCompletionState(CompletionState.InProgress), - StepId = null - } + Data = CreateStepActivityData(step, step.Title, CompletionState.InProgress, enableMarkdown: false) }; await ActivityItemUpdated.Writer.WriteAsync(state, cancellationToken).ConfigureAwait(false); @@ -113,14 +126,7 @@ public async Task CompleteStepAsync(ReportingStep step, string completionText, C var state = new PublishingActivity { Type = PublishingActivityTypes.Step, - Data = new PublishingActivityData - { - Id = step.Id, - StatusText = completionText, - CompletionState = ToBackchannelCompletionState(completionState), - StepId = null, - EnableMarkdown = enableMarkdown - } + Data = CreateStepActivityData(step, completionText, completionState, enableMarkdown) }; await ActivityItemUpdated.Writer.WriteAsync(state, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Pipelines/ReportingStep.cs b/src/Aspire.Hosting/Pipelines/ReportingStep.cs index 8286311065b..0fef46133dd 100644 --- a/src/Aspire.Hosting/Pipelines/ReportingStep.cs +++ b/src/Aspire.Hosting/Pipelines/ReportingStep.cs @@ -17,11 +17,13 @@ internal sealed class ReportingStep : IReportingStep { private readonly ConcurrentDictionary _tasks = new(); - internal ReportingStep(PipelineActivityReporter reporter, string id, string title) + internal ReportingStep(PipelineActivityReporter reporter, string id, string title, string? parentStepId = null, int hierarchyLevel = 0) { Reporter = reporter; Id = id; Title = title; + ParentStepId = parentStepId; + HierarchyLevel = hierarchyLevel; } ///

@@ -34,6 +36,16 @@ internal ReportingStep(PipelineActivityReporter reporter, string id, string titl /// public string Title { get; } + /// + /// The identifier of the parent step used for hierarchical display. + /// + internal string? ParentStepId { get; } + + /// + /// The hierarchical level of the step used for display. + /// + internal int HierarchyLevel { get; } + /// /// The completion state of the step. Defaults to InProgress. /// The state is only aggregated from child tasks during disposal. diff --git a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs index f1dae43ca75..1abd3387b67 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs @@ -59,4 +59,32 @@ public void JsonSerializerOptionsCanSerializeAndDeserializeDictionaryStringJsonE Assert.Equal("select 1", roundTripped["sql"].GetString()); Assert.Equal(1, roundTripped["limit"].GetInt32()); } + + [Fact] + public void JsonSerializerOptionsCanDeserializePublishingActivityWithoutHierarchyMetadata() + { + var options = BackchannelJsonSerializerContext.CreateJsonSerializerOptions(); + var json = + """ + { + "Type": "step", + "Data": { + "Id": "step-1", + "StatusText": "Prepare", + "CompletionState": "InProgress" + } + } + """; + + var activity = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(activity); + Assert.Equal(PublishingActivityTypes.Step, activity.Type); + Assert.Equal("step-1", activity.Data.Id); + Assert.Equal("Prepare", activity.Data.StatusText); + Assert.Null(activity.Data.ParentStepId); + Assert.Null(activity.Data.HierarchyLevel); + Assert.Null(activity.Data.CompletionMessage); + Assert.Equal(CompletionStates.InProgress, activity.Data.CompletionState); + } } diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs index c2df644c325..e10bcdaa4b8 100644 --- a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -3,6 +3,7 @@ using System.Text; using Aspire.Cli.Backchannel; +using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Spectre.Console; @@ -10,7 +11,7 @@ namespace Aspire.Cli.Tests.Utils; public class ConsoleActivityLoggerTests { - private static ConsoleActivityLogger CreateLogger(StringBuilder output, bool interactive = true, bool color = true) + private static ConsoleActivityLogger CreateLogger(StringBuilder output, bool interactive = true, bool color = true, int? width = null) { var console = AnsiConsole.Create(new AnsiConsoleSettings { @@ -18,6 +19,7 @@ private static ConsoleActivityLogger CreateLogger(StringBuilder output, bool int ColorSystem = color ? ColorSystemSupport.TrueColor : ColorSystemSupport.NoColors, Out = new AnsiConsoleOutput(new StringWriter(output)) }); + console.Profile.Width = width ?? int.MaxValue; var hostEnvironment = interactive ? TestHelpers.CreateInteractiveHostEnvironment() @@ -192,4 +194,138 @@ public void WriteSummary_WithMarkupCharactersInFailureReason_EscapesCorrectly() // The literal bracket text from the failure reason should appear Assert.Contains("Type[T]", result); } + + [Fact] + public void WriteSummary_WithHierarchicalStepDurations_RendersIndentedTreeOrder() + { + var output = new StringBuilder(); + var logger = CreateLogger(output, interactive: false, color: false); + + var records = new[] + { + new ConsoleActivityLogger.StepDurationRecord("root", "Prepare", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(10), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(10)), + new ConsoleActivityLogger.StepDurationRecord("child-a", "Build", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(3), null, "root", 1, 2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(4)), + new ConsoleActivityLogger.StepDurationRecord("grandchild", "Package", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(1), null, "child-a", 2, 3, TimeSpan.FromSeconds(1.5), TimeSpan.FromSeconds(2.5)), + new ConsoleActivityLogger.StepDurationRecord("child-b", "Publish", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, "root", 1, 4, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(7)), + new ConsoleActivityLogger.StepDurationRecord("finalize", "Finalize", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(1), null, null, 0, 5, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(11)), + }; + + logger.SetStepDurations(records); + logger.SetFinalResult(true); + logger.WriteSummary(); + + var result = output.ToString(); + + var prepareIndex = result.IndexOf("Prepare", StringComparison.Ordinal); + var buildIndex = result.IndexOf(" Build", StringComparison.Ordinal); + var packageIndex = result.IndexOf(" Package", StringComparison.Ordinal); + var publishIndex = result.IndexOf(" Publish", StringComparison.Ordinal); + var finalizeIndex = result.IndexOf("Finalize", StringComparison.Ordinal); + + Assert.True(prepareIndex >= 0); + Assert.True(buildIndex > prepareIndex); + Assert.True(packageIndex > buildIndex); + Assert.True(publishIndex > packageIndex); + Assert.True(finalizeIndex > publishIndex); + } + + [Fact] + public void WriteSummary_WithStepDurations_RendersTimelineScaleAndBars() + { + var output = new StringBuilder(); + var logger = CreateLogger(output, interactive: false, color: false); + + var records = new[] + { + new ConsoleActivityLogger.StepDurationRecord("root", "Prepare", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(8), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(8)), + new ConsoleActivityLogger.StepDurationRecord("child", "Publish", ConsoleActivityLogger.ActivityState.Warning, TimeSpan.FromSeconds(2), null, "root", 1, 2, TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(6)), + }; + + logger.SetStepDurations(records); + logger.SetFinalResult(true); + logger.WriteSummary(); + + var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Contains(lines, line => line.Contains("Step timeline:", StringComparison.Ordinal)); + + var scaleLine = Assert.Single(lines, line => line.Contains('┬')); + var publishLine = Assert.Single(lines, line => line.Contains("Publish", StringComparison.Ordinal)); + + Assert.Matches(@"│[─┬]+│", scaleLine); + Assert.Contains('│', publishLine); + Assert.True(publishLine.IndexOf('│') > publishLine.IndexOf("Publish", StringComparison.Ordinal)); + Assert.True(scaleLine.LastIndexOf('│') > scaleLine.IndexOf('│')); + Assert.True(publishLine.Contains('╶') || publishLine.Contains('╴')); + } + + [Fact] + public void WriteSummary_WithMillisecondTimeline_UsesMillisecondStartLabel() + { + var output = new StringBuilder(); + var logger = CreateLogger(output, interactive: false, color: false); + + var records = new[] + { + new ConsoleActivityLogger.StepDurationRecord("root", "Prepare", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(8), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromMilliseconds(8)), + new ConsoleActivityLogger.StepDurationRecord("child", "Publish", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(2), null, "root", 1, 2, TimeSpan.FromMilliseconds(4), TimeSpan.FromMilliseconds(6)), + }; + + logger.SetStepDurations(records); + logger.SetFinalResult(true); + logger.WriteSummary(); + + var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var timelineLabelLine = Assert.Single(lines, line => line.Contains(SharedCommandStrings.PipelineStepTimelineLabel, StringComparison.Ordinal)); + + Assert.Contains("0ms", timelineLabelLine); + Assert.DoesNotContain("0s", timelineLabelLine); + Assert.Contains("8.00ms", timelineLabelLine); + } + + [Fact] + public void WriteSummary_WithSubColumnDuration_RendersPointMarker() + { + var output = new StringBuilder(); + var logger = CreateLogger(output, interactive: false, color: false); + + var records = new[] + { + new ConsoleActivityLogger.StepDurationRecord("root", "Prepare", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(100), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromMilliseconds(100)), + new ConsoleActivityLogger.StepDurationRecord("tiny", "Package", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(0.1), null, "root", 1, 2, TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(10.1)), + }; + + logger.SetStepDurations(records); + logger.SetFinalResult(true); + logger.WriteSummary(); + + var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var packageLine = Assert.Single(lines, line => line.Contains("Package", StringComparison.Ordinal)); + + Assert.Contains('╴', packageLine); + } + + [Fact] + public void WriteSummary_WithDeepNesting_SkipsTimelineToPreserveStepNames() + { + var output = new StringBuilder(); + var logger = CreateLogger(output, interactive: false, color: false, width: 60); + + var records = new[] + { + new ConsoleActivityLogger.StepDurationRecord("root", "Prepare", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(8), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(8)), + new ConsoleActivityLogger.StepDurationRecord("deep", "Publish", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, "root", 12, 2, TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(6)), + }; + + logger.SetStepDurations(records); + logger.SetFinalResult(true); + logger.WriteSummary(); + + var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + var deepPublishLine = Assert.Single(lines, line => line.Contains("Publish", StringComparison.Ordinal)); + + Assert.DoesNotContain(lines, line => line.Contains(SharedCommandStrings.PipelineStepTimelineLabel, StringComparison.Ordinal)); + Assert.DoesNotContain(lines, line => line.Contains('┬')); + Assert.DoesNotContain('│', deepPublishLine); + Assert.Contains(new string(' ', 24) + "Publish", deepPublishLine); + } } diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index c6e7b882fce..d4b4b8678be 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -49,6 +49,32 @@ public async Task CreateStepAsync_CreatesStepAndEmitsActivity() Assert.Null(activity.Data.StepId); } + [Fact] + public async Task CreateStepAsync_WithHierarchyMetadata_EmitsMetadataOnCreateAndComplete() + { + // Arrange + var reporter = CreatePublishingReporter(); + + // Act + var step = await reporter.CreateStepAsync("Child Step", "parent-step-id", 2, CancellationToken.None); + + // Assert + var stepInternal = Assert.IsType(step); + Assert.Equal("parent-step-id", stepInternal.ParentStepId); + Assert.Equal(2, stepInternal.HierarchyLevel); + + var activityReader = reporter.ActivityItemUpdated.Reader; + Assert.True(activityReader.TryRead(out var createActivity)); + Assert.Equal("parent-step-id", createActivity.Data.ParentStepId); + Assert.Equal(2, createActivity.Data.HierarchyLevel); + + await step.CompleteAsync("Done", CompletionState.Completed, CancellationToken.None); + + Assert.True(activityReader.TryRead(out var completeActivity)); + Assert.Equal("parent-step-id", completeActivity.Data.ParentStepId); + Assert.Equal(2, completeActivity.Data.HierarchyLevel); + } + [Fact] public async Task CreateTaskAsync_CreatesTaskAndEmitsActivity() {