File: Components\Controls\Chart\MetricTable.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.Diagnostics;
using System.Globalization;
using Aspire.Dashboard.Components.Controls.Chart;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Controls;
 
public partial class MetricTable : ChartBase
{
    private SortedList<DateTimeOffset, MetricViewBase> _metrics = [];
    private List<ChartExemplar> _exemplars = [];
    private string _unitColumnHeader = string.Empty;
    private IJSObjectReference? _jsModule;
 
    private OtlpInstrumentSummary? _instrument;
    private bool _showCount;
    private DateTimeOffset? _lastUpdate;
 
    private IQueryable<MetricViewBase> _metricsView => _metrics.Values.AsEnumerable().Reverse().ToList().AsQueryable();
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required IDialogService DialogService { get; init; }
 
    public bool OnlyShowValueChangesInTable { get; set; } = true;
 
    protected override async Task OnChartUpdatedAsync(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime, CancellationToken cancellationToken)
    {
        Debug.Assert(_jsModule != null, "The module should be initialized before chart data is sent to control.");
 
        // Only update the data grid once per second to avoid additional DOM re-renders.
        if (inProgressDataTime - _lastUpdate < TimeSpan.FromSeconds(1))
        {
            return;
        }
 
        _lastUpdate = inProgressDataTime;
 
        if (!Equals(_instrument?.Name, InstrumentViewModel.Instrument?.Name) || _showCount != InstrumentViewModel.ShowCount)
        {
            _metrics.Clear();
        }
 
        // Store local values from view model on data update.
        // This keeps the instrument and data consistent while the view model is updated.
        _instrument = InstrumentViewModel.Instrument;
        _showCount = InstrumentViewModel.ShowCount;
 
        _metrics = UpdateMetrics(out var xValuesToAnnounce, traces, xValues, exemplars);
        _exemplars = exemplars;
 
        if (xValuesToAnnounce.Count == 0)
        {
            return;
        }
 
        await Task.Delay(500, cancellationToken);
 
        var metricView = _metricsView.ToList();
        List<int> indices = [];
 
        for (var i = 0; i < metricView.Count; i++)
        {
            if (xValuesToAnnounce.Contains(metricView[i].DateTime))
            {
                indices.Add(i);
            }
        }
 
        try
        {
            await _jsModule.InvokeVoidAsync("announceDataGridRows", "metric-table-container", indices);
        }
        catch (ObjectDisposedException)
        {
            // This call happens after a delay. To ensure there is no chance of a race between disposing
            // and using the module, catch and ignore disposed exceptions.
        }
    }
 
    private async Task OpenExemplarsDialogAsync(MetricViewBase metric)
    {
        var vm = new ExemplarsDialogViewModel
        {
            Exemplars = metric.Exemplars,
            Applications = Applications,
            Instrument = InstrumentViewModel.Instrument!
        };
        var parameters = new DialogParameters
        {
            Title = DialogsLoc[nameof(Resources.Dialogs.ExemplarsDialogTitle)],
            PrimaryAction = DialogsLoc[nameof(Resources.Dialogs.ExemplarsDialogCloseButtonText)],
            SecondaryAction = string.Empty,
            Width = "800px",
            Height = "auto"
        };
        await DialogService.ShowDialogAsync<ExemplarsDialog>(vm, parameters);
    }
 
