UserJourney

This commit is contained in:
Admin 2025-12-26 06:14:09 -05:00
parent 017e03099b
commit bd4085dfce
7 changed files with 2358 additions and 7 deletions

View File

@ -28,7 +28,9 @@ public partial class AppLogView : ContentPage
{
private TelemetryClient? _telemetryClient;
private readonly ObservableCollection<LogItem> _logItems = new();
private readonly List<LogItem> _manualLogItems = new(); // Items added via AddLog() - preserved across RefreshLogs()
private LogItem? _selectedItem;
private bool _enableLiveUpdates;
/// <summary>
/// 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();
}
/// <summary>
@ -105,6 +108,60 @@ public partial class AppLogView : ContentPage
set => SetValue(ShowClearButtonProperty, value);
}
/// <summary>
/// Whether to automatically refresh when new items are added to the TelemetryClient. Default: false.
/// When enabled, polls for new items every 2 seconds.
/// </summary>
public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create(
nameof(EnableLiveUpdates),
typeof(bool),
typeof(AppLogView),
false,
propertyChanged: OnEnableLiveUpdatesChanged);
/// <summary>
/// Gets or sets whether to automatically refresh the log list when new items are added.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
public void RefreshLogs()
{
_logItems.Clear();
// Collect all items to display
var allItems = new List<LogItem>();
// 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);
}
/// <summary>
/// Add a log item manually (for local-only logging without TelemetryClient).
/// These items are preserved across RefreshLogs() calls.
/// </summary>
/// <param name="type">Log type: "exception", "message", "info", "warning", etc.</param>
/// <param name="title">Title or exception type name.</param>
@ -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
}
/// <summary>
/// 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.
/// </summary>
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
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Whether this item was added manually via AddLog() vs from TelemetryClient.
/// </summary>
public bool IsManualEntry { get; set; }
/// <summary>
/// Formatted timestamp for display (local time, HH:mm:ss).
/// </summary>

View File

@ -0,0 +1,175 @@
using System.Globalization;
namespace IronServices.Maui.Controls;
/// <summary>
/// Converts an integer to a boolean (true if greater than zero).
/// </summary>
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();
}
/// <summary>
/// Converts a string to a boolean (true if not null or empty).
/// </summary>
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();
}
/// <summary>
/// Inverts a boolean value.
/// </summary>
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;
}
/// <summary>
/// Converts JourneyDisplayStatus or StepDisplayStatus to a Color.
/// </summary>
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();
}
/// <summary>
/// Converts nesting level to a left margin for indentation.
/// </summary>
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();
}
/// <summary>
/// Converts JourneyViewMode to a boolean for visibility binding.
/// Parameter should be the target mode (e.g., "Timeline", "Tree", "Flow").
/// </summary>
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();
}
/// <summary>
/// Converts JourneyViewMode to a background color for tab-style buttons.
/// Returns Primary color when selected, Transparent when not.
/// </summary>
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();
}
/// <summary>
/// Converts JourneyViewMode to a text color for tab-style buttons.
/// Returns White when selected, Primary when not.
/// </summary>
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();
}
/// <summary>
/// Converts a dictionary to a formatted string for display.
/// </summary>
public class DictionaryToStringConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Dictionary<string, object> 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();
}
/// <summary>
/// Converts a collection count to visibility (visible if count > 0).
/// </summary>
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();
}

788
Controls/JourneyModels.cs Normal file
View File

@ -0,0 +1,788 @@
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

View File

