File: Model\ResourceTagStore.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.Text;
using Aspire.Dashboard.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
 
namespace Aspire.Dashboard.Model;
 
/// <summary>
/// Stores the tags users assign to resources.
/// </summary>
/// <remarks>
/// Tags describe the application rather than a single run, so they're stored in a settings database shared by every
/// run of the application instead of the per-run database, which is replaced on each run and pruned. Tags are keyed
/// by the resource's display name because replica names have a random suffix that changes on every run; a tag
/// therefore applies to every replica of a resource. When the dashboard doesn't persist data, tags are only kept in
/// memory.
/// </remarks>
public sealed class ResourceTagStore
{
    internal const int MaxTagLength = 50;
    private const string SettingsName = "resource tags";
 
    private readonly Lock _lock = new();
    private readonly DashboardSettingsDatabase _database;
    private readonly Dictionary<string, List<string>> _tagsByResource = new(StringComparers.ResourceName);
    private bool _loaded;
 
    public ResourceTagStore(IOptions<DashboardOptions> options, ILogger<ResourceTagStore> logger)
        : this(DashboardSettingsDatabase.GetDatabasePath(options.Value), logger)
    {
    }
 
    internal ResourceTagStore(string? databasePath, ILogger logger)
    {
        _database = new DashboardSettingsDatabase(databasePath, logger);
    }
 
    /// <summary>
    /// Gets the path of the settings database, or <see langword="null"/> when tags are only kept in memory.
    /// </summary>
    internal string? DatabasePath => _database.DatabasePath;
 
    /// <summary>
    /// Raised after tags are added or removed. Handlers can be invoked on any thread.
    /// </summary>
    public event Action? Changed;
 
    /// <summary>
    /// Gets the tags of a resource in the order they were added.
    /// </summary>
    /// <param name="resourceName">The display name of the resource.</param>
    public IReadOnlyList<string> GetTags(string resourceName)
    {
        lock (_lock)
        {
            EnsureLoaded();
            return _tagsByResource.TryGetValue(resourceName, out var tags) ? tags.ToArray() : [];
        }
    }
 
    /// <summary>
    /// Gets every tag assigned to at least one resource, ordered by name.
    /// </summary>
    public IReadOnlyList<string> GetAllTags()
    {
        lock (_lock)
        {
            EnsureLoaded();
            return GetAllTagsCore().Order(StringComparer.CurrentCultureIgnoreCase).ToArray();
        }
    }
 
    /// <summary>
    /// Gets a snapshot of the tags of every tagged resource, keyed by resource display name.
    /// </summary>
    public IReadOnlyDictionary<string, IReadOnlyList<string>> GetTagsByResource()
    {
        lock (_lock)
        {
            EnsureLoaded();
            return _tagsByResource.ToDictionary(kvp => kvp.Key, kvp => (IReadOnlyList<string>)kvp.Value.ToArray(), StringComparers.ResourceName);
        }
    }
 
    /// <summary>Gets editable tags merged with immutable tags in the selected run's resource snapshots.</summary>
    public IReadOnlyDictionary<string, IReadOnlyList<string>> GetTagsByResource(IEnumerable<ResourceViewModel> resources)
    {
        var tagsByResource = new Dictionary<string, IReadOnlyList<string>>(GetTagsByResource(), StringComparers.ResourceName);
        foreach (var resource in resources)
        {
            var appHostTags = resource.AppHostTags;
            if (appHostTags.Count > 0)
            {
                tagsByResource[resource.DisplayName] = appHostTags
                    .Concat(tagsByResource.GetValueOrDefault(resource.DisplayName) ?? [])
                    .Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
            }
        }
 
        return tagsByResource;
    }
 
    public IReadOnlyList<string> GetAllTags(IEnumerable<ResourceViewModel> resources) =>
        GetTagsByResource(resources).Values.SelectMany(tags => tags).Distinct(StringComparer.OrdinalIgnoreCase)
            .Order(StringComparer.CurrentCultureIgnoreCase).ToArray();
 
