diff --git a/Controls/AppLogView.xaml.cs b/Controls/AppLogView.xaml.cs index ced351d..e0df250 100644 --- a/Controls/AppLogView.xaml.cs +++ b/Controls/AppLogView.xaml.cs @@ -28,7 +28,9 @@ public partial class AppLogView : ContentPage { private TelemetryClient? _telemetryClient; private readonly ObservableCollection _logItems = new(); + private readonly List _manualLogItems = new(); // Items added via AddLog() - preserved across RefreshLogs() private LogItem? _selectedItem; + private bool _enableLiveUpdates; /// /// Creates a new AppLogView instance. @@ -65,6 +67,7 @@ public partial class AppLogView : ContentPage var view = (AppLogView)bindable; view._telemetryClient = newValue as TelemetryClient; view.RefreshLogs(); + view.UpdateLiveUpdatesSubscription(); } /// @@ -105,6 +108,60 @@ public partial class AppLogView : ContentPage set => SetValue(ShowClearButtonProperty, value); } + /// + /// Whether to automatically refresh when new items are added to the TelemetryClient. Default: false. + /// When enabled, polls for new items every 2 seconds. + /// + public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create( + nameof(EnableLiveUpdates), + typeof(bool), + typeof(AppLogView), + false, + propertyChanged: OnEnableLiveUpdatesChanged); + + /// + /// Gets or sets whether to automatically refresh the log list when new items are added. + /// + public bool EnableLiveUpdates + { + get => (bool)GetValue(EnableLiveUpdatesProperty); + set => SetValue(EnableLiveUpdatesProperty, value); + } + + private static void OnEnableLiveUpdatesChanged(BindableObject bindable, object oldValue, object newValue) + { + var view = (AppLogView)bindable; + view._enableLiveUpdates = (bool)newValue; + view.UpdateLiveUpdatesSubscription(); + } + + private Timer? _liveUpdateTimer; + private int _lastKnownItemCount; + + private void UpdateLiveUpdatesSubscription() + { + _liveUpdateTimer?.Dispose(); + _liveUpdateTimer = null; + + if (_enableLiveUpdates && _telemetryClient != null) + { + _lastKnownItemCount = _telemetryClient.GetLocalLogItems().Count; + _liveUpdateTimer = new Timer(CheckForNewItems, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + } + + private void CheckForNewItems(object? state) + { + if (_telemetryClient == null) return; + + var currentCount = _telemetryClient.GetLocalLogItems().Count; + if (currentCount != _lastKnownItemCount) + { + _lastKnownItemCount = currentCount; + MainThread.BeginInvokeOnMainThread(RefreshLogs); + } + } + #endregion #region Events @@ -135,29 +192,41 @@ public partial class AppLogView : ContentPage /// /// Refresh the log list from the telemetry client's local log queue. - /// Call this after adding new logs or when the queue may have changed. + /// Preserves manually added items (via AddLog()) and merges with telemetry items. /// public void RefreshLogs() { _logItems.Clear(); + // Collect all items to display + var allItems = new List(); + + // Add items from telemetry client if (_telemetryClient != null) { - // Get all locally captured log items (not just failed ones) var localItems = _telemetryClient.GetLocalLogItems(); - - foreach (var item in localItems.OrderByDescending(i => i.Timestamp)) + foreach (var item in localItems) { - _logItems.Add(LogItem.FromEnvelopeItem(item)); + allItems.Add(LogItem.FromEnvelopeItem(item)); } } + // Add manually added items (preserved across refreshes) + allItems.AddRange(_manualLogItems); + + // Sort by timestamp descending and add to observable collection + foreach (var item in allItems.OrderByDescending(i => i.Timestamp)) + { + _logItems.Add(item); + } + UpdateCount(); LogsRefreshed?.Invoke(this, EventArgs.Empty); } /// /// Add a log item manually (for local-only logging without TelemetryClient). + /// These items are preserved across RefreshLogs() calls. /// /// Log type: "exception", "message", "info", "warning", etc. /// Title or exception type name. @@ -171,9 +240,14 @@ public partial class AppLogView : ContentPage Title = title, Message = message, StackTrace = stackTrace, - Timestamp = DateTime.UtcNow + Timestamp = DateTime.UtcNow, + IsManualEntry = true }; + // Store in manual items list (preserved across refreshes) + _manualLogItems.Add(item); + + // Also add to display list immediately _logItems.Insert(0, item); UpdateCount(); } @@ -188,11 +262,12 @@ public partial class AppLogView : ContentPage } /// - /// Clear all displayed logs. Does not clear the TelemetryClient's offline queue. + /// Clear all displayed logs including manually added ones. Does not clear the TelemetryClient's offline queue. /// public void ClearLogs() { _logItems.Clear(); + _manualLogItems.Clear(); HideDetail(); UpdateCount(); LogsCleared?.Invoke(this, EventArgs.Empty); @@ -372,6 +447,14 @@ public partial class AppLogView : ContentPage { base.OnAppearing(); RefreshLogs(); + UpdateLiveUpdatesSubscription(); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _liveUpdateTimer?.Dispose(); + _liveUpdateTimer = null; } #endregion @@ -440,6 +523,11 @@ public class LogItem /// public string? UserId { get; set; } + /// + /// Whether this item was added manually via AddLog() vs from TelemetryClient. + /// + public bool IsManualEntry { get; set; } + /// /// Formatted timestamp for display (local time, HH:mm:ss). /// diff --git a/Controls/JourneyConverters.cs b/Controls/JourneyConverters.cs new file mode 100644 index 0000000..ca103a0 --- /dev/null +++ b/Controls/JourneyConverters.cs @@ -0,0 +1,175 @@ +using System.Globalization; + +namespace IronServices.Maui.Controls; + +/// +/// Converts an integer to a boolean (true if greater than zero). +/// +public class IsGreaterThanZeroConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is int i && i > 0; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts a string to a boolean (true if not null or empty). +/// +public class StringToBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => !string.IsNullOrEmpty(value as string); + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Inverts a boolean value. +/// +public class InvertedBoolConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b && !b; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b && !b; +} + +/// +/// Converts JourneyDisplayStatus or StepDisplayStatus to a Color. +/// +public class StatusToColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value switch + { + JourneyDisplayStatus.InProgress => Colors.Blue, + JourneyDisplayStatus.Completed => Colors.Green, + JourneyDisplayStatus.Failed => Colors.Red, + JourneyDisplayStatus.Abandoned => Colors.Gray, + StepDisplayStatus.InProgress => Colors.Blue, + StepDisplayStatus.Completed => Colors.Green, + StepDisplayStatus.Failed => Colors.Red, + StepDisplayStatus.Skipped => Colors.Gray, + _ => Colors.Gray + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts nesting level to a left margin for indentation. +/// +public class NestingLevelToMarginConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int level) + return new Thickness(level * 20, 0, 0, 0); + return new Thickness(0); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts JourneyViewMode to a boolean for visibility binding. +/// Parameter should be the target mode (e.g., "Timeline", "Tree", "Flow"). +/// +public class ViewModeToVisibilityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is JourneyViewMode mode && parameter is string targetMode) + { + return mode.ToString().Equals(targetMode, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts JourneyViewMode to a background color for tab-style buttons. +/// Returns Primary color when selected, Transparent when not. +/// +public class ViewModeToBackgroundConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is JourneyViewMode mode && parameter is string targetMode) + { + var isSelected = mode.ToString().Equals(targetMode, StringComparison.OrdinalIgnoreCase); + return isSelected ? Color.FromArgb("#512BD4") : Colors.Transparent; + } + return Colors.Transparent; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts JourneyViewMode to a text color for tab-style buttons. +/// Returns White when selected, Primary when not. +/// +public class ViewModeToTextColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is JourneyViewMode mode && parameter is string targetMode) + { + var isSelected = mode.ToString().Equals(targetMode, StringComparison.OrdinalIgnoreCase); + return isSelected ? Colors.White : Color.FromArgb("#512BD4"); + } + return Color.FromArgb("#512BD4"); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts a dictionary to a formatted string for display. +/// +public class DictionaryToStringConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is Dictionary dict && dict.Count > 0) + { + return string.Join("\n", dict.Select(kvp => $"{kvp.Key}: {kvp.Value}")); + } + return "No data"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +/// +/// Converts a collection count to visibility (visible if count > 0). +/// +public class CountToVisibilityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int count) + return count > 0; + if (value is System.Collections.ICollection collection) + return collection.Count > 0; + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/Controls/JourneyModels.cs b/Controls/JourneyModels.cs new file mode 100644 index 0000000..fb14879 --- /dev/null +++ b/Controls/JourneyModels.cs @@ -0,0 +1,788 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using IronTelemetry.Client; + +namespace IronServices.Maui.Controls; + +#region Enums + +/// +/// Display status for a user journey. +/// +public enum JourneyDisplayStatus +{ + InProgress, + Completed, + Failed, + Abandoned +} + +/// +/// Display status for a step within a journey. +/// +public enum StepDisplayStatus +{ + InProgress, + Completed, + Failed, + Skipped +} + +/// +/// View mode for the UserJourneyView control. +/// +public enum JourneyViewMode +{ + /// + /// Vertical timeline with nested steps shown with indentation. + /// + Timeline, + + /// + /// Expandable tree hierarchy with collapsible nodes. + /// + Tree, + + /// + /// Horizontal flowchart-style diagram showing step progression. + /// + Flow +} + +#endregion + +#region JourneyItem + +/// +/// Represents a user journey for display in UserJourneyView. +/// +public class JourneyItem : INotifyPropertyChanged +{ + private bool _isExpanded; + private bool _isSelected; + + /// + /// Unique journey identifier. + /// + public string JourneyId { get; set; } = ""; + + /// + /// Journey name (e.g., "Checkout Flow", "User Onboarding"). + /// + public string Name { get; set; } = ""; + + /// + /// Associated user ID. + /// + public string? UserId { get; set; } + + /// + /// Associated user email. + /// + public string? UserEmail { get; set; } + + /// + /// Current journey status. + /// + public JourneyDisplayStatus Status { get; set; } = JourneyDisplayStatus.InProgress; + + /// + /// When the journey started. + /// + public DateTime StartTime { get; set; } + + /// + /// When the journey ended (null if still in progress). + /// + public DateTime? EndTime { get; set; } + + /// + /// Duration in milliseconds. + /// + public double? DurationMs { get; set; } + + /// + /// Custom metadata attached to the journey. + /// + public Dictionary Metadata { get; set; } = new(); + + /// + /// Top-level steps in this journey. + /// + public ObservableCollection Steps { get; } = new(); + + /// + /// Breadcrumbs captured during this journey. + /// + public List Breadcrumbs { get; set; } = new(); + + /// + /// Exceptions captured during this journey. + /// + public List Exceptions { get; set; } = new(); + + /// + /// UI state: whether this journey is expanded in tree view. + /// + public bool IsExpanded + { + get => _isExpanded; + set { _isExpanded = value; OnPropertyChanged(); } + } + + /// + /// UI state: whether this journey is currently selected. + /// + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; OnPropertyChanged(); } + } + + #region Computed Properties + + /// + /// Formatted duration for display. + /// + 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"; + } + } + + /// + /// Relative time since journey started. + /// + 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"); + } + } + + /// + /// Status text for display. + /// + public string StatusDisplay => Status switch + { + JourneyDisplayStatus.InProgress => "In Progress", + JourneyDisplayStatus.Completed => "Completed", + JourneyDisplayStatus.Failed => "Failed", + JourneyDisplayStatus.Abandoned => "Abandoned", + _ => "Unknown" + }; + + /// + /// Color for status badge. + /// + public Color StatusColor => Status switch + { + JourneyDisplayStatus.InProgress => Colors.Blue, + JourneyDisplayStatus.Completed => Colors.Green, + JourneyDisplayStatus.Failed => Colors.Red, + JourneyDisplayStatus.Abandoned => Colors.Gray, + _ => Colors.Gray + }; + + /// + /// Icon/text for status badge. + /// + public string StatusIcon => Status switch + { + JourneyDisplayStatus.InProgress => "...", + JourneyDisplayStatus.Completed => "OK", + JourneyDisplayStatus.Failed => "X", + JourneyDisplayStatus.Abandoned => "-", + _ => "?" + }; + + /// + /// Total number of steps (including nested). + /// + public int StepCount => CountAllSteps(Steps); + + /// + /// Number of failed steps. + /// + public int FailedStepCount => CountFailedSteps(Steps); + + /// + /// Whether there are any failed steps. + /// + public bool HasFailedSteps => FailedStepCount > 0; + + /// + /// Whether there are any exceptions. + /// + public bool HasExceptions => Exceptions.Count > 0; + + /// + /// Whether a user is associated with this journey. + /// + public bool HasUser => !string.IsNullOrEmpty(UserId); + + /// + /// Start time formatted for display. + /// + public string StartTimeDisplay => StartTime.ToLocalTime().ToString("HH:mm:ss"); + + #endregion + + #region Helper Methods + + private static int CountAllSteps(IEnumerable steps) + { + int count = 0; + foreach (var step in steps) + { + count++; + count += CountAllSteps(step.ChildSteps); + } + return count; + } + + private static int CountFailedSteps(IEnumerable steps) + { + int count = 0; + foreach (var step in steps) + { + if (step.Status == StepDisplayStatus.Failed) count++; + count += CountFailedSteps(step.ChildSteps); + } + return count; + } + + /// + /// Get a flattened list of all steps for timeline display. + /// + public List GetFlattenedSteps() + { + var result = new List(); + FlattenSteps(Steps, result, 0); + return result; + } + + private static void FlattenSteps(IEnumerable steps, List 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 + +/// +/// Represents a step within a user journey. +/// +public class StepItem : INotifyPropertyChanged +{ + private bool _isExpanded; + private bool _isSelected; + private int _nestingLevel; + + /// + /// Unique step identifier. + /// + public string StepId { get; set; } = ""; + + /// + /// Parent step ID for nested steps. + /// + public string? ParentStepId { get; set; } + + /// + /// Step name (e.g., "Validate Cart", "Process Payment"). + /// + public string Name { get; set; } = ""; + + /// + /// Step category (e.g., "business", "technical", "navigation"). + /// + public string? Category { get; set; } + + /// + /// Current step status. + /// + public StepDisplayStatus Status { get; set; } = StepDisplayStatus.InProgress; + + /// + /// Reason for failure if status is Failed. + /// + public string? FailureReason { get; set; } + + /// + /// When the step started. + /// + public DateTime StartTime { get; set; } + + /// + /// When the step ended. + /// + public DateTime? EndTime { get; set; } + + /// + /// Duration in milliseconds. + /// + public double? DurationMs { get; set; } + + /// + /// Custom data attached to the step. + /// + public Dictionary Data { get; set; } = new(); + + /// + /// Child steps nested under this step. + /// + public ObservableCollection ChildSteps { get; } = new(); + + /// + /// Breadcrumbs captured during this step. + /// + public List Breadcrumbs { get; set; } = new(); + + /// + /// UI state: nesting level for indentation. + /// + public int NestingLevel + { + get => _nestingLevel; + set { _nestingLevel = value; OnPropertyChanged(); OnPropertyChanged(nameof(IndentMargin)); } + } + + /// + /// UI state: whether this step is expanded in tree view. + /// + public bool IsExpanded + { + get => _isExpanded; + set { _isExpanded = value; OnPropertyChanged(); } + } + + /// + /// UI state: whether this step is currently selected. + /// + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; OnPropertyChanged(); } + } + + #region Computed Properties + + /// + /// Formatted duration for display. + /// + public string DurationDisplay + { + get + { + if (!DurationMs.HasValue) return "..."; + if (DurationMs.Value < 1000) return $"{DurationMs:F0}ms"; + return $"{DurationMs.Value / 1000:F1}s"; + } + } + + /// + /// Category for display (defaults to "general"). + /// + public string CategoryDisplay => Category ?? "general"; + + /// + /// Color for status indicator. + /// + public Color StatusColor => Status switch + { + StepDisplayStatus.InProgress => Colors.Blue, + StepDisplayStatus.Completed => Colors.Green, + StepDisplayStatus.Failed => Colors.Red, + StepDisplayStatus.Skipped => Colors.Gray, + _ => Colors.Gray + }; + + /// + /// Icon/text for status indicator. + /// + public string StatusIcon => Status switch + { + StepDisplayStatus.InProgress => "...", + StepDisplayStatus.Completed => "OK", + StepDisplayStatus.Failed => "X", + StepDisplayStatus.Skipped => "-", + _ => "?" + }; + + /// + /// Margin for indentation based on nesting level. + /// + public Thickness IndentMargin => new(NestingLevel * 20, 0, 0, 0); + + /// + /// Whether this step has child steps. + /// + public bool HasChildren => ChildSteps.Count > 0; + + /// + /// Whether this step has a failure reason. + /// + public bool HasFailureReason => !string.IsNullOrEmpty(FailureReason); + + /// + /// Start time formatted for display. + /// + 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 + +/// +/// Represents a breadcrumb captured during journey/step execution. +/// +public class BreadcrumbItem +{ + /// + /// When the breadcrumb was captured. + /// + public DateTime Timestamp { get; set; } + + /// + /// Breadcrumb category. + /// + public string Category { get; set; } = ""; + + /// + /// Breadcrumb message. + /// + public string Message { get; set; } = ""; + + /// + /// Log level: "info", "warning", "error", "debug". + /// + public string Level { get; set; } = "Info"; + + /// + /// Optional additional data. + /// + public Dictionary? Data { get; set; } + + #region Computed Properties + + /// + /// Timestamp formatted for display. + /// + public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss.fff"); + + /// + /// Color based on log level. + /// + public Color LevelColor => Level.ToLower() switch + { + "error" => Colors.Red, + "warning" => Colors.Orange, + "info" => Colors.Blue, + "debug" => Colors.Gray, + _ => Colors.Gray + }; + + /// + /// Level display text. + /// + public string LevelDisplay => Level.ToUpper(); + + #endregion +} + +#endregion + +#region ExceptionItem + +/// +/// Represents an exception captured during journey execution. +/// +public class ExceptionItem +{ + /// + /// When the exception was captured. + /// + public DateTime Timestamp { get; set; } + + /// + /// Exception type name. + /// + public string ExceptionType { get; set; } = ""; + + /// + /// Exception message. + /// + public string Message { get; set; } = ""; + + /// + /// Stack trace. + /// + public string? StackTrace { get; set; } + + /// + /// Step ID where the exception was captured. + /// + public string? StepId { get; set; } + + #region Computed Properties + + /// + /// Timestamp formatted for display. + /// + public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss"); + + /// + /// Whether there is a stack trace. + /// + public bool HasStackTrace => !string.IsNullOrEmpty(StackTrace); + + /// + /// Short display combining type and message. + /// + public string ShortDisplay => $"{ExceptionType}: {Message}"; + + #endregion +} + +#endregion + +#region JourneyReconstructor + +/// +/// Utility class to reconstruct journey hierarchy from flat EnvelopeItem list. +/// +public static class JourneyReconstructor +{ + /// + /// Reconstruct journey hierarchy from flat EnvelopeItem list. + /// + public static List ReconstructJourneys(IEnumerable items) + { + var journeyMap = new Dictionary(); + var stepMap = new Dictionary(); + var journeyBreadcrumbs = new Dictionary>(); + var journeyExceptions = new Dictionary>(); + var stepToJourney = new Dictionary(); // 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(); + journeyExceptions[item.JourneyId] = new List(); + } + 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 diff --git a/Controls/UserJourneyView.xaml b/Controls/UserJourneyView.xaml new file mode 100644 index 0000000..949d38a --- /dev/null +++ b/Controls/UserJourneyView.xaml @@ -0,0 +1,388 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +