902 lines
27 KiB
C#
902 lines
27 KiB
C#
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
|