UserJourney
This commit is contained in:
parent
017e03099b
commit
bd4085dfce
|
|
@ -28,7 +28,9 @@ public partial class AppLogView : ContentPage
|
||||||
{
|
{
|
||||||
private TelemetryClient? _telemetryClient;
|
private TelemetryClient? _telemetryClient;
|
||||||
private readonly ObservableCollection<LogItem> _logItems = new();
|
private readonly ObservableCollection<LogItem> _logItems = new();
|
||||||
|
private readonly List<LogItem> _manualLogItems = new(); // Items added via AddLog() - preserved across RefreshLogs()
|
||||||
private LogItem? _selectedItem;
|
private LogItem? _selectedItem;
|
||||||
|
private bool _enableLiveUpdates;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new AppLogView instance.
|
/// Creates a new AppLogView instance.
|
||||||
|
|
@ -65,6 +67,7 @@ public partial class AppLogView : ContentPage
|
||||||
var view = (AppLogView)bindable;
|
var view = (AppLogView)bindable;
|
||||||
view._telemetryClient = newValue as TelemetryClient;
|
view._telemetryClient = newValue as TelemetryClient;
|
||||||
view.RefreshLogs();
|
view.RefreshLogs();
|
||||||
|
view.UpdateLiveUpdatesSubscription();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -105,6 +108,60 @@ public partial class AppLogView : ContentPage
|
||||||
set => SetValue(ShowClearButtonProperty, value);
|
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
|
#endregion
|
||||||
|
|
||||||
#region Events
|
#region Events
|
||||||
|
|
@ -135,29 +192,41 @@ public partial class AppLogView : ContentPage
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refresh the log list from the telemetry client's local log queue.
|
/// 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>
|
/// </summary>
|
||||||
public void RefreshLogs()
|
public void RefreshLogs()
|
||||||
{
|
{
|
||||||
_logItems.Clear();
|
_logItems.Clear();
|
||||||
|
|
||||||
|
// Collect all items to display
|
||||||
|
var allItems = new List<LogItem>();
|
||||||
|
|
||||||
|
// Add items from telemetry client
|
||||||
if (_telemetryClient != null)
|
if (_telemetryClient != null)
|
||||||
{
|
{
|
||||||
// Get all locally captured log items (not just failed ones)
|
|
||||||
var localItems = _telemetryClient.GetLocalLogItems();
|
var localItems = _telemetryClient.GetLocalLogItems();
|
||||||
|
foreach (var item in localItems)
|
||||||
foreach (var item in localItems.OrderByDescending(i => i.Timestamp))
|
|
||||||
{
|
{
|
||||||
_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();
|
UpdateCount();
|
||||||
LogsRefreshed?.Invoke(this, EventArgs.Empty);
|
LogsRefreshed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add a log item manually (for local-only logging without TelemetryClient).
|
/// Add a log item manually (for local-only logging without TelemetryClient).
|
||||||
|
/// These items are preserved across RefreshLogs() calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type">Log type: "exception", "message", "info", "warning", etc.</param>
|
/// <param name="type">Log type: "exception", "message", "info", "warning", etc.</param>
|
||||||
/// <param name="title">Title or exception type name.</param>
|
/// <param name="title">Title or exception type name.</param>
|
||||||
|
|
@ -171,9 +240,14 @@ public partial class AppLogView : ContentPage
|
||||||
Title = title,
|
Title = title,
|
||||||
Message = message,
|
Message = message,
|
||||||
StackTrace = stackTrace,
|
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);
|
_logItems.Insert(0, item);
|
||||||
UpdateCount();
|
UpdateCount();
|
||||||
}
|
}
|
||||||
|
|
@ -188,11 +262,12 @@ public partial class AppLogView : ContentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public void ClearLogs()
|
public void ClearLogs()
|
||||||
{
|
{
|
||||||
_logItems.Clear();
|
_logItems.Clear();
|
||||||
|
_manualLogItems.Clear();
|
||||||
HideDetail();
|
HideDetail();
|
||||||
UpdateCount();
|
UpdateCount();
|
||||||
LogsCleared?.Invoke(this, EventArgs.Empty);
|
LogsCleared?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
@ -372,6 +447,14 @@ public partial class AppLogView : ContentPage
|
||||||
{
|
{
|
||||||
base.OnAppearing();
|
base.OnAppearing();
|
||||||
RefreshLogs();
|
RefreshLogs();
|
||||||
|
UpdateLiveUpdatesSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDisappearing()
|
||||||
|
{
|
||||||
|
base.OnDisappearing();
|
||||||
|
_liveUpdateTimer?.Dispose();
|
||||||
|
_liveUpdateTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
@ -440,6 +523,11 @@ public class LogItem
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this item was added manually via AddLog() vs from TelemetryClient.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsManualEntry { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formatted timestamp for display (local time, HH:mm:ss).
|
/// Formatted timestamp for display (local time, HH:mm:ss).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<MauiEnableXamlCHotReload>true</MauiEnableXamlCHotReload>
|
<MauiEnableXamlCHotReload>true</MauiEnableXamlCHotReload>
|
||||||
<!-- Suppress XC0022/XC0025: Bindings with Source={x:Reference} to self cannot use compiled bindings -->
|
<!-- Suppress XC0022/XC0025: Bindings with Source={x:Reference} to self cannot use compiled bindings -->
|
||||||
<NoWarn>$(NoWarn);XC0022;XC0025</NoWarn>
|
<NoWarn>$(NoWarn);XC0022;XC0025</NoWarn>
|
||||||
|
<PackageOutputPath>C:\Users\logik\Dropbox\Nugets</PackageOutputPath>
|
||||||
|
|
||||||
<RootNamespace>IronServices.Maui</RootNamespace>
|
<RootNamespace>IronServices.Maui</RootNamespace>
|
||||||
<AssemblyName>IronServices.Maui</AssemblyName>
|
<AssemblyName>IronServices.Maui</AssemblyName>
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
<Company>MarketAlly</Company>
|
<Company>MarketAlly</Company>
|
||||||
<PackageId>IronServices.Maui</PackageId>
|
<PackageId>IronServices.Maui</PackageId>
|
||||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||||
|
<PackageIcon>nuget_im.png</PackageIcon>
|
||||||
|
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">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="..\ironlicensing-dotnet\IronLicensing.Client.csproj" />
|
||||||
<ProjectReference Include="..\irontelemetry-dotnet\IronTelemetry.Client.csproj" />
|
<ProjectReference Include="..\irontelemetry-dotnet\IronTelemetry.Client.csproj" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Loading…
Reference in New Issue