File: Components\Dialogs\FilterDialog.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 System.Globalization;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Dialogs;
 
public partial class FilterDialog : IAsyncDisposable
{
    // Cancels in-flight telemetry reads when the dialog closes. Reads now run against SQLite on the thread
    // pool, so without this a closed dialog leaves a full-table scan running with nobody waiting on it.
    private readonly CancellationTokenSource _disposeCts = new();
    private long _fieldValuesUpdateVersion;
    private List<SelectViewModel<FilterCondition>> _filterConditions = null!;
    private List<SelectViewModel<FilterCondition>> _stringFilterConditions = null!;
    private List<SelectViewModel<FilterCondition>> _numericFilterConditions = null!;
    private List<SelectViewModel<FilterCondition>> _dateFilterConditions = null!;
 
    private SelectViewModel<FilterCondition> CreateFilterSelectViewModel(FilterCondition condition) =>
        new SelectViewModel<FilterCondition> { Id = condition, Name = FieldTelemetryFilter.ConditionToString(condition, FilterLoc) };
 
    [CascadingParameter]
    public IDialogInstance? Dialog { get; set; }
 
    [Parameter]
    public FilterDialogViewModel Content { get; set; } = default!;
 
    [Inject]
    public required DashboardDataSource DataSource { get; init; }
 
    [Inject]
    public required ILogger<FilterDialog> Logger { get; init; }
 
    public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository;
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    private IJSObjectReference? _jsModule;
    private ElementReference _datePickerInput;
    private FilterDialogFormModel _formModel = default!;
    private List<SelectViewModel<string>> _parameters = default!;
    private List<SelectViewModel<FieldValue>> _filteredValues = default!;
    private List<SelectViewModel<FieldValue>>? _allValues;
    private bool _loadingPropertyKeys = true;
    private bool _loadingFieldValues = true;
    private SelectViewModel<FieldValue>? _selectedValue;
 
    public EditContext EditContext { get; private set; } = default!;
 
