File: Components\Pages\StructuredLogs.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Pages;
 
public partial class StructuredLogs : IPageWithSessionAndUrlState<StructuredLogs.StructuredLogsPageViewModel, StructuredLogs.StructuredLogsPageState>
{
    private SelectViewModel<ResourceTypeDetails> _allApplication = default!;
 
    private TotalItemsFooter _totalItemsFooter = default!;
    private List<OtlpApplication> _applications = default!;
    private List<SelectViewModel<ResourceTypeDetails>> _applicationViewModels = default!;
    private List<SelectViewModel<LogLevel?>> _logLevels = default!;
    private Subscription? _applicationsSubscription;
    private Subscription? _logsSubscription;
    private bool _applicationChanged;
    private CancellationTokenSource? _filterCts;
    private string? _elementIdBeforeDetailsViewOpened;
 
    public string BasePath => DashboardUrls.StructuredLogsBasePath;
    public string SessionStorageKey => "StructuredLogs_PageState";
    public StructuredLogsPageViewModel PageViewModel { get; set; } = null!;
 
    [Parameter]
    public string? ApplicationName { get; set; }
 
    [Inject]
    public required TelemetryRepository TelemetryRepository { get; set; }
 
    [Inject]
    public required StructuredLogsViewModel ViewModel { get; set; }
 
    [Inject]
    public required IDialogService DialogService { get; set; }
 
    [Inject]
    public required ProtectedSessionStorage SessionStorage { get; set; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; set; }
 
    [Inject]
    public required BrowserTimeProvider TimeProvider { get; set; }
 
    [Inject]
    public required ILogger<Traces> Logger { get; init; }
 
    [Parameter]
    [SupplyParameterFromQuery]
    public string? TraceId { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery]
    public string? SpanId { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery(Name = "logLevel")]
    public string? LogLevelText { get; set; }
 
    [Parameter]
    [SupplyParameterFromQuery(Name = "filters")]
    public string? SerializedLogFilters { get; set; }
 
    public StructureLogsDetailsViewModel? SelectedLogEntry { get; set; }
 
    private ValueTask<GridItemsProviderResult<OtlpLogEntry>> GetData(GridItemsProviderRequest<OtlpLogEntry> request)
    {
        ViewModel.StartIndex = request.StartIndex;
        ViewModel.Count = request.Count;
 
        var logs = ViewModel.GetLogs();
 
        // Updating the total item count as a field doesn't work because it isn't updated with the grid.
        // The workaround is to put the count inside a control and explicitly update and refresh the control.
        _totalItemsFooter.SetTotalItemCount(logs.TotalItemCount);
 
        TelemetryRepository.MarkViewedErrorLogs(ViewModel.ApplicationKey);
 
        return ValueTask.FromResult(GridItemsProviderResult.From(logs.Items, logs.TotalItemCount));
    }
 
    protected override Task OnInitializedAsync()
    {
        if (!string.IsNullOrEmpty(TraceId))
        {
            ViewModel.AddFilter(new LogFilter { Field = "TraceId", Condition = FilterCondition.Equals, Value = TraceId });
        }
        if (!string.IsNullOrEmpty(SpanId))
        {
            ViewModel.AddFilter(new LogFilter { Field = "SpanId", Condition = FilterCondition.Equals, Value = SpanId });
        }
 
        _allApplication = new()
        {
            Id = null,
            Name = Loc[nameof(Dashboard.Resources.ControlsStrings.All)]
        };
 
        _logLevels = new List<SelectViewModel<LogLevel?>>
        {
            new SelectViewModel<LogLevel?> { Id = null, Name = $"({Loc[nameof(Dashboard.Resources.ControlsStrings.All)]})" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Trace, Name = "Trace" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Debug, Name = "Debug" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Information, Name = "Information" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Warning, Name = "Warning" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Error, Name = "Error" },
            new SelectViewModel<LogLevel?> { Id = LogLevel.Critical, Name = "Critical" },
        };
 
        PageViewModel = new StructuredLogsPageViewModel { SelectedLogLevel = _logLevels[0], SelectedApplication = _allApplication };
 
        UpdateApplications();
        _applicationsSubscription = TelemetryRepository.OnNewApplications(() => InvokeAsync(() =>
        {
            UpdateApplications();
            StateHasChanged();
        }));
 
        return Task.CompletedTask;
    }
 
