ironservices-maui/Controls/JourneyModels.cs

789 lines
22 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using IronTelemetry.Client;
namespace IronServices.Maui.Controls;
#region Enums
/// <summary>
/// Display status for a user journey.
/// </summary>
public enum JourneyDisplayStatus
{
InProgress,
Completed,
Failed,
Abandoned
}
/// <summary>
/// Display status for a step within a journey.
/// </summary>
public enum StepDisplayStatus
{
InProgress,
Completed,
Failed,
Skipped
}
/// <summary>
/// View mode for the UserJourneyView control.
/// </summary>
public enum JourneyViewMode
{
/// <summary>
/// Vertical timeline with nested steps shown with indentation.
/// </summary>
Timeline,
/// <summary>
/// Expandable tree hierarchy with collapsible nodes.
/// </summary>
Tree,
/// <summary>
/// Horizontal flowchart-style diagram showing step progression.
/// </summary>
Flow
}
#endregion
#region JourneyItem
/// <summary>
/// Represents a user journey for display in UserJourneyView.
/// </summary>
public class JourneyItem : INotifyPropertyChanged
{
private bool _isExpanded;
private bool _isSelected;
/// <summary>
/// Unique journey identifier.
/// </summary>
public string JourneyId { get; set; } = "";
/// <summary>
/// Journey name (e.g., "Checkout Flow", "User Onboarding").
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Associated user ID.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Associated user email.
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// Current journey status.
/// </summary>
public JourneyDisplayStatus Status { get; set; } = JourneyDisplayStatus.InProgress;
/// <summary>
/// When the journey started.
/// </summary>
public DateTime StartTime { get; set; }
/// <summary>
/// When the journey ended (null if still in progress).
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
public double? DurationMs { get; set; }
/// <summary>
/// Custom metadata attached to the journey.
/// </summary>
public Dictionary<string, object> Metadata { get; set; } = new();
/// <summary>
/// Top-level steps in this journey.
/// </summary>
public ObservableCollection<StepItem> Steps { get; } = new();
/// <summary>
/// Breadcrumbs captured during this journey.
/// </summary>
public List<BreadcrumbItem> Breadcrumbs { get; set; } = new();
/// <summary>
/// Exceptions captured during this journey.
/// </summary>
public List<ExceptionItem> Exceptions { get; set; } = new();
/// <summary>
/// UI state: whether this journey is expanded in tree view.
/// </summary>
public bool IsExpanded
{
get => _isExpanded;
set { _isExpanded = value; OnPropertyChanged(); }
}
/// <summary>
/// UI state: whether this journey is currently selected.
/// </summary>
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
#region Computed Properties
/// <summary>
/// Formatted duration for display.
/// </summary>
public string DurationDisplay
{
get
{
if (!DurationMs.HasValue) return "In Progress";
if (DurationMs.Value < 1000) return $"{DurationMs:F0}ms";
if (DurationMs.Value < 60000) return $"{DurationMs.Value / 1000:F1}s";
return $"{DurationMs.Value / 60000:F1}m";
}
}
/// <summary>
/// Relative time since journey started.
/// </summary>
public string TimeAgo
{
get
{
var span = DateTime.UtcNow - StartTime;
if (span.TotalMinutes < 1) return "just now";
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes}m ago";
if (span.TotalHours < 24) return $"{(int)span.TotalHours}h ago";
if (span.TotalDays < 7) return $"{(int)span.TotalDays}d ago";
return StartTime.ToLocalTime().ToString("MMM d");
}
}
/// <summary>
/// Status text for display.
/// </summary>
public string StatusDisplay => Status switch
{
JourneyDisplayStatus.InProgress => "In Progress",
JourneyDisplayStatus.Completed => "Completed",
JourneyDisplayStatus.Failed => "Failed",
JourneyDisplayStatus.Abandoned => "Abandoned",
_ => "Unknown"
};
/// <summary>
/// Color for status badge.
/// </summary>
public Color StatusColor => Status switch
{
JourneyDisplayStatus.InProgress => Colors.Blue,
JourneyDisplayStatus.Completed => Colors.Green,
JourneyDisplayStatus.Failed => Colors.Red,
JourneyDisplayStatus.Abandoned => Colors.Gray,
_ => Colors.Gray
};
/// <summary>
/// Icon/text for status badge.
/// </summary>
public string StatusIcon => Status switch
{
JourneyDisplayStatus.InProgress => "...",
JourneyDisplayStatus.Completed => "OK",
JourneyDisplayStatus.Failed => "X",
JourneyDisplayStatus.Abandoned => "-",
_ => "?"
};
/// <summary>
/// Total number of steps (including nested).
/// </summary>
public int StepCount => CountAllSteps(Steps);
/// <summary>
/// Number of failed steps.
/// </summary>
public int FailedStepCount => CountFailedSteps(Steps);
/// <summary>
/// Whether there are any failed steps.
/// </summary>
public bool HasFailedSteps => FailedStepCount > 0;
/// <summary>
/// Whether there are any exceptions.
/// </summary>
public bool HasExceptions => Exceptions.Count > 0;
/// <summary>
/// Whether a user is associated with this journey.
/// </summary>
public bool HasUser => !string.IsNullOrEmpty(UserId);
/// <summary>
/// Start time formatted for display.
/// </summary>
public string StartTimeDisplay => StartTime.ToLocalTime().ToString("HH:mm:ss");
#endregion
#region Helper Methods
private static int CountAllSteps(IEnumerable<StepItem> steps)
{
int count = 0;
foreach (var step in steps)
{
count++;
count += CountAllSteps(step.ChildSteps);
}
return count;
}
private static int CountFailedSteps(IEnumerable<StepItem> steps)
{
int count = 0;
foreach (var step in steps)
{
if (step.Status == StepDisplayStatus.Failed) count++;
count += CountFailedSteps(step.ChildSteps);
}
return count;
}
/// <summary>
/// Get a flattened list of all steps for timeline display.
/// </summary>
public List<StepItem> GetFlattenedSteps()
{
var result = new List<StepItem>();
FlattenSteps(Steps, result, 0);
return result;
}
private static void FlattenSteps(IEnumerable<StepItem> steps, List<StepItem> result, int level)
{
foreach (var step in steps)
{
step.NestingLevel = level;
result.Add(step);
FlattenSteps(step.ChildSteps, result, level + 1);
}
}
#endregion
#region INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
#endregion
}
#endregion
#region StepItem
/// <summary>
/// Represents a step within a user journey.
/// </summary>
public class StepItem : INotifyPropertyChanged
{
private bool _isExpanded;
private bool _isSelected;
private int _nestingLevel;
/// <summary>
/// Unique step identifier.
/// </summary>
public string StepId { get; set; } = "";
/// <summary>
/// Parent step ID for nested steps.
/// </summary>
public string? ParentStepId { get; set; }
/// <summary>
/// Step name (e.g., "Validate Cart", "Process Payment").
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Step category (e.g., "business", "technical", "navigation").
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Current step status.
/// </summary>
public StepDisplayStatus Status { get; set; } = StepDisplayStatus.InProgress;
/// <summary>
/// Reason for failure if status is Failed.
/// </summary>
public string? FailureReason { get; set; }
/// <summary>
/// When the step started.
/// </summary>
public DateTime StartTime { get; set; }
/// <summary>
/// When the step ended.
/// </summary>
public DateTime? EndTime { get; set; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
public double? DurationMs { get; set; }
/// <summary>
/// Custom data attached to the step.
/// </summary>
public Dictionary<string, object> Data { get; set; } = new();
/// <summary>
/// Child steps nested under this step.
/// </summary>
public ObservableCollection<StepItem> ChildSteps { get; } = new();
/// <summary>
/// Breadcrumbs captured during this step.
/// </summary>
public List<BreadcrumbItem> Breadcrumbs { get; set; } = new();
/// <summary>
/// UI state: nesting level for indentation.
/// </summary>
public int NestingLevel
{
get => _nestingLevel;
set { _nestingLevel = value; OnPropertyChanged(); OnPropertyChanged(nameof(IndentMargin)); }
}
/// <summary>
/// UI state: whether this step is expanded in tree view.
/// </summary>
public bool IsExpanded
{
get => _isExpanded;
set { _isExpanded = value; OnPropertyChanged(); }
}
/// <summary>
/// UI state: whether this step is currently selected.
/// </summary>
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
#region Computed Properties
/// <summary>
/// Formatted duration for display.
/// </summary>
public string DurationDisplay
{
get
{
if (!DurationMs.HasValue) return "...";
if (DurationMs.Value < 1000) return $"{DurationMs:F0}ms";
return $"{DurationMs.Value / 1000:F1}s";
}
}
/// <summary>
/// Category for display (defaults to "general").
/// </summary>
public string CategoryDisplay => Category ?? "general";
/// <summary>
/// Color for status indicator.
/// </summary>
public Color StatusColor => Status switch
{
StepDisplayStatus.InProgress => Colors.Blue,
StepDisplayStatus.Completed => Colors.Green,
StepDisplayStatus.Failed => Colors.Red,
StepDisplayStatus.Skipped => Colors.Gray,
_ => Colors.Gray
};
/// <summary>
/// Icon/text for status indicator.
/// </summary>
public string StatusIcon => Status switch
{
StepDisplayStatus.InProgress => "...",
StepDisplayStatus.Completed => "OK",
StepDisplayStatus.Failed => "X",
StepDisplayStatus.Skipped => "-",
_ => "?"
};
/// <summary>
/// Margin for indentation based on nesting level.
/// </summary>
public Thickness IndentMargin => new(NestingLevel * 20, 0, 0, 0);
/// <summary>
/// Whether this step has child steps.
/// </summary>
public bool HasChildren => ChildSteps.Count > 0;
/// <summary>
/// Whether this step has a failure reason.
/// </summary>
public bool HasFailureReason => !string.IsNullOrEmpty(FailureReason);
/// <summary>
/// Start time formatted for display.
/// </summary>
public string StartTimeDisplay => StartTime.ToLocalTime().ToString("HH:mm:ss.fff");
#endregion
#region INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
#endregion
}
#endregion
#region BreadcrumbItem
/// <summary>
/// Represents a breadcrumb captured during journey/step execution.
/// </summary>
public class BreadcrumbItem
{
/// <summary>
/// When the breadcrumb was captured.
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Breadcrumb category.
/// </summary>
public string Category { get; set; } = "";
/// <summary>
/// Breadcrumb message.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Log level: "info", "warning", "error", "debug".
/// </summary>
public string Level { get; set; } = "Info";
/// <summary>
/// Optional additional data.
/// </summary>
public Dictionary<string, object>? Data { get; set; }
#region Computed Properties
/// <summary>
/// Timestamp formatted for display.
/// </summary>
public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss.fff");
/// <summary>
/// Color based on log level.
/// </summary>
public Color LevelColor => Level.ToLower() switch
{
"error" => Colors.Red,
"warning" => Colors.Orange,
"info" => Colors.Blue,
"debug" => Colors.Gray,
_ => Colors.Gray
};
/// <summary>
/// Level display text.
/// </summary>
public string LevelDisplay => Level.ToUpper();
#endregion
}
#endregion
#region ExceptionItem
/// <summary>
/// Represents an exception captured during journey execution.
/// </summary>
public class ExceptionItem
{
/// <summary>
/// When the exception was captured.
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Exception type name.
/// </summary>
public string ExceptionType { get; set; } = "";
/// <summary>
/// Exception message.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Stack trace.
/// </summary>
public string? StackTrace { get; set; }
/// <summary>
/// Step ID where the exception was captured.
/// </summary>
public string? StepId { get; set; }
#region Computed Properties
/// <summary>
/// Timestamp formatted for display.
/// </summary>
public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss");
/// <summary>
/// Whether there is a stack trace.
/// </summary>
public bool HasStackTrace => !string.IsNullOrEmpty(StackTrace);
/// <summary>
/// Short display combining type and message.
/// </summary>
public string ShortDisplay => $"{ExceptionType}: {Message}";
#endregion
}
#endregion
#region JourneyReconstructor
/// <summary>
/// Utility class to reconstruct journey hierarchy from flat EnvelopeItem list.
/// </summary>
public static class JourneyReconstructor
{
/// <summary>
/// Reconstruct journey hierarchy from flat EnvelopeItem list.
/// </summary>
public static List<JourneyItem> ReconstructJourneys(IEnumerable<EnvelopeItem> items)
{
var journeyMap = new Dictionary<string, JourneyItem>();
var stepMap = new Dictionary<string, StepItem>();
var journeyBreadcrumbs = new Dictionary<string, List<BreadcrumbItem>>();
var journeyExceptions = new Dictionary<string, List<ExceptionItem>>();
var stepToJourney = new Dictionary<string, string>(); // stepId -> journeyId
// Process items in timestamp order
foreach (var item in items.OrderBy(i => i.Timestamp))
{
switch (item.Type)
{
case "journey_start":
if (!string.IsNullOrEmpty(item.JourneyId))
{
journeyMap[item.JourneyId] = new JourneyItem
{
JourneyId = item.JourneyId,
Name = item.Name ?? "Unknown Journey",
UserId = item.UserId,
UserEmail = item.UserEmail,
StartTime = item.Timestamp,
Status = JourneyDisplayStatus.InProgress
};
journeyBreadcrumbs[item.JourneyId] = new List<BreadcrumbItem>();
journeyExceptions[item.JourneyId] = new List<ExceptionItem>();
}
break;
case "journey_end":
if (!string.IsNullOrEmpty(item.JourneyId) &&
journeyMap.TryGetValue(item.JourneyId, out var journey))
{
journey.EndTime = item.Timestamp;
journey.Status = ParseJourneyStatus(item.Status);
if (item.Metadata.TryGetValue("durationMs", out var durationObj))
{
journey.DurationMs = Convert.ToDouble(durationObj);
}
else
{
journey.DurationMs = (item.Timestamp - journey.StartTime).TotalMilliseconds;
}
foreach (var kvp in item.Metadata)
{
journey.Metadata[kvp.Key] = kvp.Value;
}
}
break;
case "step_start":
if (!string.IsNullOrEmpty(item.StepId))
{
var step = new StepItem
{
StepId = item.StepId,
ParentStepId = item.ParentStepId,
Name = item.Name ?? "Unknown Step",
Category = item.Category,
StartTime = item.Timestamp,
Status = StepDisplayStatus.InProgress
};
stepMap[item.StepId] = step;
if (!string.IsNullOrEmpty(item.JourneyId))
{
stepToJourney[item.StepId] = item.JourneyId;
}
}
break;
case "step_end":
if (!string.IsNullOrEmpty(item.StepId) &&
stepMap.TryGetValue(item.StepId, out var endStep))
{
endStep.EndTime = item.Timestamp;
endStep.Status = ParseStepStatus(item.Status);
if (item.Data.TryGetValue("durationMs", out var durObj))
{
endStep.DurationMs = Convert.ToDouble(durObj);
}
else
{
endStep.DurationMs = (item.Timestamp - endStep.StartTime).TotalMilliseconds;
}
if (item.Data.TryGetValue("failureReason", out var reasonObj))
{
endStep.FailureReason = reasonObj?.ToString();
}
foreach (var kvp in item.Data)
{
endStep.Data[kvp.Key] = kvp.Value;
}
}
break;
case "exception":
if (!string.IsNullOrEmpty(item.JourneyId) &&
journeyExceptions.TryGetValue(item.JourneyId, out var excList))
{
excList.Add(new ExceptionItem
{
Timestamp = item.Timestamp,
ExceptionType = item.ExceptionType ?? "Exception",
Message = item.Message ?? "",
StackTrace = item.StackTrace,
StepId = item.StepId
});
}
break;
}
// Collect breadcrumbs
if (!string.IsNullOrEmpty(item.JourneyId) &&
item.Breadcrumbs?.Count > 0 &&
journeyBreadcrumbs.TryGetValue(item.JourneyId, out var bcList))
{
foreach (var bc in item.Breadcrumbs)
{
bcList.Add(new BreadcrumbItem
{
Timestamp = bc.Timestamp ?? item.Timestamp,
Category = bc.Category,
Message = bc.Message,
Level = bc.Level,
Data = bc.Data
});
}
}
}
// Build step hierarchy
foreach (var step in stepMap.Values)
{
if (!string.IsNullOrEmpty(step.ParentStepId) &&
stepMap.TryGetValue(step.ParentStepId, out var parentStep))
{
parentStep.ChildSteps.Add(step);
step.NestingLevel = parentStep.NestingLevel + 1;
}
}
// Associate root-level steps with journeys
foreach (var step in stepMap.Values)
{
if (string.IsNullOrEmpty(step.ParentStepId) &&
stepToJourney.TryGetValue(step.StepId, out var journeyId) &&
journeyMap.TryGetValue(journeyId, out var ownerJourney))
{
ownerJourney.Steps.Add(step);
}
}
// Associate breadcrumbs and exceptions
foreach (var journey in journeyMap.Values)
{
if (journeyBreadcrumbs.TryGetValue(journey.JourneyId, out var bcs))
{
journey.Breadcrumbs = bcs.OrderBy(b => b.Timestamp).ToList();
}
if (journeyExceptions.TryGetValue(journey.JourneyId, out var excs))
{
journey.Exceptions = excs.OrderBy(e => e.Timestamp).ToList();
}
}
return journeyMap.Values.ToList();
}
private static JourneyDisplayStatus ParseJourneyStatus(string? status) => status?.ToLower() switch
{
"completed" => JourneyDisplayStatus.Completed,
"failed" => JourneyDisplayStatus.Failed,
"abandoned" => JourneyDisplayStatus.Abandoned,
_ => JourneyDisplayStatus.InProgress
};
private static StepDisplayStatus ParseStepStatus(string? status) => status?.ToLower() switch
{
"completed" => StepDisplayStatus.Completed,
"failed" => StepDisplayStatus.Failed,
"skipped" => StepDisplayStatus.Skipped,
_ => StepDisplayStatus.InProgress
};
}
#endregion