File: Model\DashboardSettingsDatabase.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.Shared;
using Microsoft.Data.Sqlite;
 
namespace Aspire.Dashboard.Model;
 
/// <summary>
/// The application settings database, which keeps settings that describe the application rather than a single run,
/// such as resource tags, resource order and pinned metrics.
/// </summary>
/// <remarks>
/// <para>
/// The settings are shared by every run of the application, so they're stored next to the runs instead of in the
/// per-run database, which is replaced on each run and pruned. Each settings store uses its own tables in the file.
/// </para>
/// <para>
/// Settings are a convenience, so every store handles a database it can't use the same way: when the settings
/// can't be loaded, the store keeps its settings in memory for the rest of the process and never writes to the file,
/// so a database that couldn't be read is never overwritten. When the dashboard doesn't persist data, settings are
/// only kept in memory too.
/// </para>
/// </remarks>
internal sealed class DashboardSettingsDatabase
{
    internal const string DatabaseFileName = "dashboard-settings.db";
 
    private readonly ILogger _logger;
    private string? _connectionString;
 
    public DashboardSettingsDatabase(string? databasePath, ILogger logger)
    {
        DatabasePath = databasePath;
        _logger = logger;
 
        if (databasePath is not null)
        {
            _connectionString = new SqliteConnectionStringBuilder
            {
                DataSource = databasePath,
                Mode = SqliteOpenMode.ReadWriteCreate,
                // Settings change rarely. Not pooling connections avoids keeping the file open between changes, so
                // the data directory can be deleted while the dashboard is running.
                Pooling = false,
                DefaultTimeout = 5
            }.ToString();
        }
    }
 
    /// <summary>
    /// Gets the path of the settings database, or <see langword="null"/> when settings are only kept in memory.
    /// </summary>
    public string? DatabasePath { get; }
 
    internal static string? GetDatabasePath(DashboardOptions options) => options.Data.PersistenceMode switch
    {
        DashboardPersistenceMode.None => null,
        _ => Path.Combine(DashboardRunStore.GetApplicationSettingsDirectory(options.Data.Directory, options.GetApplicationNameOrDefault()), DatabaseFileName)
    };
 
    /// <summary>
    /// Creates the store's tables and reads its settings. When that fails, the store's settings are kept in memory.
    /// </summary>
    /// <param name="settingsName">The settings, for log messages, for example "resource tags".</param>
    /// <param name="load">Reads the settings from an open connection.</param>
    public void Load(string settingsName, Action<SqliteConnection> load)
    {
        if (_connectionString is null)
        {
            return;
        }
 
        try
        {
            // Settings can include resource names, so restrict them to the current user like run data.
            DirectoryHelper.CreateWithOwnerOnlyPermissions(Path.GetDirectoryName(DatabasePath)!);
 
            using var connection = OpenConnection();
            load(connection);
        }
        catch (Exception ex) when (IsDatabaseException(ex))
        {
            _logger.LogWarning(ex, "Unable to load {SettingsName} from '{DatabasePath}'. Changes are kept in memory and won't be saved.", settingsName, DatabasePath);
            _connectionString = null;
        }
    }
 
    /// <summary>
    /// Saves a change to the store's settings.
    /// </summary>
    /// <param name="settingsName">The settings, for log messages, for example "resource tags".</param>
    /// <param name="save">Writes the change with an open connection.</param>
    /// <returns>
    /// <see langword="false"/> when the change couldn't be saved; otherwise, <see langword="true"/>, including when
    /// settings are only kept in memory.
    /// </returns>
    public bool TrySave(string settingsName, Action<SqliteConnection> save)
    {
        if (_connectionString is null)
        {
            return true;
        }
 
        try
        {
            using var connection = OpenConnection();
            save(connection);
            return true;
        }
        catch (Exception ex) when (IsDatabaseException(ex))
        {
            _logger.LogWarning(ex, "Unable to save {SettingsName} to '{DatabasePath}'.", settingsName, DatabasePath);
            return false;
        }
    }
 
    // The file can be locked by another process, deleted with its directory, or not accessible to the current user.
    private static bool IsDatabaseException(Exception ex) => ex is SqliteException or IOException or UnauthorizedAccessException;
 
    private SqliteConnection OpenConnection()
    {
        var connection = new SqliteConnection(_connectionString);
        connection.Open();
        return connection;
    }
}