@ -0,0 +1,388 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:IronServices.Maui.Controls"
x:Class="IronServices.Maui.Controls.UserJourneyView"
x:DataType="controls:UserJourneyView"
Title="User Journeys">
<ContentPage.Resources>
<ResourceDictionary>
<controls:IsGreaterThanZeroConverter x:Key="IsGreaterThanZero" />
<controls:StringToBoolConverter x:Key="StringToBool" />
<controls:NestingLevelToMarginConverter x:Key="NestingToMargin" />
<controls:DictionaryToStringConverter x:Key="DictToString" />
<controls:CountToVisibilityConverter x:Key="CountToVisibility" />
</ResourceDictionary>
</ContentPage.Resources>
<ContentPage.ToolbarItems>
<ToolbarItem x:Name="RefreshToolbarItem"
Text="Refresh"
Order="Primary"
Clicked="OnRefreshClicked" />
<ToolbarItem x:Name="ShareToolbarItem"
Text="Share"
Order="Primary"
Clicked="OnShareClicked" />
<ToolbarItem x:Name="ClearToolbarItem"
Text="Clear"
Order="Secondary"
Clicked="OnClearClicked" />
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Header Bar with Summary and View Mode Toggle -->
<Border Grid.Row="0"
Padding="16,12"
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray800}}"
StrokeThickness="0">
<Grid ColumnDefinitions="*,Auto,Auto">
<!-- Count Label -->
<Label x:Name="CountLabel"
Text="0 journeys"
FontSize="14"
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}"
VerticalOptions="Center" />
<!-- View Mode Selector -->
<HorizontalStackLayout Grid.Column="1" Spacing="4" Margin="0,0,12,0">
<Button x:Name="TimelineBtn"
Text="Timeline"
Clicked="OnTimelineSelected"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
FontSize="11"
Padding="8,4"
CornerRadius="4"
HeightRequest="28" />
<Button x:Name="TreeBtn"
Text="Tree"
Clicked="OnTreeSelected"
BackgroundColor="Transparent"
TextColor="{StaticResource Primary}"
FontSize="11"
Padding="8,4"
CornerRadius="4"
HeightRequest="28" />
<Button x:Name="FlowBtn"
Text="Flow"
Clicked="OnFlowSelected"
BackgroundColor="Transparent"
TextColor="{StaticResource Primary}"
FontSize="11"
Padding="8,4"
CornerRadius="4"
HeightRequest="28" />
</HorizontalStackLayout>
<!-- Live Updates Indicator -->
<HorizontalStackLayout Grid.Column="2" Spacing="4" IsVisible="{Binding EnableLiveUpdates}">
<Ellipse WidthRequest="8" HeightRequest="8" Fill="Green" VerticalOptions="Center" />
<Label Text="Live" FontSize="11" TextColor="Green" VerticalOptions="Center" />
</HorizontalStackLayout>
</Grid>
</Border>
<!-- Main Content Area -->
<Grid x:Name="ContentArea" Grid.Row="1">
<!-- Timeline View (default) -->
<CollectionView x:Name="JourneyList"
SelectionMode="Single"
SelectionChanged="OnJourneySelected">
<CollectionView.EmptyView>
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Padding="32">
<Label Text="No journeys captured"
FontSize="18"
TextColor="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}"
HorizontalOptions="Center" />
<Label Text="User journeys will appear here when tracked"
FontSize="14"
TextColor="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}"
HorizontalOptions="Center"
Margin="0,8,0,0" />
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="controls:JourneyItem">
<Border Padding="12"
Margin="8,4"
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray900}}"
Stroke="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray700}}"
StrokeShape="RoundRectangle 8">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<!-- Status Badge -->
<Border Grid.Row="0" Grid.Column="0"
Padding="6,2"
StrokeThickness="0"
BackgroundColor="{Binding StatusColor}"
StrokeShape="RoundRectangle 4"
VerticalOptions="Start"
Margin="0,0,8,0">
<Label Text="{Binding StatusIcon}"
FontSize="10"
TextColor="White"
FontAttributes="Bold" />
</Border>
<!-- Journey Name -->
<Label Grid.Row="0" Grid.Column="1"
Text="{Binding Name}"
FontSize="14"
FontAttributes="Bold"
LineBreakMode="TailTruncation"
MaxLines="1"
VerticalOptions="Center" />
<!-- Duration and Time -->
<VerticalStackLayout Grid.Row="0" Grid.Column="2" Spacing="2">
<Label Text="{Binding DurationDisplay}"
FontSize="12"
FontAttributes="Bold"
TextColor="{AppThemeBinding Light={StaticResource Gray700}, Dark={StaticResource Gray300}}"
HorizontalOptions="End" />
<Label Text="{Binding TimeAgo}"
FontSize="10"
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
HorizontalOptions="End" />
</VerticalStackLayout>
<!-- Journey Details Row -->
<HorizontalStackLayout Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3"
Spacing="12" Margin="0,8,0,0">
<Label Text="{Binding StepCount, StringFormat='{0} steps'}"
FontSize="12"
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}" />
<Label Text="{Binding FailedStepCount, StringFormat='{0} failed'}"
FontSize="12"
TextColor="Red"
IsVisible="{Binding HasFailedSteps}" />
<Label Text="{Binding Exceptions.Count, StringFormat='{0} errors'}"
FontSize="12"
TextColor="Red"
IsVisible="{Binding HasExceptions}" />
</HorizontalStackLayout>
<!-- User Info Row -->
<HorizontalStackLayout Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3"
Spacing="8" Margin="0,4,0,0"
IsVisible="{Binding HasUser}">
<Label Text="User:"
FontSize="11"
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}" />
<Label Text="{Binding UserId}"
FontSize="11"
TextColor="{StaticResource Primary}"
LineBreakMode="TailTruncation"
MaxLines="1" />
</HorizontalStackLayout>
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!-- Tree View (shown when ViewMode = Tree) -->
<ScrollView x:Name="TreeViewContainer" IsVisible="False">
<VerticalStackLayout x:Name="TreeViewContent" Padding="8" Spacing="4">
<!-- Tree nodes will be built programmatically -->
</VerticalStackLayout>
</ScrollView>
<!-- Flow View (shown when ViewMode = Flow) -->
<ScrollView x:Name="FlowViewContainer" IsVisible="False" Orientation="Both">
<GraphicsView x:Name="FlowGraphicsView"
WidthRequest="800"
HeightRequest="400" />
</ScrollView>
</Grid>
<!-- Detail Panel (shows when journey selected) -->
<Border x:Name="DetailPanel"
Grid.Row="2"
IsVisible="False"
Padding="16"
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray800}}"
Stroke="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"
StrokeShape="Rectangle"
MaximumHeightRequest="400">
<ScrollView>
<VerticalStackLayout Spacing="12">
<!-- Header with Close Button -->
<Grid ColumnDefinitions="*,Auto">
<Label x:Name="DetailTitle"
FontSize="16"
FontAttributes="Bold"
LineBreakMode="TailTruncation" />
<Button Grid.Column="1"
Text="Close"
Clicked="OnCloseDetailClicked"
BackgroundColor="Transparent"
TextColor="{StaticResource Primary}"
FontSize="12"
Padding="8,4" />
</Grid>
<!-- Status, Duration, User -->
<HorizontalStackLayout Spacing="16">
<HorizontalStackLayout Spacing="4">
<Label Text="Status:" FontSize="12" TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}" />
<Label x:Name="DetailStatus" FontSize="12" FontAttributes="Bold" />
</HorizontalStackLayout>
<HorizontalStackLayout Spacing="4">
<Label Text="Duration:" FontSize="12" TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}" />
<Label x:Name="DetailDuration" FontSize="12" FontAttributes="Bold" />
</HorizontalStackLayout>
<HorizontalStackLayout x:Name="DetailUserContainer" Spacing="4" IsVisible="False">
<Label Text="User:" FontSize="12" TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}" />
<Label x:Name="DetailUser" FontSize="12" TextColor="{StaticResource Primary}" />
</HorizontalStackLayout>
</HorizontalStackLayout>
<!-- Steps Section -->
<VerticalStackLayout x:Name="StepsSection" Spacing="4">
<Label Text="Steps" FontSize="14" FontAttributes="Bold" />
<CollectionView x:Name="StepsList"
MaximumHeightRequest="150"
SelectionMode="Single"
SelectionChanged="OnStepSelected">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="controls:StepItem">
<Grid Margin="{Binding IndentMargin}" Padding="8,4" ColumnDefinitions="Auto,*,Auto">
<!-- Status Indicator -->
<Border Padding="4,2"
StrokeThickness="0"
BackgroundColor="{Binding StatusColor}"
StrokeShape="RoundRectangle 2"
VerticalOptions="Center"
Margin="0,0,8,0">
<Label Text="{Binding StatusIcon}"
FontSize="9"
TextColor="White"
FontAttributes="Bold" />
</Border>
<!-- Step Name -->
<VerticalStackLayout Grid.Column="1" VerticalOptions="Center">
<Label Text="{Binding Name}"
FontSize="12"
LineBreakMode="TailTruncation" />
<Label Text="{Binding FailureReason}"
FontSize="10"
TextColor="Red"
IsVisible="{Binding HasFailureReason}"
LineBreakMode="TailTruncation" />
</VerticalStackLayout>
<!-- Duration -->
<Label Grid.Column="2"
Text="{Binding DurationDisplay}"
FontSize="11"
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
<!-- Breadcrumbs Section -->
<VerticalStackLayout x:Name="BreadcrumbsSection" Spacing="4" IsVisible="False">
<Label Text="Breadcrumbs" FontSize="14" FontAttributes="Bold" />
<CollectionView x:Name="BreadcrumbsList" MaximumHeightRequest="100">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="controls:BreadcrumbItem">
<Grid Padding="4" ColumnDefinitions="Auto,Auto,*">
<Label Text="{Binding TimestampDisplay}"
FontSize="10"
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
VerticalOptions="Center"
Margin="0,0,8,0" />
<Border Grid.Column="1"
Padding="4,1"
StrokeThickness="0"
BackgroundColor="{Binding LevelColor}"
StrokeShape="RoundRectangle 2"
VerticalOptions="Center"
Margin="0,0,8,0">
<Label Text="{Binding Category}"
FontSize="9"
TextColor="White" />
</Border>
<Label Grid.Column="2"
Text="{Binding Message}"
FontSize="11"
LineBreakMode="TailTruncation"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
<!-- Exceptions Section -->
<VerticalStackLayout x:Name="ExceptionsSection" Spacing="4" IsVisible="False">
<Label Text="Exceptions" FontSize="14" FontAttributes="Bold" TextColor="Red" />
<CollectionView x:Name="ExceptionsList"
MaximumHeightRequest="120"
SelectionMode="Single"
SelectionChanged="OnExceptionSelected">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="controls:ExceptionItem">
<Border Padding="8" Margin="0,2"
BackgroundColor="{AppThemeBinding Light=#FEE2E2, Dark=#7F1D1D}"
Stroke="Transparent"
StrokeShape="RoundRectangle 4">
<VerticalStackLayout Spacing="2">
<HorizontalStackLayout Spacing="8">
<Label Text="{Binding TimestampDisplay}"
FontSize="10"
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray300}}" />
<Label Text="{Binding ExceptionType}"
FontSize="11"
FontAttributes="Bold"
TextColor="Red" />
</HorizontalStackLayout>
<Label Text="{Binding Message}"
FontSize="11"
LineBreakMode="TailTruncation"
MaxLines="2" />
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
<!-- Metadata Section -->
<VerticalStackLayout x:Name="MetadataSection" Spacing="4" IsVisible="False">
<Label Text="Metadata" FontSize="14" FontAttributes="Bold" />
<Label x:Name="MetadataLabel"
FontFamily="Consolas"
FontSize="11"
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}" />
</VerticalStackLayout>
<!-- Copy Button -->
<Button x:Name="CopyButton"
Text="Copy to Clipboard"
Clicked="OnCopyClicked"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
FontSize="12"
Padding="12,8"
CornerRadius="4"
HorizontalOptions="Start" />
</VerticalStackLayout>
</ScrollView>
</Border>
</Grid>
</ContentPage>

