using System.Collections.ObjectModel; using System.Text; using IronTelemetry.Client; using Microsoft.Maui.Controls.Shapes; namespace IronServices.Maui.Controls; /// /// A page for displaying user journeys captured by IronTelemetry with multiple view modes. /// Supports Timeline, Tree, and Flow visualization of journey data. /// /// /// /// // 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 /// }; /// /// public partial class UserJourneyView : ContentPage { private TelemetryClient? _telemetryClient; private readonly ObservableCollection _journeys = new(); private JourneyItem? _selectedJourney; private StepItem? _selectedStep; private JourneyViewMode _viewMode = JourneyViewMode.Timeline; private bool _enableLiveUpdates; private Timer? _liveUpdateTimer; private int _lastKnownItemCount; /// /// Creates a new UserJourneyView instance. /// public UserJourneyView() { InitializeComponent(); JourneyList.ItemsSource = _journeys; } #region Bindable Properties /// /// The TelemetryClient to pull journey data from. /// public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create( nameof(TelemetryClient), typeof(TelemetryClient), typeof(UserJourneyView), null, propertyChanged: OnTelemetryClientChanged); /// /// Gets or sets the TelemetryClient used to retrieve journey data. /// 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(); } /// /// Current view mode (Timeline, Tree, or Flow). /// public static readonly BindableProperty ViewModeProperty = BindableProperty.Create( nameof(ViewMode), typeof(JourneyViewMode), typeof(UserJourneyView), JourneyViewMode.Timeline, propertyChanged: OnViewModeChanged); /// /// Gets or sets the current view mode. /// 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(); } /// /// Whether to automatically refresh when new items are added. /// public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create( nameof(EnableLiveUpdates), typeof(bool), typeof(UserJourneyView), false, propertyChanged: OnEnableLiveUpdatesChanged); /// /// Gets or sets whether to automatically refresh the journey list. /// 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(); } /// /// Whether to show the share button. Default: true. /// public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create( nameof(ShowShareButton), typeof(bool), typeof(UserJourneyView), true, propertyChanged: (b, o, n) => ((UserJourneyView)b).ShareToolbarItem.IsEnabled = (bool)n); /// /// Gets or sets whether the Share toolbar button is visible. /// public bool ShowShareButton { get => (bool)GetValue(ShowShareButtonProperty); set => SetValue(ShowShareButtonProperty, value); } /// /// Whether to show the clear button. Default: true. /// public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create( nameof(ShowClearButton), typeof(bool), typeof(UserJourneyView), true, propertyChanged: (b, o, n) => ((UserJourneyView)b).ClearToolbarItem.IsEnabled = (bool)n); /// /// Gets or sets whether the Clear toolbar button is visible. /// public bool ShowClearButton { get => (bool)GetValue(ShowClearButtonProperty); set => SetValue(ShowClearButtonProperty, value); } /// /// Filter to show only journeys matching this user ID. /// public static readonly BindableProperty UserIdFilterProperty = BindableProperty.Create( nameof(UserIdFilter), typeof(string), typeof(UserJourneyView), null, propertyChanged: (b, o, n) => ((UserJourneyView)b).RefreshJourneys()); /// /// Gets or sets a user ID to filter journeys by. /// public string? UserIdFilter { get => (string?)GetValue(UserIdFilterProperty); set => SetValue(UserIdFilterProperty, value); } #endregion #region Events /// /// Raised when a journey is selected from the list. /// public event EventHandler? JourneySelected; /// /// Raised when a step is selected from a journey. /// public event EventHandler? StepSelected; /// /// Raised when the journey list is refreshed. /// public event EventHandler? JourneysRefreshed; /// /// Raised when journeys are cleared. /// public event EventHandler? JourneysCleared; /// /// Raised when journeys are shared/exported. /// public event EventHandler? JourneysShared; #endregion #region Public Methods /// /// Refresh the journey list from the TelemetryClient's local log queue. /// 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); } /// /// Clear all displayed journeys. /// public void ClearJourneys() { _journeys.Clear(); HideDetailPanel(); UpdateCount(); JourneysCleared?.Invoke(this, EventArgs.Empty); } /// /// Clear all journeys including the TelemetryClient's local queue. /// public void ClearAllJourneys() { _telemetryClient?.ClearLocalLogItems(); ClearJourneys(); } /// /// Export all journeys as a formatted string. /// 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(); } /// /// Get read-only list of current journeys. /// public IReadOnlyList GetJourneys() => _journeys.ToList().AsReadOnly(); /// /// Get the count of journeys. /// 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 /// /// Custom drawable for the Flow view visualization. /// public class FlowViewDrawable : IDrawable { private readonly List _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 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