    protected override async Task OnParametersSetAsync()
    {
        await this.InitializeViewModelAsync();
        UpdateSubscription();
    }
 
    private void UpdateApplications()
    {
        _applications = TelemetryRepository.GetApplications();
        _applicationViewModels = ApplicationsSelectHelpers.CreateApplications(_applications);
        _applicationViewModels.Insert(0, _allApplication);
    }
 
    private Task HandleSelectedApplicationChangedAsync()
    {
        _applicationChanged = true;
 
        return this.AfterViewModelChangedAsync();
    }
 
    private async Task HandleSelectedLogLevelChangedAsync()
    {
        _applicationChanged = true;
 
        await ClearSelectedLogEntryAsync();
        await this.AfterViewModelChangedAsync();
    }
 
    private void UpdateSubscription()
    {
        // Subscribe to updates.
        if (_logsSubscription is null || _logsSubscription.ApplicationKey != PageViewModel.SelectedApplication.Id?.GetApplicationKey())
        {
            _logsSubscription?.Dispose();
            _logsSubscription = TelemetryRepository.OnNewLogs(PageViewModel.SelectedApplication.Id?.GetApplicationKey(), SubscriptionType.Read, async () =>
            {
                ViewModel.ClearData();
                await InvokeAsync(StateHasChanged);
            });
        }
    }
 
    private async Task OnShowPropertiesAsync(OtlpLogEntry entry, string buttonId)
    {
        _elementIdBeforeDetailsViewOpened = buttonId;
 
        if (SelectedLogEntry?.LogEntry == entry)
        {
            await ClearSelectedLogEntryAsync();
        }
        else
        {
            var logEntryViewModel = new StructureLogsDetailsViewModel
            {
                LogEntry = entry
            };
 
            SelectedLogEntry = logEntryViewModel;
        }
    }
 
    private async Task ClearSelectedLogEntryAsync(bool causedByUserAction = false)
    {
        SelectedLogEntry = null;
 
        if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
        {
            await JS.InvokeVoidAsync("focusElement", _elementIdBeforeDetailsViewOpened);
        }
 
        _elementIdBeforeDetailsViewOpened = null;
    }
 