View File

@ -0,0 +1,901 @@
using System.Collections.ObjectModel;
using System.Text;
using IronTelemetry.Client;
using Microsoft.Maui.Controls.Shapes;
namespace IronServices.Maui.Controls;
/// <summary>
/// A page for displaying user journeys captured by IronTelemetry with multiple view modes.
/// Supports Timeline, Tree, and Flow visualization of journey data.
/// </summary>
/// <example>
/// <code>
/// // Basic usage
/// var journeyView = new UserJourneyView
/// {
/// TelemetryClient = IronTelemetry.Client,
/// EnableLiveUpdates = true
/// };
/// await Navigation.PushAsync(journeyView);
///
/// // With filtering
/// var journeyView = new UserJourneyView
/// {
/// TelemetryClient = myClient,
/// ViewMode = JourneyViewMode.Tree,
/// UserIdFilter = currentUser.Id
/// };
/// </code>
/// </example>
public partial class UserJourneyView : ContentPage
{
private TelemetryClient? _telemetryClient;
private readonly ObservableCollection<JourneyItem> _journeys = new();
private JourneyItem? _selectedJourney;
private StepItem? _selectedStep;
private JourneyViewMode _viewMode = JourneyViewMode.Timeline;
private bool _enableLiveUpdates;
private Timer? _liveUpdateTimer;
private int _lastKnownItemCount;
/// <summary>
/// Creates a new UserJourneyView instance.
/// </summary>
public UserJourneyView()
{
InitializeComponent();
JourneyList.ItemsSource = _journeys;
}
#region Bindable Properties
/// <summary>
/// The TelemetryClient to pull journey data from.
/// </summary>
public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create(
nameof(TelemetryClient),
typeof(TelemetryClient),
typeof(UserJourneyView),
null,
propertyChanged: OnTelemetryClientChanged);
/// <summary>
/// Gets or sets the TelemetryClient used to retrieve journey data.
/// </summary>
public TelemetryClient? TelemetryClient
{
get => (TelemetryClient?)GetValue(TelemetryClientProperty);
set => SetValue(TelemetryClientProperty, value);
}
private static void OnTelemetryClientChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._telemetryClient = newValue as TelemetryClient;
view.RefreshJourneys();
view.UpdateLiveUpdatesSubscription();
}
/// <summary>
/// Current view mode (Timeline, Tree, or Flow).
/// </summary>
public static readonly BindableProperty ViewModeProperty = BindableProperty.Create(
nameof(ViewMode),
typeof(JourneyViewMode),
typeof(UserJourneyView),
JourneyViewMode.Timeline,
propertyChanged: OnViewModeChanged);
/// <summary>
/// Gets or sets the current view mode.
/// </summary>
public JourneyViewMode ViewMode
{
get => (JourneyViewMode)GetValue(ViewModeProperty);
set => SetValue(ViewModeProperty, value);
}
private static void OnViewModeChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._viewMode = (JourneyViewMode)newValue;
view.UpdateViewMode();
}
/// <summary>
/// Whether to automatically refresh when new items are added.
/// </summary>
public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create(
nameof(EnableLiveUpdates),
typeof(bool),
typeof(UserJourneyView),
false,
propertyChanged: OnEnableLiveUpdatesChanged);
/// <summary>
/// Gets or sets whether to automatically refresh the journey list.
/// </summary>
public bool EnableLiveUpdates
{
get => (bool)GetValue(EnableLiveUpdatesProperty);
set => SetValue(EnableLiveUpdatesProperty, value);
}
private static void OnEnableLiveUpdatesChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._enableLiveUpdates = (bool)newValue;
view.UpdateLiveUpdatesSubscription();
}
/// <summary>
/// Whether to show the share button. Default: true.
/// </summary>
public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create(
nameof(ShowShareButton),
typeof(bool),
typeof(UserJourneyView),
true,
propertyChanged: (b, o, n) => ((UserJourneyView)b).ShareToolbarItem.IsEnabled = (bool)n);
/// <summary>
/// Gets or sets whether the Share toolbar button is visible.
/// </summary>
public bool ShowShareButton
{
get => (bool)GetValue(ShowShareButtonProperty);
set => SetValue(ShowShareButtonProperty, value);
}
/// <summary>
/// Whether to show the clear button. Default: true.
/// </summary>
public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create(
nameof(ShowClearButton),
typeof(bool),
typeof(UserJourneyView),
true,
propertyChanged: (b, o, n) => ((UserJourneyView)b).ClearToolbarItem.IsEnabled = (bool)n);
/// <summary>
/// Gets or sets whether the Clear toolbar button is visible.
/// </summary>
public bool ShowClearButton
{
get => (bool)GetValue(ShowClearButtonProperty);
set => SetValue(ShowClearButtonProperty, value);
}
/// <summary>
/// Filter to show only journeys matching this user ID.
/// </summary>
public static readonly BindableProperty UserIdFilterProperty = BindableProperty.Create(
nameof(UserIdFilter),
typeof(string),
typeof(UserJourneyView),
null,
propertyChanged: (b, o, n) => ((UserJourneyView)b).RefreshJourneys());
/// <summary>
/// Gets or sets a user ID to filter journeys by.
/// </summary>
public string? UserIdFilter
{
get => (string?)GetValue(UserIdFilterProperty);
set => SetValue(UserIdFilterProperty, value);
}
#endregion
#region Events
/// <summary>
/// Raised when a journey is selected from the list.
/// </summary>
public event EventHandler<JourneyItem>? JourneySelected;
/// <summary>
/// Raised when a step is selected from a journey.
/// </summary>
public event EventHandler<StepItem>? StepSelected;
/// <summary>
/// Raised when the journey list is refreshed.
/// </summary>
public event EventHandler? JourneysRefreshed;
/// <summary>
/// Raised when journeys are cleared.
/// </summary>
public event EventHandler? JourneysCleared;
/// <summary>
/// Raised when journeys are shared/exported.
/// </summary>
public event EventHandler? JourneysShared;
#endregion
#region Public Methods
/// <summary>
/// Refresh the journey list from the TelemetryClient's local log queue.
/// </summary>
public void RefreshJourneys()
{
_journeys.Clear();
if (_telemetryClient == null) return;
var localItems = _telemetryClient.GetLocalLogItems();
var journeys = JourneyReconstructor.ReconstructJourneys(localItems);
// Apply user filter if set
if (!string.IsNullOrEmpty(UserIdFilter))
{
journeys = journeys.Where(j => j.UserId == UserIdFilter).ToList();
}
// Sort by start time descending
foreach (var journey in journeys.OrderByDescending(j => j.StartTime))
{
_journeys.Add(journey);
}
UpdateCount();
UpdateViewForCurrentMode();
JourneysRefreshed?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Clear all displayed journeys.
/// </summary>
public void ClearJourneys()
{
_journeys.Clear();
HideDetailPanel();
UpdateCount();
JourneysCleared?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Clear all journeys including the TelemetryClient's local queue.
/// </summary>
public void ClearAllJourneys()
{
_telemetryClient?.ClearLocalLogItems();
ClearJourneys();
}
/// <summary>
/// Export all journeys as a formatted string.
/// </summary>
public string ExportJourneys()
{
var sb = new StringBuilder();
sb.AppendLine("=== User Journeys Export ===");
sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Total journeys: {_journeys.Count}");
sb.AppendLine();
foreach (var journey in _journeys)
{
ExportJourney(sb, journey);
}
return sb.ToString();
}
/// <summary>
/// Get read-only list of current journeys.
/// </summary>
public IReadOnlyList<JourneyItem> GetJourneys() => _journeys.ToList().AsReadOnly();
/// <summary>
/// Get the count of journeys.
/// </summary>
public int JourneyCount => _journeys.Count;
#endregion
#region Event Handlers
private void OnRefreshClicked(object? sender, EventArgs e)
{
RefreshJourneys();
}
private void OnClearClicked(object? sender, EventArgs e)
{
ClearJourneys();
}
private async void OnShareClicked(object? sender, EventArgs e)
{
if (_journeys.Count == 0)
{
await DisplayAlert("No Journeys", "There are no journeys to share.", "OK");
return;
}
var content = ExportJourneys();
await Share.Default.RequestAsync(new ShareTextRequest
{
Title = "User Journeys",
Text = content
});
JourneysShared?.Invoke(this, EventArgs.Empty);
}
private void OnTimelineSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Timeline;
}
private void OnTreeSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Tree;
}
private void OnFlowSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Flow;
}
private void OnJourneySelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is JourneyItem journey)
{
_selectedJourney = journey;
ShowDetailPanel(journey);
JourneySelected?.Invoke(this, journey);
}
}
private void OnStepSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is StepItem step)
{
_selectedStep = step;
StepSelected?.Invoke(this, step);
}
}
private async void OnExceptionSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is ExceptionItem exception)
{
// Show exception details in an alert or copy to clipboard
var message = $"{exception.ExceptionType}: {exception.Message}";
if (exception.HasStackTrace)
{
message += $"\n\nStack Trace:\n{exception.StackTrace}";
}
await Clipboard.Default.SetTextAsync(message);
await DisplayAlert("Exception Copied", "Exception details copied to clipboard.", "OK");
}
}
private void OnCloseDetailClicked(object? sender, EventArgs e)
{
HideDetailPanel();
JourneyList.SelectedItem = null;
}
private async void OnCopyClicked(object? sender, EventArgs e)
{
if (_selectedJourney == null) return;
var sb = new StringBuilder();
ExportJourney(sb, _selectedJourney);
await Clipboard.Default.SetTextAsync(sb.ToString());
// Show brief feedback
var originalText = CopyButton.Text;
CopyButton.Text = "Copied!";
await Task.Delay(1500);
CopyButton.Text = originalText;
}
#endregion
#region Lifecycle
protected override void OnAppearing()
{
base.OnAppearing();
RefreshJourneys();
UpdateLiveUpdatesSubscription();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_liveUpdateTimer?.Dispose();
_liveUpdateTimer = null;
}
#endregion
#region View Mode Management
private void UpdateViewMode()
{
// Update button styles
TimelineBtn.BackgroundColor = _viewMode == JourneyViewMode.Timeline
? Color.FromArgb("#512BD4") : Colors.Transparent;
TimelineBtn.TextColor = _viewMode == JourneyViewMode.Timeline
? Colors.White : Color.FromArgb("#512BD4");
TreeBtn.BackgroundColor = _viewMode == JourneyViewMode.Tree
? Color.FromArgb("#512BD4") : Colors.Transparent;
TreeBtn.TextColor = _viewMode == JourneyViewMode.Tree
? Colors.White : Color.FromArgb("#512BD4");
FlowBtn.BackgroundColor = _viewMode == JourneyViewMode.Flow
? Color.FromArgb("#512BD4") : Colors.Transparent;
FlowBtn.TextColor = _viewMode == JourneyViewMode.Flow
? Colors.White : Color.FromArgb("#512BD4");
// Show/hide containers
JourneyList.IsVisible = _viewMode == JourneyViewMode.Timeline;
TreeViewContainer.IsVisible = _viewMode == JourneyViewMode.Tree;
FlowViewContainer.IsVisible = _viewMode == JourneyViewMode.Flow;
// Build view content
UpdateViewForCurrentMode();
}
private void UpdateViewForCurrentMode()
{
switch (_viewMode)
{
case JourneyViewMode.Tree:
BuildTreeView();
break;
case JourneyViewMode.Flow:
BuildFlowView();
break;
}
}
private void BuildTreeView()
{
TreeViewContent.Children.Clear();
foreach (var journey in _journeys)
{
var journeyNode = CreateTreeNode(journey);
TreeViewContent.Children.Add(journeyNode);
}
}
private View CreateTreeNode(JourneyItem journey)
{
var container = new VerticalStackLayout { Spacing = 2 };
// Journey header
var header = new Border
{
Padding = new Thickness(12, 8),
BackgroundColor = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#1F2937") : Colors.White,
Stroke = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#374151") : Color.FromArgb("#E5E7EB"),
StrokeShape = new RoundRectangle { CornerRadius = 8 }
};
var headerContent = new Grid
{
ColumnDefinitions = new ColumnDefinitionCollection
{
new ColumnDefinition(GridLength.Auto),
new ColumnDefinition(GridLength.Star),
new ColumnDefinition(GridLength.Auto)
}
};
// Expand/collapse indicator
var expandLabel = new Label
{
Text = journey.IsExpanded ? "-" : "+",
FontSize = 14,
FontAttributes = FontAttributes.Bold,
VerticalOptions = LayoutOptions.Center,
Margin = new Thickness(0, 0, 8, 0)
};
headerContent.Add(expandLabel, 0);
// Journey name and status
var nameStack = new HorizontalStackLayout { Spacing = 8 };
nameStack.Add(new Border
{
Padding = new Thickness(6, 2),
BackgroundColor = journey.StatusColor,
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 4 },
Content = new Label { Text = journey.StatusIcon, FontSize = 10, TextColor = Colors.White }
});
nameStack.Add(new Label { Text = journey.Name, FontSize = 14, FontAttributes = FontAttributes.Bold });
headerContent.Add(nameStack, 1);
// Duration
headerContent.Add(new Label
{
Text = journey.DurationDisplay,
FontSize = 12,
TextColor = Colors.Gray,
VerticalOptions = LayoutOptions.Center
}, 2);
header.Content = headerContent;
// Handle tap to expand/collapse
var tapGesture = new TapGestureRecognizer();
tapGesture.Tapped += (s, e) =>
{
journey.IsExpanded = !journey.IsExpanded;
expandLabel.Text = journey.IsExpanded ? "-" : "+";
BuildTreeView(); // Rebuild to show/hide children
};
header.GestureRecognizers.Add(tapGesture);
container.Add(header);
// Child steps (if expanded)
if (journey.IsExpanded && journey.Steps.Count > 0)
{
var stepsContainer = new VerticalStackLayout
{
Margin = new Thickness(20, 4, 0, 0),
Spacing = 2
};
foreach (var step in journey.Steps)
{
var stepNode = CreateStepTreeNode(step, 1);
stepsContainer.Add(stepNode);
}
container.Add(stepsContainer);
}
return container;
}
private View CreateStepTreeNode(StepItem step, int level)
{
var container = new VerticalStackLayout { Spacing = 2 };
var stepBorder = new Border
{
Padding = new Thickness(8, 4),
BackgroundColor = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#111827") : Color.FromArgb("#F9FAFB"),
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 4 }
};
var stepContent = new Grid
{
ColumnDefinitions = new ColumnDefinitionCollection
{
new ColumnDefinition(GridLength.Auto),
new ColumnDefinition(GridLength.Star),
new ColumnDefinition(GridLength.Auto)
}
};
// Status indicator
stepContent.Add(new Border
{
Padding = new Thickness(4, 2),
BackgroundColor = step.StatusColor,
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 2 },
Content = new Label { Text = step.StatusIcon, FontSize = 9, TextColor = Colors.White },
Margin = new Thickness(0, 0, 8, 0)
}, 0);
// Step name
stepContent.Add(new Label
{
Text = step.Name,
FontSize = 12,
VerticalOptions = LayoutOptions.Center
}, 1);
// Duration
stepContent.Add(new Label
{
Text = step.DurationDisplay,
FontSize = 10,
TextColor = Colors.Gray,
VerticalOptions = LayoutOptions.Center
}, 2);
stepBorder.Content = stepContent;
container.Add(stepBorder);
// Child steps
if (step.ChildSteps.Count > 0)
{
var childContainer = new VerticalStackLayout
{
Margin = new Thickness(16, 2, 0, 0),
Spacing = 2
};
foreach (var child in step.ChildSteps)
{
childContainer.Add(CreateStepTreeNode(child, level + 1));
}
container.Add(childContainer);
}
return container;
}
private void BuildFlowView()
{
// Flow view uses GraphicsView - implement custom drawable
// For now, show a placeholder message
FlowGraphicsView.Drawable = new FlowViewDrawable(_journeys.ToList());
FlowGraphicsView.Invalidate();
}
#endregion
#region Detail Panel
private void ShowDetailPanel(JourneyItem journey)
{
DetailTitle.Text = journey.Name;
DetailStatus.Text = journey.StatusDisplay;
DetailStatus.TextColor = journey.StatusColor;
DetailDuration.Text = journey.DurationDisplay;
if (!string.IsNullOrEmpty(journey.UserId))
{
DetailUser.Text = journey.UserId;
DetailUserContainer.IsVisible = true;
}
else
{
DetailUserContainer.IsVisible = false;
}
// Populate steps
var flattenedSteps = journey.GetFlattenedSteps();
StepsList.ItemsSource = flattenedSteps;
StepsSection.IsVisible = flattenedSteps.Count > 0;
// Populate breadcrumbs
BreadcrumbsList.ItemsSource = journey.Breadcrumbs;
BreadcrumbsSection.IsVisible = journey.Breadcrumbs.Count > 0;
// Populate exceptions
ExceptionsList.ItemsSource = journey.Exceptions;
ExceptionsSection.IsVisible = journey.Exceptions.Count > 0;
// Populate metadata
if (journey.Metadata.Count > 0)
{
MetadataLabel.Text = string.Join("\n", journey.Metadata.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
MetadataSection.IsVisible = true;
}
else
{
MetadataSection.IsVisible = false;
}
DetailPanel.IsVisible = true;
}
private void HideDetailPanel()
{
DetailPanel.IsVisible = false;
_selectedJourney = null;
_selectedStep = null;
}
#endregion
#region Live Updates
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(RefreshJourneys);
}
}
#endregion
#region Helper Methods
private void UpdateCount()
{
CountLabel.Text = _journeys.Count == 1 ? "1 journey" : $"{_journeys.Count} journeys";
}
private void ExportJourney(StringBuilder sb, JourneyItem journey)
{
sb.AppendLine($"=== Journey: {journey.Name} ===");
sb.AppendLine($"ID: {journey.JourneyId}");
sb.AppendLine($"Status: {journey.StatusDisplay}");
sb.AppendLine($"Started: {journey.StartTime:yyyy-MM-dd HH:mm:ss UTC}");
if (journey.EndTime.HasValue)
sb.AppendLine($"Ended: {journey.EndTime:yyyy-MM-dd HH:mm:ss UTC}");
sb.AppendLine($"Duration: {journey.DurationDisplay}");
if (!string.IsNullOrEmpty(journey.UserId))
sb.AppendLine($"User: {journey.UserId} ({journey.UserEmail})");
sb.AppendLine();
if (journey.Steps.Count > 0)
{
sb.AppendLine("Steps:");
foreach (var step in journey.GetFlattenedSteps())
{
ExportStep(sb, step);
}
sb.AppendLine();
}
if (journey.Exceptions.Count > 0)
{
sb.AppendLine("Exceptions:");
foreach (var ex in journey.Exceptions)
{
sb.AppendLine($" [{ex.TimestampDisplay}] {ex.ExceptionType}: {ex.Message}");
if (!string.IsNullOrEmpty(ex.StackTrace))
{
foreach (var line in ex.StackTrace.Split('\n').Take(5))
{
sb.AppendLine($" {line.Trim()}");
}
sb.AppendLine(" ...");
}
}
sb.AppendLine();
}
if (journey.Breadcrumbs.Count > 0)
{
sb.AppendLine("Breadcrumbs:");
foreach (var bc in journey.Breadcrumbs)
{
sb.AppendLine($" [{bc.TimestampDisplay}] [{bc.Level}] {bc.Category}: {bc.Message}");
}
sb.AppendLine();
}
if (journey.Metadata.Count > 0)
{
sb.AppendLine("Metadata:");
foreach (var kvp in journey.Metadata)
{
sb.AppendLine($" {kvp.Key}: {kvp.Value}");
}
sb.AppendLine();
}
sb.AppendLine();
}
private void ExportStep(StringBuilder sb, StepItem step)
{
var indent = new string(' ', (step.NestingLevel + 1) * 2);
var statusIcon = step.Status switch
{
StepDisplayStatus.Completed => "[OK]",
StepDisplayStatus.Failed => "[FAIL]",
StepDisplayStatus.Skipped => "[SKIP]",
_ => "[...]"
};
sb.AppendLine($"{indent}{statusIcon} {step.Name} ({step.DurationDisplay})");
if (!string.IsNullOrEmpty(step.FailureReason))
sb.AppendLine($"{indent} Reason: {step.FailureReason}");
}
#endregion
}
#region Flow View Drawable
/// <summary>
/// Custom drawable for the Flow view visualization.
/// </summary>
public class FlowViewDrawable : IDrawable
{
private readonly List<JourneyItem> _journeys;
private const float JourneyHeight = 80;
private const float StepWidth = 120;
private const float StepHeight = 40;
private const float Padding = 20;
private const float StepGap = 20;
public FlowViewDrawable(List<JourneyItem> journeys)
{
_journeys = journeys;
}
public void Draw(ICanvas canvas, RectF dirtyRect)
{
canvas.FillColor = Colors.Transparent;
canvas.FillRectangle(dirtyRect);
float y = Padding;
foreach (var journey in _journeys)
{
DrawJourney(canvas, journey, Padding, y);
y += JourneyHeight + Padding;
}
}
private void DrawJourney(ICanvas canvas, JourneyItem journey, float x, float y)
{
// Draw journey header
canvas.FillColor = journey.StatusColor;
canvas.FillRoundedRectangle(x, y, StepWidth, StepHeight, 8);
canvas.FontColor = Colors.White;
canvas.FontSize = 12;
canvas.DrawString(journey.Name, x + 8, y + 8, StepWidth - 16, StepHeight - 16, HorizontalAlignment.Left, VerticalAlignment.Center);
// Draw steps
float stepX = x + StepWidth + StepGap;
var flatSteps = journey.GetFlattenedSteps().Where(s => s.NestingLevel == 0).ToList();
for (int i = 0; i < flatSteps.Count; i++)
{
var step = flatSteps[i];
// Draw connector line
canvas.StrokeColor = Colors.Gray;
canvas.StrokeSize = 2;
canvas.DrawLine(stepX - StepGap, y + StepHeight / 2, stepX, y + StepHeight / 2);
// Draw step box
canvas.FillColor = step.StatusColor.WithAlpha(0.2f);
canvas.FillRoundedRectangle(stepX, y, StepWidth, StepHeight, 4);
canvas.StrokeColor = step.StatusColor;
canvas.StrokeSize = 2;
canvas.DrawRoundedRectangle(stepX, y, StepWidth, StepHeight, 4);
canvas.FontColor = Colors.Black;
canvas.FontSize = 10;
canvas.DrawString(step.Name, stepX + 4, y + 4, StepWidth - 8, StepHeight - 8, HorizontalAlignment.Left, VerticalAlignment.Center);
stepX += StepWidth + StepGap;
}
}
}
#endregion

View File

@ -12,6 +12,7 @@
<MauiEnableXamlCHotReload>true</MauiEnableXamlCHotReload>
<!-- Suppress XC0022/XC0025: Bindings with Source={x:Reference} to self cannot use compiled bindings -->
<NoWarn>$(NoWarn);XC0022;XC0025</NoWarn>
<PackageOutputPath>C:\Users\logik\Dropbox\Nugets</PackageOutputPath>
<RootNamespace>IronServices.Maui</RootNamespace>
<AssemblyName>IronServices.Maui</AssemblyName>
@ -20,6 +21,7 @@
<Company>MarketAlly</Company>
<PackageId>IronServices.Maui</PackageId>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<PackageIcon>nuget_im.png</PackageIcon>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
@ -38,5 +40,14 @@
<ProjectReference Include="..\ironlicensing-dotnet\IronLicensing.Client.csproj" />
<ProjectReference Include="..\irontelemetry-dotnet\IronTelemetry.Client.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="nuget_im.png">
<Pack>true</Pack>
<PackagePath>\</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>true</Visible>
</None>
<None Include="README.md" Pack="true" PackagePath="\" Condition="Exists('README.md')" />
</ItemGroup>
</Project>

BIN
nuget_im.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB