497 lines
14 KiB
C#
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
|
|
};
|
|
}
|
|
}
|