    private SortedList<DateTimeOffset, MetricViewBase> UpdateMetrics(out ISet<DateTimeOffset> addedXValues, List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars)
    {
        var newMetrics = new SortedList<DateTimeOffset, MetricViewBase>();
 
        _unitColumnHeader = traces.First().Name;
 
        for (var i = 0; i < xValues.Count; i++)
        {
            var xValue = xValues[i];
            var previousMetric = newMetrics.LastOrDefault(dt => dt.Key < xValue).Value;
 
            if (IsHistogramInstrument() && !_showCount)
            {
                var iTmp = i;
                var traceValuesByPercentile = traces.ToDictionary(trace => trace.Percentile!.Value, trace => trace.Values[iTmp]);
                var valueDiffs = traceValuesByPercentile.Select(kvp =>
                {
                    var (percentile, traceValue) = kvp;
                    if (traceValue is not null
                        && previousMetric is HistogramMetricView histogramMetricView
                        && histogramMetricView.Percentiles[percentile].Value is { } previousPercentileValue)
                    {
                        return traceValue.Value - previousPercentileValue;
                    }
 
                    return traceValue;
                }).ToList();
 
                if (traceValuesByPercentile.Values.All(value => value is null))
                {
                    continue;
                }
 
                if (OnlyShowValueChangesInTable && valueDiffs.All(diff => DoubleEquals(diff, 0)))
                {
                    continue;
                }
 
                newMetrics.Add(xValue, CreateHistogramMetricView());
 
                MetricViewBase CreateHistogramMetricView()
                {
                    var percentiles = new SortedDictionary<int, (string Name, double? Value, ValueDirectionChange Direction)>();
                    for (var traceIndex = 0; traceIndex < traces.Count; traceIndex++)
                    {
                        var trace = traces[traceIndex];
                        percentiles.Add(trace.Percentile!.Value, (trace.Name, trace.Values[i], GetDirectionChange(valueDiffs[traceIndex])));
                    }
 
                    return new HistogramMetricView
                    {
                        DateTime = xValue,
                        Percentiles = percentiles,
                        Exemplars = []
                    };
                }
            }
            else
            {
                var trace = traces.Single();
                var yValue = trace.Values[i];
                var valueDiff = yValue is not null && (previousMetric as MetricValueView)?.Value is { } previousValue ? yValue - previousValue : yValue;
 
                if (yValue is null)
                {
                    continue;
                }
 
                if (OnlyShowValueChangesInTable && DoubleEquals(valueDiff, 0d))
                {
                    continue;
                }
 
                newMetrics.Add(xValue, CreateMetricView());
 
                MetricViewBase CreateMetricView()
                {
                    return new MetricValueView
                    {
                        DateTime = xValue,
                        Value = yValue,
                        ValueChange = GetDirectionChange(valueDiff),
                        Exemplars = []
                    };
                }
            }
        }
 
        // Associate exemplars with rows. Need to happen after rows are calculated because they could be skipped (e.g. unchanged data)
        for (var i = newMetrics.Count - 1; i >= 0; i--)
        {
            var current = newMetrics.GetValueAtIndex(i);
            var endTime = (i != newMetrics.Count - 1) ? current.DateTime : (DateTimeOffset?)null;
            var startTime = (i > 0) ? newMetrics.GetKeyAtIndex(i - 1) : (DateTimeOffset?)null;
 
            var currentExemplars = exemplars.Where(e => (e.Start >= startTime || startTime == null) && (e.Start < endTime || endTime == null)).ToList();
            current.Exemplars.AddRange(currentExemplars);
        }
 
        Debug.Assert(exemplars.Count == newMetrics.Sum(m => m.Value.Exemplars.Count), $"Expected {exemplars.Count} exemplars but got {newMetrics.Sum(m => m.Value.Exemplars.Count)} exemplars.");
 
        var latestCurrentMetric = _metrics.Keys.OfType<DateTimeOffset?>().LastOrDefault();
        addedXValues = newMetrics.Keys.Where(newKey => newKey > latestCurrentMetric || latestCurrentMetric == null).ToHashSet();
        return newMetrics;
    }
 
    private static bool DoubleEquals(double? a, double? b)
    {
        if (a is not null && b is not null)
        {
            return Math.Abs(a.Value - b.Value) < 0.00002; // arbitrarily small number
        }
 
        if ((a is null && b is not null) || (a is not null && b is null))
        {
            return false;
        }
 
        return true;
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "/Components/Controls/Chart/MetricTable.razor.js");
        }
 
        await base.OnAfterRenderAsync(firstRender);
    }
 
    // The first data is used to initialize the chart. The module needs to be ready first.
    protected override bool ReadyForData() => _jsModule != null;
 
    private bool IsHistogramInstrument()
    {
        return _instrument?.Type == OtlpInstrumentType.Histogram;
    }
 
    private bool ShowPercentiles()
    {
        return IsHistogramInstrument() && !_showCount;
    }
 
    private Task SettingsChangedAsync() => InvokeAsync(StateHasChanged);
 
    private static ValueDirectionChange GetDirectionChange(double? comparisonResult)
    {
        if (comparisonResult > 0)
        {
            return ValueDirectionChange.Up;
        }
 
        return comparisonResult < 0 ? ValueDirectionChange.Down : ValueDirectionChange.Constant;
    }
 
    protected override async ValueTask DisposeAsync(bool disposing)
    {
        await base.DisposeAsync(disposing);
 
        if (disposing)
        {
            await JSInteropHelpers.SafeDisposeAsync(_jsModule);
        }
    }
 
    public abstract record MetricViewBase
    {
        public required DateTimeOffset DateTime { get; set; }
        public required List<ChartExemplar> Exemplars { get; set; }
    }
 
    public record MetricValueView : MetricViewBase
    {
        public required double? Value { get; set; }
        public required ValueDirectionChange? ValueChange { get; init; }
    }
 
    public record HistogramMetricView : MetricViewBase
    {
        public required SortedDictionary<int, (string Name, double? Value, ValueDirectionChange Direction)> Percentiles { get; init; }
    }
 
    public enum ValueDirectionChange
    {
        Up,
        Down,
        Constant
    }
 
    private (Icon Icon, string Title)? GetIconAndTitleForDirection(ValueDirectionChange? directionChange)
    {
        return directionChange switch
        {
            ValueDirectionChange.Up => (new Icons.Filled.Size16.ArrowCircleUp().WithColor(Color.Success), Loc[nameof(ControlsStrings.MetricTableValueIncreased)]),
            ValueDirectionChange.Down => (new Icons.Filled.Size16.ArrowCircleDown().WithColor(Color.Warning), Loc[nameof(ControlsStrings.MetricTableValueDecreased)]),
            ValueDirectionChange.Constant => (new Icons.Filled.Size16.ArrowCircleRight().WithColor(Color.Info), Loc[nameof(ControlsStrings.MetricTableValueNoChange)]),
            _ => null
        };
    }
 
    private static string FormatMetricValue(double? value)
    {
        return value is null ? string.Empty : value.Value.ToString("F3", CultureInfo.CurrentCulture);
    }
}