    /// <summary>
    /// Adds a tag to a resource. A tag that matches an existing tag ignoring case reuses the existing tag's casing.
    /// </summary>
    /// <param name="resourceName">The display name of the resource.</param>
    /// <param name="tag">The tag to add.</param>
    /// <returns><see langword="true"/> when the tag was added; otherwise, <see langword="false"/> when the tag is empty or already assigned.</returns>
    public bool AddTag(string resourceName, string tag)
    {
        ArgumentException.ThrowIfNullOrEmpty(resourceName);
 
        if (NormalizeTag(tag) is not { } normalizedTag)
        {
            return false;
        }
 
        lock (_lock)
        {
            EnsureLoaded();
 
            // Reusing the casing of an existing tag keeps a single group per tag in the tag list.
            normalizedTag = GetAllTagsCore().FirstOrDefault(t => string.Equals(t, normalizedTag, StringComparison.OrdinalIgnoreCase)) ?? normalizedTag;
 
            if (!_tagsByResource.TryGetValue(resourceName, out var tags))
            {
                tags = [];
                _tagsByResource[resourceName] = tags;
            }
            else if (tags.Contains(normalizedTag, StringComparer.OrdinalIgnoreCase))
            {
                return false;
            }
 
            tags.Add(normalizedTag);
            Persist(command =>
            {
                command.CommandText = "INSERT OR IGNORE INTO resource_tags (resource_name, tag) VALUES ($resourceName, $tag);";
                command.Parameters.AddWithValue("$resourceName", resourceName);
                command.Parameters.AddWithValue("$tag", normalizedTag);
            });
        }
 
        Changed?.Invoke();
        return true;
    }
 
    /// <summary>
    /// Removes a tag from a resource.
    /// </summary>
    /// <param name="resourceName">The display name of the resource.</param>
    /// <param name="tag">The tag to remove.</param>
    /// <returns><see langword="true"/> when the tag was removed; otherwise, <see langword="false"/>.</returns>
    public bool RemoveTag(string resourceName, string tag)
    {
        lock (_lock)
        {
            EnsureLoaded();
 
            if (!_tagsByResource.TryGetValue(resourceName, out var tags) ||
                tags.RemoveAll(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)) == 0)
            {
                return false;
            }
 
            if (tags.Count == 0)
            {
                _tagsByResource.Remove(resourceName);
            }
 
            Persist(command =>
            {
                command.CommandText = "DELETE FROM resource_tags WHERE resource_name = $resourceName AND tag = $tag;";
                command.Parameters.AddWithValue("$resourceName", resourceName);
                command.Parameters.AddWithValue("$tag", tag);
            });
        }
 
        Changed?.Invoke();
        return true;
    }
 
    /// <summary>
    /// Trims a tag and collapses inner whitespace so visually identical tags are equal.
    /// </summary>
    /// <returns>The normalized tag, or <see langword="null"/> when the tag is empty.</returns>
    internal static string? NormalizeTag(string? tag)
    {
        if (string.IsNullOrWhiteSpace(tag))
        {
            return null;
        }
 
        var builder = new StringBuilder(tag.Length);
        var pendingSpace = false;
        foreach (var character in tag.Trim())
        {
            if (char.IsWhiteSpace(character))
            {
                pendingSpace = true;
                continue;
            }
 
            if (pendingSpace)
            {
                builder.Append(' ');
                pendingSpace = false;
            }
 
            builder.Append(character);
        }
 
        var normalized = builder.ToString();
        return normalized.Length > MaxTagLength ? normalized[..MaxTagLength].TrimEnd() : normalized;
    }
 
    private IEnumerable<string> GetAllTagsCore() =>
        _tagsByResource.Values.SelectMany(tags => tags).Distinct(StringComparer.OrdinalIgnoreCase);
 
    private void EnsureLoaded()
    {
        if (_loaded)
        {
            return;
        }
 
        _loaded = true;
        _database.Load(SettingsName, connection =>
        {
            using (var createCommand = connection.CreateCommand())
            {
                // The tag column uses NOCASE so a tag can't be stored twice for a resource with different casing.
                createCommand.CommandText = """
                    CREATE TABLE IF NOT EXISTS resource_tags (
                        resource_name TEXT NOT NULL COLLATE NOCASE,
                        tag TEXT NOT NULL COLLATE NOCASE,
                        PRIMARY KEY (resource_name, tag)
                    );
                    """;
                createCommand.ExecuteNonQuery();
            }
 
            using var selectCommand = connection.CreateCommand();
            // rowid preserves the order tags were added in.
            selectCommand.CommandText = "SELECT resource_name, tag FROM resource_tags ORDER BY rowid;";
            using var reader = selectCommand.ExecuteReader();
            while (reader.Read())
            {
                var resourceName = reader.GetString(0);
                if (!_tagsByResource.TryGetValue(resourceName, out var tags))
                {
                    tags = [];
                    _tagsByResource[resourceName] = tags;
                }
 
                tags.Add(reader.GetString(1));
            }
        });
    }
 
    private void Persist(Action<SqliteCommand> configureCommand)
    {
        // A tag that couldn't be saved is still shown for the rest of the run. Tags are a convenience, so it isn't
        // worth failing the edit for.
        _database.TrySave(SettingsName, connection =>
        {
            using var command = connection.CreateCommand();
            configureCommand(command);
            command.ExecuteNonQuery();
        });
    }
}