File: Model\DashboardViewPreferences.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.Configuration;
using Aspire.Dashboard.Utils;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Model;
 
/// <summary>
/// How much room each resource takes in the resource list.
/// </summary>
public enum ResourceListDensity
{
    /// <summary>
    /// Two lines per resource: the name, then its state and type.
    /// </summary>
    Comfortable,
 
    /// <summary>
    /// Three lines per resource: the name, state and type, then health and primary endpoint details.
    /// </summary>
    Spacious,
 
    /// <summary>
    /// One line per resource, with its state, type and persistence in aligned columns on the right.
    /// </summary>
    Compact
}
 
/// <summary>
/// What the overview shows when all resources, a group or several resources are selected.
/// </summary>
public enum GroupOverview
{
    /// <summary>
    /// A responsive grid of cards for resources and parameters.
    /// </summary>
    Cards,
 
    /// <summary>
    /// The app's health, live activity, insights and recent problems.
    /// </summary>
    Summary,
 
    /// <summary>
    /// A relationship graph of the resources.
    /// </summary>
    Graph,
 
    /// <summary>
    /// The former table layout. Stored values are migrated to <see cref="Cards"/>.
    /// </summary>
    Resources
}
 
/// <summary>
/// How the header switches between the live run and recordings of previous runs.
/// </summary>
public enum RecordingsStyle
{
    /// <summary>
    /// A recordings icon that opens a side panel, with a pill in the header while a recording is viewed.
    /// </summary>
    Panel,
 
    /// <summary>
    /// A "Live run" dropdown next to the header buttons, listing the runs in a popup menu.
    /// </summary>
    Menu
}
 
/// <summary>
/// The view preferences chosen in the settings. They're kept in the browser's local storage, and shared by the
/// settings panel and the views that apply them, which update as soon as a preference changes.
/// </summary>
public sealed class DashboardViewPreferences(ILocalStorage localStorage, IOptionsMonitor<DashboardOptions> options)
{
    private Task? _loadTask;
    private GroupOverview _groupOverview;
 
    /// <summary>
    /// Gets the resource list's density.
    /// </summary>
    public ResourceListDensity Density { get; private set; }
 
    /// <summary>
    /// Gets what the overview of several resources shows.
    /// </summary>
    /// <remarks>
    /// A graph choice stored before Dashboard:UI:DisableResourceGraph was set shows the cards instead. The stored
    /// choice is kept, so the graph comes back if the setting is removed.
    /// </remarks>
    public GroupOverview GroupOverview
    {
        get => _groupOverview == GroupOverview.Graph && !IsGraphEnabled ? GroupOverview.Cards : _groupOverview;
        private set => _groupOverview = value;
    }
 
    /// <summary>
    /// Gets a value indicating whether the overview can show the resource graph, which Dashboard:UI:DisableResourceGraph turns off.
    /// </summary>
    public bool IsGraphEnabled => options.CurrentValue.UI.DisableResourceGraph != true;
 
    /// <summary>
    /// Gets how the header switches between runs.
    /// </summary>
    public RecordingsStyle RecordingsStyle { get; private set; }
 
    /// <summary>
    /// Raised when a preference changes.
    /// </summary>
    public event Action? Changed;
 
    /// <summary>
    /// Loads the stored preferences, once per circuit.
    /// </summary>
    public Task EnsureLoadedAsync() => _loadTask ??= LoadAsync();
 
    public async Task SetDensityAsync(ResourceListDensity density)
    {
        if (Density == density)
        {
            return;
        }
 
        Density = density;
        Changed?.Invoke();
        await localStorage.SetUnprotectedAsync(BrowserStorageKeys.ResourceListDensity, density.ToString()).ConfigureAwait(false);
    }
 
    public async Task SetGroupOverviewAsync(GroupOverview groupOverview)
    {
        // Graph deep links and shortcuts go through here, so this is where a disabled graph is enforced.
        if (GroupOverview == groupOverview || (groupOverview == GroupOverview.Graph && !IsGraphEnabled))
        {
            return;
        }
 
        GroupOverview = groupOverview;
        Changed?.Invoke();
        await localStorage.SetUnprotectedAsync(BrowserStorageKeys.GroupOverview, groupOverview.ToString()).ConfigureAwait(false);
    }
 
    public async Task SetRecordingsStyleAsync(RecordingsStyle recordingsStyle)
    {
        if (RecordingsStyle == recordingsStyle)
        {
            return;
        }
 
        RecordingsStyle = recordingsStyle;
        Changed?.Invoke();
        await localStorage.SetUnprotectedAsync(BrowserStorageKeys.RecordingsStyle, recordingsStyle.ToString()).ConfigureAwait(false);
    }
 
    private async Task LoadAsync()
    {
        try
        {
            var density = await ReadAsync<ResourceListDensity>(BrowserStorageKeys.ResourceListDensity).ConfigureAwait(false);
            var groupOverview = await ReadAsync<GroupOverview>(BrowserStorageKeys.GroupOverview).ConfigureAwait(false);
            if (groupOverview == GroupOverview.Resources)
            {
                groupOverview = GroupOverview.Cards;
                await localStorage.SetUnprotectedAsync(BrowserStorageKeys.GroupOverview, groupOverview.ToString()).ConfigureAwait(false);
            }
            var recordingsStyle = await ReadAsync<RecordingsStyle>(BrowserStorageKeys.RecordingsStyle).ConfigureAwait(false);
            if (density != Density || groupOverview != GroupOverview || recordingsStyle != RecordingsStyle)
            {
                Density = density;
                GroupOverview = groupOverview;
                RecordingsStyle = recordingsStyle;
                Changed?.Invoke();
            }
        }
        catch (JSDisconnectedException)
        {
            // The circuit is gone. Load again if another component asks.
            _loadTask = null;
        }
    }
 
    // Values are stored by name, so reordering the enums doesn't change a stored choice.
    private async Task<T> ReadAsync<T>(string key) where T : struct, Enum
    {
        var result = await localStorage.GetUnprotectedAsync<string>(key).ConfigureAwait(false);
        return result.Success && Enum.TryParse<T>(result.Value, out var value) && Enum.IsDefined(value) ? value : default;
    }
}