|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace Aspire.Cli.Utils;
/// <summary>
/// Lightweight, spec-aligned console logger for aligned colored task output without
/// rewriting the entire existing publishing pipeline. Integrates by mapping publish
/// step/task events to Start/Progress/Success/Warning/Failure calls.
/// </summary>
internal sealed class ConsoleActivityLogger
{
private readonly bool _enableColor;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly object _lock = new();
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private readonly Dictionary<string, string> _stepColors = new();
private readonly Dictionary<string, ActivityState> _stepStates = new(); // Track final state per step for summary
private readonly Dictionary<string, string> _displayNames = new(); // Optional friendly display names for step keys
private List<StepDurationRecord>? _durationRecords; // Optional per-step duration breakdown
private readonly string[] _availableColors = ["blue", "cyan", "yellow", "magenta", "purple", "orange3"];
private int _colorIndex;
private int _successCount;
private int _warningCount;
private int _failureCount;
private volatile bool _spinning;
private Task? _spinnerTask;
private readonly char[] _spinnerChars = ['|', '/', '-', '\\'];
private int _spinnerIndex;
// No raw ANSI escape codes; rely on Spectre.Console markup tokens.
private const string SuccessSymbol = "✓";
private const string FailureSymbol = "✗";
private const string WarningSymbol = "⚠";
private const string InProgressSymbol = "→";
private const string InfoSymbol = "i";
public ConsoleActivityLogger(ICliHostEnvironment hostEnvironment, bool? forceColor = null)
{
_hostEnvironment = hostEnvironment;
_enableColor = forceColor ?? _hostEnvironment.SupportsAnsi;
// Disable spinner in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput)
{
_spinning = false;
}
}
public enum ActivityState
{
InProgress,
Success,
Warning,
Failure,
Info
}
public void StartTask(string taskKey, string? startingMessage = null)
{
lock (_lock)
{
// Initialize step state as InProgress if first time seen
if (!_stepStates.ContainsKey(taskKey))
{
_stepStates[taskKey] = ActivityState.InProgress;
}
}
WriteLine(taskKey, InProgressSymbol, startingMessage ?? "Starting...", ActivityState.InProgress);
}
public void StartTask(string taskKey, string displayName, string? startingMessage = null)
{
lock (_lock)
{
if (!_stepStates.ContainsKey(taskKey))
{
_stepStates[taskKey] = ActivityState.InProgress;
}
_displayNames[taskKey] = displayName;
}
WriteLine(taskKey, InProgressSymbol, startingMessage ?? ($"Starting {displayName}..."), ActivityState.InProgress);
}
public void StartSpinner()
{
// Skip spinner in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput || _spinning)
{
return;
}
_spinning = true;
_spinnerTask = Task.Run(async () =>
{
// Spinner sits at bottom; we write spinner char then backspace.
while (_spinning)
{
AnsiConsole.Write(CultureInfo.InvariantCulture, _spinnerChars[_spinnerIndex % _spinnerChars.Length]);
AnsiConsole.Write(CultureInfo.InvariantCulture, "\b");
_spinnerIndex++;
await Task.Delay(120).ConfigureAwait(false);
}
// Clear spinner character
AnsiConsole.Write(CultureInfo.InvariantCulture, ' ');
AnsiConsole.Write(CultureInfo.InvariantCulture, "\b");
});
}
public async Task StopSpinnerAsync()
{
_spinning = false;
if (_spinnerTask is not null)
{
await _spinnerTask.ConfigureAwait(false);
_spinnerTask = null;
}
}
public void Progress(string taskKey, string message)
{
WriteLine(taskKey, InProgressSymbol, message, ActivityState.InProgress);
}
public void Success(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_successCount++;
_stepStates[taskKey] = ActivityState.Success;
}
WriteCompletion(taskKey, SuccessSymbol, message, ActivityState.Success, seconds);
}
public void Warning(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_warningCount++;
_stepStates[taskKey] = ActivityState.Warning;
}
WriteCompletion(taskKey, WarningSymbol, message, ActivityState.Warning, seconds);
}
public void Failure(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_failureCount++;
_stepStates[taskKey] = ActivityState.Failure;
}
WriteCompletion(taskKey, FailureSymbol, message, ActivityState.Failure, seconds);
}
public void Info(string taskKey, string message)
{
WriteLine(taskKey, InfoSymbol, message, ActivityState.Info);
}
public void Continuation(string message)
{
lock (_lock)
{
// Continuation lines: indent with two spaces relative to the symbol column for readability
const string continuationPrefix = " ";
foreach (var line in SplitLinesPreserve(message))
{
Console.Write(continuationPrefix);
Console.WriteLine(line);
}
}
}
public void WriteSummary(string? dashboardUrl = null)
{
lock (_lock)
{
var totalSeconds = _stopwatch.Elapsed.TotalSeconds;
var line = new string('-', 60);
AnsiConsole.MarkupLine(line);
var totalSteps = _stepStates.Count;
// Derive per-step outcome counts from _stepStates (not task-level counters) for accurate X/Y display.
var succeededSteps = _stepStates.Values.Count(v => v == ActivityState.Success);
var warningSteps = _stepStates.Values.Count(v => v == ActivityState.Warning);
var failedSteps = _stepStates.Values.Count(v => v == ActivityState.Failure);
var summaryParts = new List<string>();
var succeededSegment = totalSteps > 0 ? $"{succeededSteps}/{totalSteps} steps succeeded" : $"{succeededSteps} steps succeeded";
if (_enableColor)
{
summaryParts.Add($"[green]{SuccessSymbol} {succeededSegment}[/]");
if (warningSteps > 0)
{
summaryParts.Add($"[yellow]{WarningSymbol} {warningSteps} warning{(warningSteps == 1 ? string.Empty : "s")}[/]");
}
if (failedSteps > 0)
{
summaryParts.Add($"[red]{FailureSymbol} {failedSteps} failed[/]");
}
}
else
{
summaryParts.Add($"{SuccessSymbol} {succeededSegment}");
if (warningSteps > 0)
{
summaryParts.Add($"{WarningSymbol} {warningSteps} warning{(warningSteps == 1 ? string.Empty : "s")}");
}
if (failedSteps > 0)
{
summaryParts.Add($"{FailureSymbol} {failedSteps} failed");
}
}
summaryParts.Add($"Total time: {totalSeconds.ToString("0.0", CultureInfo.InvariantCulture)}s");
AnsiConsole.MarkupLine(string.Join(" • ", summaryParts));
if (_durationRecords is { Count: > 0 })
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("Steps Summary:");
foreach (var rec in _durationRecords)
{
var durStr = rec.Duration.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture).PadLeft(4);
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]— {HighlightAndEscape(rec.FailureReason!)}[/]" : $" — {rec.FailureReason}" )
: string.Empty;
var lineSb = new StringBuilder();
lineSb.Append(" ")
.Append(durStr).Append(" s ")
.Append(symbol).Append(' ')
.Append("[dim]").Append(name).Append("[/]")
.Append(reason);
AnsiConsole.MarkupLine(lineSb.ToString());
}
AnsiConsole.WriteLine();
}
// If a caller provided a final status line via SetFinalResult, print it now
if (!string.IsNullOrEmpty(_finalStatusHeader))
{
AnsiConsole.MarkupLine(_finalStatusHeader!);
}
if (!string.IsNullOrEmpty(dashboardUrl))
{
// Render dashboard URL as clickable link in interactive terminals, plain in non-interactive
var url = dashboardUrl;
if (!_hostEnvironment.SupportsInteractiveOutput || !_enableColor)
{
AnsiConsole.MarkupLine($"Dashboard: {url.EscapeMarkup()}");
}
else
{
AnsiConsole.MarkupLine($"Dashboard: [link={url}]{url}[/]");
}
}
AnsiConsole.MarkupLine(line);
AnsiConsole.WriteLine(); // Ensure final newline after deployment summary
}
}
private string? _finalStatusHeader;
/// <summary>
/// Sets the final deployment result lines to be displayed in the summary (e.g., DEPLOYMENT FAILED ...).
/// Optional usage so existing callers remain compatible.
/// </summary>
public void SetFinalResult(bool succeeded)
{
// Always show only a single final header line with symbol; no per-step duplication.
if (succeeded)
{
_finalStatusHeader = _enableColor
? $"[green]{SuccessSymbol} DEPLOYMENT SUCCEEDED[/]"
: $"{SuccessSymbol} DEPLOYMENT SUCCEEDED";
}
else
{
_finalStatusHeader = _enableColor
? $"[red]{FailureSymbol} DEPLOYMENT FAILED[/]"
: $"{FailureSymbol} DEPLOYMENT FAILED";
}
}
/// <summary>
/// Provides per-step duration data (already sorted) for inclusion in the summary.
/// </summary>
public void SetStepDurations(IEnumerable<StepDurationRecord> records)
{
_durationRecords = records.ToList();
}
public readonly record struct StepDurationRecord(string Key, string DisplayName, ActivityState State, TimeSpan Duration, string? FailureReason);
private void WriteCompletion(string taskKey, string symbol, string message, ActivityState state, double? seconds)
{
var text = seconds.HasValue ? $"{message} ({seconds.Value.ToString("0.0", CultureInfo.InvariantCulture)}s)" : message;
WriteLine(taskKey, symbol, text, state);
}
private void WriteLine(string taskKey, string symbol, string message, ActivityState state)
{
lock (_lock)
{
var time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
var stepColor = GetOrAssignStepColor(taskKey);
var displayKey = _displayNames.TryGetValue(taskKey, out var dn) ? dn : taskKey;
var coloredSymbol = _enableColor ? ColorizeSymbol(symbol, state) : symbol;
foreach (var line in SplitLinesPreserve(message))
{
// Format: dim timestamp, colored step tag, symbol, escaped message
var escapedLine = HighlightAndEscape(line);
var escapedTask = displayKey.EscapeMarkup();
var markup = new StringBuilder();
markup.Append("[dim]").Append(time).Append("[/] ");
markup.Append('[').Append(stepColor).Append(']').Append('(').Append(escapedTask).Append(')').Append("[/] ");
if (_enableColor)
{
if (state == ActivityState.Failure)
{
// Make the entire failure segment (symbol + message) red, not just the symbol
markup.Append("[red]").Append(symbol).Append(' ').Append(escapedLine).Append("[/]");
}
else if (state == ActivityState.Warning)
{
// Optionally color whole warning message (improves scanability)
markup.Append("[yellow]").Append(symbol).Append(' ').Append(escapedLine).Append("[/]");
}
else
{
markup.Append(coloredSymbol).Append(' ').Append(escapedLine);
}
}
else
{
markup.Append(symbol).Append(' ').Append(escapedLine);
}
AnsiConsole.MarkupLine(markup.ToString());
}
}
}
private string GetOrAssignStepColor(string taskKey)
{
if (!_stepColors.TryGetValue(taskKey, out var color))
{
color = _availableColors[_colorIndex % _availableColors.Length];
_stepColors[taskKey] = color;
_colorIndex++;
}
return color;
}
private static IEnumerable<string> SplitLinesPreserve(string message)
{
if (message.IndexOf('\n') < 0)
{
yield return message;
yield break;
}
var lines = message.Replace("\r\n", "\n").Split('\n');
foreach (var l in lines)
{
yield return l;
}
}
private static string ColorizeSymbol(string symbol, ActivityState state) => state switch
{
ActivityState.Success => $"[green]{symbol}[/]",
ActivityState.Warning => $"[yellow]{symbol}[/]",
ActivityState.Failure => $"[red]{symbol}[/]",
ActivityState.InProgress => $"[cyan]{symbol}[/]",
ActivityState.Info => $"[dim]{symbol}[/]",
_ => symbol
};
private static readonly Regex s_urlRegex = new(
pattern: @"(?:(?:https?|ftp)://)[^\s]+",
options: RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
// Escapes non-URL portions for Spectre markup while preserving injected [link] markup unescaped.
private string HighlightAndEscape(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var matches = s_urlRegex.Matches(input);
if (matches.Count == 0)
{
return input.EscapeMarkup();
}
// In non-interactive environments, just output URLs as-is without [link] markup
if (!_hostEnvironment.SupportsInteractiveOutput)
{
return input.EscapeMarkup();
}
var sb = new StringBuilder(input.Length + 32);
var lastIndex = 0;
foreach (Match match in matches)
{
if (match.Index > lastIndex)
{
var segment = input.Substring(lastIndex, match.Index - lastIndex);
sb.Append(segment.EscapeMarkup());
}
var url = match.Value; // Do not EscapeMarkup inside [link] to keep it functional.
sb.Append("[link=").Append(url).Append(']').Append(url).Append("[/]");
lastIndex = match.Index + match.Length;
}
if (lastIndex < input.Length)
{
sb.Append(input.Substring(lastIndex).EscapeMarkup());
}
return sb.ToString();
}
// Note: DetectColorSupport is no longer needed as we use _hostEnvironment.SupportsAnsi directly
}
|