    private async Task OpenFilterAsync(LogFilter? entry)
    {
        var logPropertyKeys = TelemetryRepository.GetLogPropertyKeys(PageViewModel.SelectedApplication.Id?.GetApplicationKey());
 
        var title = entry is not null ? Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEditFilter)] : Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsAddFilter)];
        var parameters = new DialogParameters
        {
            OnDialogResult = DialogService.CreateDialogCallback(this, HandleFilterDialog),
            Title = title,
            Alignment = HorizontalAlignment.Right,
            PrimaryAction = null,
            SecondaryAction = null,
        };
        var data = new FilterDialogViewModel
        {
            Filter = entry,
            LogPropertyKeys = logPropertyKeys
        };
        await DialogService.ShowPanelAsync<FilterDialog>(data, parameters);
    }
 
    private async Task HandleFilterDialog(DialogResult result)
    {
        if (result.Data is FilterDialogResult filterResult && filterResult.Filter is LogFilter filter)
        {
            if (filterResult.Delete)
            {
                ViewModel.RemoveFilter(filter);
            }
            else if (filterResult.Add)
            {
                ViewModel.AddFilter(filter);
            }
 
            await ClearSelectedLogEntryAsync();
        }
 
        await this.AfterViewModelChangedAsync();
    }
 
    private void HandleFilter(ChangeEventArgs args)
    {
        if (args.Value is string newFilter)
        {
            PageViewModel.Filter = newFilter;
            _filterCts?.Cancel();
 
            // Debouncing logic. Apply the filter after a delay.
            var cts = _filterCts = new CancellationTokenSource();
            _ = Task.Run(async () =>
            {
                await ClearSelectedLogEntryAsync();
 
                await Task.Delay(400, cts.Token);
                ViewModel.FilterText = newFilter;
                await InvokeAsync(StateHasChanged);
            });
        }
    }
 
    private async Task HandleClearAsync()
    {
        if (_filterCts is not null)
        {
            await _filterCts.CancelAsync();
        }
 
        ViewModel.FilterText = string.Empty;
        await ClearSelectedLogEntryAsync();
        StateHasChanged();
    }
 
    private string GetResourceName(OtlpApplication app) => OtlpApplication.GetResourceName(app, _applications);
 
    private string GetRowClass(OtlpLogEntry entry)
    {
        if (entry == SelectedLogEntry?.LogEntry)
        {
            return "selected-row";
        }
        else
        {
            return $"log-row-{entry.Severity.ToString().ToLowerInvariant()}";
        }
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (_applicationChanged)
        {
            await JS.InvokeVoidAsync("resetContinuousScrollPosition");
            _applicationChanged = false;
        }
        if (firstRender)
        {
            await JS.InvokeVoidAsync("initializeContinuousScroll");
        }
    }
 
    public void Dispose()
    {
        _applicationsSubscription?.Dispose();
        _logsSubscription?.Dispose();
        _filterCts?.Dispose();
    }
 
    public string GetUrlFromSerializableViewModel(StructuredLogsPageState serializable)
    {
        var filters = (serializable.Filters.Count > 0) ? LogFilterFormatter.SerializeLogFiltersToString(serializable.Filters) : null;
 
        var url = DashboardUrls.StructuredLogsUrl(
            resource: serializable.SelectedApplication,
            logLevel: serializable.LogLevelText,
            filters: filters);
 
        return url;
    }
 
    public StructuredLogsPageState ConvertViewModelToSerializable()
    {
        return new StructuredLogsPageState
        {
            Filter = PageViewModel.Filter,
            LogLevelText = PageViewModel.SelectedLogLevel.Id?.ToString().ToLowerInvariant(),
            SelectedApplication = PageViewModel.SelectedApplication.Id is not null ? PageViewModel.SelectedApplication.Name : null,
            Filters = ViewModel.Filters
        };
    }
 
    public void UpdateViewModelFromQuery(StructuredLogsPageViewModel viewModel)
    {
        PageViewModel.SelectedApplication = _applicationViewModels.GetApplication(Logger, ApplicationName, _allApplication);
        ViewModel.ApplicationKey = PageViewModel.SelectedApplication.Id?.GetApplicationKey();
 
        if (LogLevelText is not null && Enum.TryParse<LogLevel>(LogLevelText, ignoreCase: true, out var logLevel))
        {
            PageViewModel.SelectedLogLevel = _logLevels.SingleOrDefault(e => e.Id == logLevel) ?? _logLevels[0];
        }
        else
        {
            PageViewModel.SelectedLogLevel = _logLevels[0];
        }
 
        ViewModel.LogLevel = PageViewModel.SelectedLogLevel.Id;
 
        if (SerializedLogFilters is not null)
        {
            var filters = LogFilterFormatter.DeserializeLogFiltersFromString(SerializedLogFilters);
 
            if (filters.Count > 0)
            {
                ViewModel.ClearFilters();
                ViewModel.AddFilters(filters);
            }
        }
 
        _ = Task.Run(async () =>
        {
            await InvokeAsync(StateHasChanged);
        });
    }
 
    public class StructuredLogsPageViewModel
    {
        public string Filter { get; set; } = string.Empty;
        public required SelectViewModel<ResourceTypeDetails> SelectedApplication { get; set; }
        public SelectViewModel<LogLevel?> SelectedLogLevel { get; set; } = default!;
    }
 
    public class StructuredLogsPageState
    {
        public required string Filter { get; set; }
        public string? SelectedApplication { get; set; }
        public string? LogLevelText { get; set; }
        public required IReadOnlyCollection<LogFilter> Filters { get; set; }
    }
}