    protected override async Task OnInitializedAsync()
    {
        _stringFilterConditions =
        [
            CreateFilterSelectViewModel(FilterCondition.Equals),
            CreateFilterSelectViewModel(FilterCondition.Contains),
            CreateFilterSelectViewModel(FilterCondition.NotEqual),
            CreateFilterSelectViewModel(FilterCondition.NotContains)
        ];
 
        _numericFilterConditions =
        [
            CreateFilterSelectViewModel(FilterCondition.Equals),
            CreateFilterSelectViewModel(FilterCondition.NotEqual),
            CreateFilterSelectViewModel(FilterCondition.GreaterThanOrEqual),
            CreateFilterSelectViewModel(FilterCondition.GreaterThan),
            CreateFilterSelectViewModel(FilterCondition.LessThanOrEqual),
            CreateFilterSelectViewModel(FilterCondition.LessThan)
        ];
 
        _dateFilterConditions =
        [
            CreateFilterSelectViewModel(FilterCondition.GreaterThanOrEqual),
            CreateFilterSelectViewModel(FilterCondition.GreaterThan),
            CreateFilterSelectViewModel(FilterCondition.LessThanOrEqual),
            CreateFilterSelectViewModel(FilterCondition.LessThan),
            CreateFilterSelectViewModel(FilterCondition.Equals),
            CreateFilterSelectViewModel(FilterCondition.NotEqual)
        ];
 
        _filterConditions = _stringFilterConditions;
 
        _formModel = new FilterDialogFormModel();
        EditContext = new EditContext(_formModel);
 
        _filteredValues = [];
        _parameters = CreateParameters([]);
 
        if (Content.Filter is { } filter)
        {
            _formModel.Parameter = _parameters.SingleOrDefault(c => c.Id == filter.Field);
            UpdateSelectedParameter();
            _formModel.Condition = _filterConditions.SingleOrDefault(c => c.Id == filter.Condition) ?? GetDefaultCondition();
            SetFormValue(filter.Value);
        }
        else
        {
            _formModel.Parameter = _parameters.FirstOrDefault();
            UpdateSelectedParameter();
            _formModel.Condition = GetDefaultCondition();
            SetFormValue("");
        }
 
        if (!await UpdateParameterFieldValuesAsync())
        {
            return;
        }
        ValueChanged();
 
        try
        {
            var propertyKeys = await Content.GetPropertyKeysAsync(_disposeCts.Token);
 
            var selectedParameter = _formModel.Parameter?.Id;
            _parameters = CreateParameters(propertyKeys);
            _formModel.Parameter = _parameters.SingleOrDefault(parameter => parameter.Id == selectedParameter) ?? _parameters.FirstOrDefault();
        }
        catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested)
        {
            // The dialog closed while the read was in flight.
        }
        catch (Exception ex)
        {
            // Custom property keys are additive to KnownKeys, so the dialog is still usable without them.
            // Letting the exception escape OnInitializedAsync would tear down the circuit and take the whole
            // dashboard tab with it for what is a recoverable read failure.
            Logger.LogWarning(ex, "Error loading filter property keys.");
        }
        finally
        {
            // Property keys are read from the database on a background thread, so this can fault.
            // Clear the flag in a finally, otherwise the parameter combobox stays disabled with a
            // spinner for the lifetime of the dialog and the only recovery is reloading the page.
            _loadingPropertyKeys = false;
        }
    }
 
    private List<SelectViewModel<string>> CreateParameters(List<string> propertyKeys)
    {
        var knownFields = Content.KnownKeys.Select(p => new SelectViewModel<string> { Id = p, Name = FieldTelemetryFilter.ResolveFieldName(p) }).ToList();
        var customFields = propertyKeys
            .Append(Content.Filter is { Field: { } field } && !Content.KnownKeys.Contains(field, StringComparers.OtlpAttribute) ? field : null)
            .OfType<string>()
            .Distinct(StringComparers.OtlpAttribute)
            .Select(propertyKey => new SelectViewModel<string> { Id = propertyKey, Name = FieldTelemetryFilter.ResolveFieldName(propertyKey) })
            .ToList();
 
        return customFields.Count > 0
            ?
            [
                .. knownFields,
                new SelectViewModel<string> { Id = null, Name = "-" },
                .. customFields
            ]
            : knownFields;
    }
 
    private void UpdateSelectedParameter()
    {
        var fieldType = _formModel.Parameter?.Id is { } parameterName ? FieldTelemetryFilter.GetFieldType(parameterName) : FieldType.String;
        _formModel.ValueIsNumeric = fieldType is FieldType.Numeric;
        _formModel.ValueIsDate = fieldType is FieldType.Date;
        _filterConditions = fieldType switch
        {
            FieldType.Numeric => _numericFilterConditions,
            FieldType.Date => _dateFilterConditions,
            _ => _stringFilterConditions
        };
    }
 
    private SelectViewModel<FilterCondition> GetDefaultCondition()
    {
        var condition = (_formModel.ValueIsNumeric || _formModel.ValueIsDate) ? FilterCondition.GreaterThanOrEqual : FilterCondition.Contains;
        return _filterConditions.Single(c => c.Id == condition);
    }
 
    private void SetFormValue(string value)
    {
        if (_formModel.ValueIsNumeric)
        {
            _formModel.Value = null;
            _formModel.NumericValue = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericValue)
                ? numericValue
                : null;
        }
        else
        {
            _formModel.Value = value;
            _formModel.NumericValue = null;
        }
    }
 
    private async Task<bool> UpdateParameterFieldValuesAsync()
    {
        var updateVersion = Interlocked.Increment(ref _fieldValuesUpdateVersion);
 
        if (_formModel.ValueIsNumeric || _formModel.ValueIsDate)
        {
            _allValues = null;
            _filteredValues = [];
            _loadingFieldValues = false;
            return true;
        }
 
        if (_formModel.Parameter?.Id is { } parameterName)
        {
            _loadingFieldValues = true;
            _allValues = null;
            _filteredValues = [];
 
            try
            {
                var fieldValues = await Content.GetFieldValuesAsync(parameterName, _disposeCts.Token);
                if (updateVersion != Volatile.Read(ref _fieldValuesUpdateVersion))
                {
                    return false;
                }
 
                _allValues = fieldValues
                    .Select(kvp => new FieldValue { Value = kvp.Key, Count = kvp.Value })
                    .OrderByDescending(v => v.Count)
                    .ThenBy(v => v.Value, StringComparers.OtlpFieldValue)
                    .Select(v => new SelectViewModel<FieldValue> { Id = v, Name = v.Value })
                    .ToList();
 
                _selectedValue = _formModel.Value is { Length: > 0 } value
                    ? _allValues.FirstOrDefault(vm => vm.Name == value) ?? new SelectViewModel<FieldValue>
                    {
                        Id = new FieldValue { Value = value, Count = 0 },
                        Name = value
                    }
                    : null;
                _loadingFieldValues = false;
            }
            catch (OperationCanceledException) when (_disposeCts.IsCancellationRequested)
            {
                // The dialog closed while the read was in flight.
                return false;
            }
            catch (Exception ex)
            {
                // Field values only drive the value autocomplete, so the user can still type a value. Failing
                // the whole dialog for a recoverable read error would be a worse outcome.
                Logger.LogWarning(ex, "Error loading filter values for field '{FieldName}'.", parameterName);
 
                // Only the newest in-flight load owns the loading flag. A stale load clearing it here
                // would hide the spinner while a newer load is still running.
                if (updateVersion != Volatile.Read(ref _fieldValuesUpdateVersion))
                {
                    return false;
                }
 
                _loadingFieldValues = false;
            }
        }
        else
        {
            _allValues = null;
            _loadingFieldValues = false;
            _selectedValue = null;
        }
 
        return true;
    }
 
    private async Task ParameterChangedAsync()
    {
        UpdateSelectedParameter();
        _formModel.Condition = GetDefaultCondition();
        SetFormValue("");
        if (!await UpdateParameterFieldValuesAsync())
        {
            return;
        }
 
        StateHasChanged();
 
        if (_formModel.ValueIsNumeric || _formModel.ValueIsDate)
        {
            return;
        }
 
        // Clearing the selected value and the combo box items together wasn't correctly clearing the selected value.
        // This is hacky, but adding a delay between the two operations puts the combo box in the right state.
        // Limitation of FluentUI: https://github.com/microsoft/fluentui-blazor/issues/2708
        await Task.Delay(100);
        ValueChanged();
    }
 
    private void ValueChanged()
    {
        if (_formModel.ValueIsNumeric || _formModel.ValueIsDate)
        {
            return;
        }
 
        // Limit to 1000 items to avoid the combo box have too many items and impacting UI perf.
        const int maxItems = 1000;
 
        if (_allValues != null)
        {
            IEnumerable<SelectViewModel<FieldValue>> newValues = _allValues;
            if (_formModel.Value is { Length: > 0 } value)
            {
                newValues = newValues.Where(vm => vm.Name.Contains(value, StringComparison.OrdinalIgnoreCase));
            }
 
            // If no values match the filter, don't apply the filter.
            // This improves user experience and fixes some combobox issues.
            // https://github.com/microsoft/fluentui-blazor/issues/4314#issuecomment-3577475233
            _filteredValues = newValues.Any() ? newValues.Take(maxItems).ToList() : _allValues.Take(maxItems).ToList();
        }
        else
        {
            _filteredValues = [];
        }
    }
 
    private void UpdateValueState()
    {
        // Fluent copies the selected option's text back into the input on blur. Keep the selection
        // synchronized with typed text so losing focus doesn't restore a stale value.
        // Reuse an exact match to retain its metadata, or represent custom text (including an empty
        // value) with a standalone option that isn't added to the suggestions.
        if (_selectedValue?.Name != _formModel.Value)
        {
            var value = _formModel.Value ?? string.Empty;
            _selectedValue = _allValues?.FirstOrDefault(vm => vm.Name == value) ?? new SelectViewModel<FieldValue>
            {
                Id = new FieldValue { Value = value, Count = 0 },
                Name = value
            };
        }
 
        EditContext.NotifyFieldChanged(new FieldIdentifier(_formModel, nameof(_formModel.Value)));
        ValueChanged();
    }
 
    private void SelectedValueChanged()
    {
        if (_selectedValue is not null)
        {
            _formModel.Value = _selectedValue.Name;
        }
        UpdateValueState();
    }
 
    private void Cancel()
    {
        Dialog!.CancelAsync();
    }
 
    private void Enable()
    {
        Dialog!.CloseAsync(DialogResult.Ok(new FilterDialogResult { Filter = Content.Filter, Enable = true }));
    }
 
    private void Disable()
    {
        Dialog!.CloseAsync(DialogResult.Ok(new FilterDialogResult { Filter = Content.Filter, Disable = true }));
    }
 
    private void Delete()
    {
        Dialog!.CloseAsync(DialogResult.Ok(new FilterDialogResult { Filter = Content.Filter, Delete = true }));
    }
 
    private void Apply()
    {
        string value;
        if (_formModel.ValueIsNumeric)
        {
            value = _formModel.NumericValue!.Value.ToString(CultureInfo.InvariantCulture);
        }
        else
        {
            value = _formModel.Value!;
        }
 
        if (Content.Filter is { } filter)
        {
            filter.Field = _formModel.Parameter!.Id!;
            filter.Condition = _formModel.Condition!.Id;
            filter.Value = value;
 
            Dialog!.CloseAsync(DialogResult.Ok(new FilterDialogResult() { Filter = filter, Delete = false }));
        }
        else
        {
            filter = new FieldTelemetryFilter
            {
                Field = _formModel.Parameter!.Id!,
                Condition = _formModel.Condition!.Id,
                Value = value
            };
 
            Dialog!.CloseAsync(DialogResult.Ok(new FilterDialogResult() { Filter = filter, Add = true }));
        }
    }
 
    private async Task OpenDatePickerAsync()
    {
        _jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Dialogs/FilterDialog.razor.js");
        await _jsModule.InvokeVoidAsync("showPicker", _datePickerInput);
    }
 
    private void OnDateTimePickerChanged(ChangeEventArgs e)
    {
        // The datetime-local input returns a value in "YYYY-MM-DDThh:mm:ss" format (local time).
        if (e.Value is string dateStr &&
            DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var localDateTime))
        {
            _formModel.Value = FormatIsoDate(localDateTime);
        }
    }
 
    private static string FormatIsoDate(DateTime dateTime)
    {
        // Format as ISO 8601 without trailing zeros on fractional seconds.
        // e.g. "2024-01-15T09:30:00" or "2024-01-15T09:30:00.12"
        return dateTime.Ticks % TimeSpan.TicksPerSecond == 0
            ? dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture)
            : dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture);
    }
 
    public async ValueTask DisposeAsync()
    {
        await _disposeCts.CancelAsync();
        _disposeCts.Dispose();
        await JSInteropHelpers.SafeDisposeAsync(_jsModule);
    }
 
    private sealed class FieldValue
    {
        public required string Value { get; init; }
        public required int Count { get; init; }
    }
}