ironservices-maui/Controls/AppLogView.xaml.cs

497 lines
14 KiB
C#

using System.Collections.ObjectModel;
using System.Text;
using IronTelemetry.Client;
namespace IronServices.Maui.Controls;
/// <summary>
/// A page for displaying captured app logs (exceptions, telemetry events) with share and clear functionality.
/// Use within a NavigationPage for proper toolbar display.
/// </summary>
/// <example>
/// <code>
/// // Navigate to the log view
/// await Navigation.PushAsync(new AppLogView
/// {
/// TelemetryClient = myTelemetryClient
/// });
///
/// // Or with custom title
/// await Navigation.PushAsync(new AppLogView
/// {
/// TelemetryClient = myTelemetryClient,
/// Title = "Error Logs"
/// });
/// </code>
/// </example>
public partial class AppLogView : ContentPage
{
private TelemetryClient? _telemetryClient;
private readonly ObservableCollection<LogItem> _logItems = new();
private LogItem? _selectedItem;
/// <summary>
/// Creates a new AppLogView instance.
/// </summary>
public AppLogView()
{
InitializeComponent();
LogList.BindingContext = _logItems;
}
#region Bindable Properties
/// <summary>
/// The TelemetryClient to pull queued logs from. When set, automatically refreshes the log list.
/// </summary>
public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create(
nameof(TelemetryClient),
typeof(TelemetryClient),
typeof(AppLogView),
null,
propertyChanged: OnTelemetryClientChanged);
/// <summary>
/// Gets or sets the TelemetryClient used to retrieve queued log items.
/// </summary>
public TelemetryClient? TelemetryClient
{
get => (TelemetryClient?)GetValue(TelemetryClientProperty);
set => SetValue(TelemetryClientProperty, value);
}
private static void OnTelemetryClientChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (AppLogView)bindable;
view._telemetryClient = newValue as TelemetryClient;
view.RefreshLogs();
}
/// <summary>
/// Whether to show the share toolbar item. Default: true.
/// </summary>
public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create(
nameof(ShowShareButton),
typeof(bool),
typeof(AppLogView),
true,
propertyChanged: (b, o, n) => ((AppLogView)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 toolbar item. Default: true.
/// </summary>
public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create(
nameof(ShowClearButton),
typeof(bool),
typeof(AppLogView),
true,
propertyChanged: (b, o, n) => ((AppLogView)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);
}
#endregion
#region Events
/// <summary>
/// Raised when logs are cleared via the Clear button.
/// </summary>
public event EventHandler? LogsCleared;
/// <summary>
/// Raised when logs are shared via the Share button.
/// </summary>
public event EventHandler? LogsShared;
/// <summary>
/// Raised when a log item is selected from the list.
/// </summary>
public event EventHandler<LogItem>? LogSelected;
/// <summary>
/// Raised when the log list is refreshed.
/// </summary>
public event EventHandler? LogsRefreshed;
#endregion
#region Public Methods
/// <summary>
/// Refresh the log list from the telemetry client's local log queue.
/// Call this after adding new logs or when the queue may have changed.
/// </summary>
public void RefreshLogs()
{
_logItems.Clear();
if (_telemetryClient != null)
{
// Get all locally captured log items (not just failed ones)
var localItems = _telemetryClient.GetLocalLogItems();
foreach (var item in localItems.OrderByDescending(i => i.Timestamp))
{
_logItems.Add(LogItem.FromEnvelopeItem(item));
}
}
UpdateCount();
LogsRefreshed?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Add a log item manually (for local-only logging without TelemetryClient).
/// </summary>
/// <param name="type">Log type: "exception", "message", "info", "warning", etc.</param>
/// <param name="title">Title or exception type name.</param>
/// <param name="message">Log message or exception message.</param>
/// <param name="stackTrace">Optional stack trace for exceptions.</param>
public void AddLog(string type, string title, string message, string? stackTrace = null)
{
var item = new LogItem
{
Type = type,
Title = title,
Message = message,
StackTrace = stackTrace,
Timestamp = DateTime.UtcNow
};
_logItems.Insert(0, item);
UpdateCount();
}
/// <summary>
/// Add an exception as a log entry.
/// </summary>
/// <param name="ex">The exception to log.</param>
public void AddException(Exception ex)
{
AddLog("exception", ex.GetType().Name, ex.Message, ex.StackTrace);
}
/// <summary>
/// Clear all displayed logs. Does not clear the TelemetryClient's offline queue.
/// </summary>
public void ClearLogs()
{
_logItems.Clear();
HideDetail();
UpdateCount();
LogsCleared?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Clear all logs including the TelemetryClient's local log queue.
/// </summary>
public void ClearAllLogs()
{
_telemetryClient?.ClearLocalLogItems();
_telemetryClient?.OfflineQueue?.Clear();
ClearLogs();
}
/// <summary>
/// Export all logs as a formatted string suitable for sharing or saving.
/// </summary>
/// <returns>Formatted log export string.</returns>
public string ExportLogs()
{
var sb = new StringBuilder();
sb.AppendLine($"=== App Logs Export ===");
sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Total items: {_logItems.Count}");
sb.AppendLine();
foreach (var item in _logItems)
{
sb.AppendLine($"--- [{item.Type.ToUpperInvariant()}] {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC} ---");
sb.AppendLine($"Title: {item.Title}");
sb.AppendLine($"Message: {item.Message}");
if (!string.IsNullOrEmpty(item.StackTrace))
{
sb.AppendLine("Stack Trace:");
sb.AppendLine(item.StackTrace);
}
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>
/// Get the current log items as a read-only list.
/// </summary>
public IReadOnlyList<LogItem> GetLogs() => _logItems.ToList().AsReadOnly();
/// <summary>
/// Get the count of log items.
/// </summary>
public int LogCount => _logItems.Count;
#endregion
#region Event Handlers
private void OnClearClicked(object? sender, EventArgs e)
{
ClearLogs();
}
private void OnRefreshClicked(object? sender, EventArgs e)
{
RefreshLogs();
}
private async void OnShareClicked(object? sender, EventArgs e)
{
if (_logItems.Count == 0)
{
await DisplayAlert("No Logs", "There are no logs to share.", "OK");
return;
}
var content = ExportLogs();
await Share.Default.RequestAsync(new ShareTextRequest
{
Title = "App Logs",
Text = content
});
LogsShared?.Invoke(this, EventArgs.Empty);
}
private async void OnLogSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is LogItem item)
{
_selectedItem = item;
ShowDetail(item);
LogSelected?.Invoke(this, item);
// Copy log entry to clipboard
await CopyLogToClipboardAsync(item);
}
}
/// <summary>
/// Copies a log item's details to the clipboard.
/// </summary>
private async Task CopyLogToClipboardAsync(LogItem item)
{
var sb = new StringBuilder();
sb.AppendLine("=== Exception Log ===");
sb.AppendLine($"Type: {item.Type.ToUpperInvariant()}");
sb.AppendLine($"Time: {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC}");
sb.AppendLine($"Title: {item.Title}");
sb.AppendLine();
sb.AppendLine("--- Message ---");
sb.AppendLine(item.Message);
if (!string.IsNullOrEmpty(item.JourneyId))
{
sb.AppendLine();
sb.AppendLine($"Journey ID: {item.JourneyId}");
}
if (!string.IsNullOrEmpty(item.UserId))
{
sb.AppendLine($"User ID: {item.UserId}");
}
if (!string.IsNullOrEmpty(item.StackTrace))
{
sb.AppendLine();
sb.AppendLine("--- Stack Trace ---");
sb.AppendLine(item.StackTrace);
}
await Clipboard.Default.SetTextAsync(sb.ToString());
// Show brief toast/feedback
await MainThread.InvokeOnMainThreadAsync(async () =>
{
var originalText = CountLabel.Text;
CountLabel.Text = "Copied to clipboard!";
CountLabel.TextColor = Colors.Green;
await Task.Delay(1500);
CountLabel.Text = originalText;
CountLabel.TextColor = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#9CA3AF")
: Color.FromArgb("#6B7280");
});
}
private void OnCloseDetailClicked(object? sender, EventArgs e)
{
HideDetail();
LogList.SelectedItem = null;
}
private async void OnCopyStackTraceClicked(object? sender, EventArgs e)
{
if (_selectedItem != null)
{
await CopyLogToClipboardAsync(_selectedItem);
if (sender is Button button)
{
var originalText = button.Text;
button.Text = "Copied!";
await Task.Delay(1000);
button.Text = originalText;
}
}
}
#endregion
#region Lifecycle
protected override void OnAppearing()
{
base.OnAppearing();
RefreshLogs();
}
#endregion
#region Helper Methods
private void UpdateCount()
{
CountLabel.Text = _logItems.Count == 1 ? "1 item" : $"{_logItems.Count} items";
}
private void ShowDetail(LogItem item)
{
DetailTitle.Text = item.Title;
DetailMessage.Text = item.Message ?? "No message available";
DetailStackTrace.Text = item.StackTrace ?? "No stack trace available";
DetailPanel.IsVisible = true;
}
private void HideDetail()
{
DetailPanel.IsVisible = false;
_selectedItem = null;
}
#endregion
}
/// <summary>
/// Represents a log item for display in AppLogView.
/// </summary>
public class LogItem
{
/// <summary>
/// UTC timestamp when the log was captured.
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Log type: "exception", "message", "journey_start", "journey_end", "step_start", "step_end", "info", "warning".
/// </summary>
public string Type { get; set; } = "info";
/// <summary>
/// Title or exception type name.
/// </summary>
public string Title { get; set; } = "";
/// <summary>
/// Log message or exception message.
/// </summary>
public string Message { get; set; } = "";
/// <summary>
/// Stack trace for exceptions.
/// </summary>
public string? StackTrace { get; set; }
/// <summary>
/// Associated journey ID if part of a user journey.
/// </summary>
public string? JourneyId { get; set; }
/// <summary>
/// Associated user ID.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Formatted timestamp for display (local time, HH:mm:ss).
/// </summary>
public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss");
/// <summary>
/// Short type label for display badge.
/// </summary>
public string TypeDisplay => Type switch
{
"exception" => "ERROR",
"message" => "MSG",
"journey_start" => "START",
"journey_end" => "END",
"step_start" => "STEP",
"step_end" => "STEP",
"warning" => "WARN",
"info" => "INFO",
_ => Type.ToUpperInvariant()
};
/// <summary>
/// Color for type badge.
/// </summary>
public Color TypeColor => Type switch
{
"exception" => Colors.Red,
"warning" => Colors.Orange,
"message" => Colors.Blue,
"info" => Colors.Teal,
"journey_start" => Colors.Green,
"journey_end" => Colors.Green,
"step_start" => Colors.Purple,
"step_end" => Colors.Purple,
_ => Colors.Gray
};
/// <summary>
/// Create a LogItem from a TelemetryClient EnvelopeItem.
/// </summary>
public static LogItem FromEnvelopeItem(EnvelopeItem item)
{
return new LogItem
{
Timestamp = item.Timestamp,
Type = item.Type ?? "unknown",
Title = item.ExceptionType ?? item.Name ?? item.Type ?? "Log Entry",
Message = item.Message ?? "",
StackTrace = item.StackTrace,
JourneyId = item.JourneyId,
UserId = item.UserId
};
}
}